diff --git a/CLAUDE.md b/CLAUDE.md index 3371f76..70fbaa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,15 @@ ruff check ruff check --fix ``` +## Pump.fun protocol notes (gotchas) + +- The vendored IDL at `idl/pump_fun_idl.json` is **incomplete**: it does not list `bonding-curve-v2` (BC trades) or `pool-v2` (PumpSwap trades). Both are required on-chain. `bonding-curve-v2` PDA seed is `["bonding-curve-v2", mint]` under the pump program; `pool-v2` is `["pool-v2", base_mint]` under the pump-amm program. Always cross-check account lists against a recent successful on-chain tx — IDL alone will produce broken code. +- BC `buy` ix is **18 accounts** (post 2026-04-28 upgrade). The trailing account is one of 8 `BREAKING_FEE_RECIPIENTS` (mutable), AFTER `bonding-curve-v2`. +- BC `sell` ix is **16 accounts non-cashback / 17 cashback**. Cashback path inserts `user_volume_accumulator` (PDA seed `["user_volume_accumulator", user]`) BEFORE `bonding-curve-v2`. Detect cashback from byte 82 of the bonding-curve account data, or via the `is_cashback_coin` field returned by the curve manager. +- The BondingCurve account is **83 bytes** (was 81): trailing fields are `is_mayhem_mode: bool` then `is_cashback_coin: bool`. PumpSwap Pool is **245 bytes** with the same `is_cashback_coin` byte at offset 244. +- The IDL instruction is `create_v2` (snake_case). `create_v2` args: `name (str), symbol (str), uri (str), creator (pubkey), is_mayhem_mode (bool), is_cashback_enabled (OptionBool 1B)`. `OptionBool` is a struct wrapping a single bool — serialized as 1 byte, not 2. +- `extreme_fast_mode` skips the curve-state RPC fetch — make sure event parsers populate `is_mayhem_mode` and `is_cashback_coin` on `TokenInfo` from the `CreateEvent` payload (otherwise mayhem coins use the wrong fee_recipient and cashback coins use the wrong sell account count). + ## Code Style & Conventions ### Python Style (Ruff Configuration) diff --git a/README.md b/README.md index 02a219a..3ba85dc 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,22 @@ This helps ensure reliable operation within your node provider's rate limits wit The IDLs under [`idl/`](idl/) are vendored from [pump-fun/pump-public-docs](https://github.com/pump-fun/pump-public-docs). To refresh, copy `pump.json`, `pump_amm.json`, `pump_fees.json` from that repo into `pump_fun_idl.json`, `pump_swap_idl.json`, `pump_fees.json` respectively, and reference the upstream commit hash in your commit message. +> **The IDL is incomplete.** It doesn't list two PDAs that the on-chain program actually requires: +> - `bonding-curve-v2` — required on every BC `buy` (18 accounts) and `sell` (16/17 accounts). Seed: `["bonding-curve-v2", mint]` under the pump program. +> - `pool-v2` — required on every PumpSwap `buy`/`sell`. Seed: `["pool-v2", base_mint]` under the pump-amm program. **Without it, pump-amm throws `AnchorError 6023 (Overflow)` after the trade transfers complete** — a misleading error code for a missing-account issue. +> +> Always cross-check your account lists against a recent successful on-chain tx (`getSignaturesForAddress` + `getTransaction`) before trusting the IDL. + +## 2026-04-28 program upgrade + +Pump.fun shipped a breaking program upgrade on **2026-04-28 16:00 UTC** ([BREAKING_FEE_RECIPIENT.md](https://github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md)). The bot is updated for it: + +- BC `buy` ix is now **18 accounts** (was 17). Trailing account is one of 8 `BREAKING_FEE_RECIPIENTS` (mutable), AFTER `bonding-curve-v2`. +- BC `sell` ix is now **16 accounts non-cashback / 17 cashback** (was 15/16). Same trailing fee recipient. +- PumpSwap `buy`/`sell` get **+2 accounts** appended after `pool-v2`: a fee recipient (readonly) and its quote-mint ATA (mutable). Counts: buy = 26 non-cashback / 27 cashback; sell = 24 / 26. Cashback pools insert `user_volume_accumulator_quote_ata` (writable) BEFORE `pool-v2` on buys; sells insert both that ATA and `user_volume_accumulator` (both writable) BEFORE `pool-v2`. Detect cashback via pool data byte 244. + +The 8 fee recipients are randomized per tx in code (per pump.fun's recommendation to spread program-tx throughput). + ## Changelog Quick note on a couple on a few new scripts in `/learning-examples`: diff --git a/learning-examples/pumpswap/manual_buy_pumpswap.py b/learning-examples/pumpswap/manual_buy_pumpswap.py index fc3c671..e91ca0c 100644 --- a/learning-examples/pumpswap/manual_buy_pumpswap.py +++ b/learning-examples/pumpswap/manual_buy_pumpswap.py @@ -100,6 +100,7 @@ BREAKING_FEE_RECIPIENTS = [ POOL_DISCRIMINATOR_SIZE = 8 POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored +POOL_IS_CASHBACK_OFFSET = 244 # Where is_cashback_coin flag is stored POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag # GlobalConfig structure offsets @@ -326,18 +327,11 @@ async def get_reserved_fee_recipient_pumpswap(client: AsyncClient) -> Pubkey: async def get_pumpswap_fee_recipients( client: AsyncClient, pool: Pubkey -) -> tuple[Pubkey, Pubkey]: - """Determine the correct fee recipient based on pool's mayhem mode status. - - This function checks if mayhem mode is enabled for the pool and returns - the appropriate fee recipient and their WSOL token account. - - Args: - client: Solana RPC client - pool: Address of the AMM pool +) -> tuple[Pubkey, Pubkey, bool]: + """Determine the correct fee recipient and whether the pool is cashback. Returns: - Tuple of (fee_recipient_pubkey, fee_recipient_token_account) + Tuple of (fee_recipient_pubkey, fee_recipient_token_account, is_cashback) """ response = await client.get_account_info(pool, encoding="base64") if not response.value or not response.value.data: @@ -346,23 +340,23 @@ async def get_pumpswap_fee_recipients( pool_data = response.value.data - # Check if mayhem mode flag exists and is enabled is_mayhem_mode = len(pool_data) >= POOL_MAYHEM_MODE_MIN_SIZE and bool( pool_data[POOL_MAYHEM_MODE_OFFSET] ) + is_cashback = len(pool_data) > POOL_IS_CASHBACK_OFFSET and bool( + pool_data[POOL_IS_CASHBACK_OFFSET] + ) - # Select appropriate fee recipient if is_mayhem_mode: fee_recipient = await get_reserved_fee_recipient_pumpswap(client) else: fee_recipient = STANDARD_PUMPSWAP_FEE_RECIPIENT - # Get the fee recipient's WSOL token account fee_recipient_token_account = get_associated_token_address( fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM ) - return (fee_recipient, fee_recipient_token_account) + return (fee_recipient, fee_recipient_token_account, is_cashback) # ============================================================================ @@ -483,9 +477,16 @@ async def buy_pump_swap( global_volume_accumulator = find_global_volume_accumulator() user_volume_accumulator = find_user_volume_accumulator(payer.pubkey()) - # Get fee recipient based on mayhem mode - fee_recipient, fee_recipient_token_account = await get_pumpswap_fee_recipients( - client, market + # Get fee recipient based on mayhem mode + detect cashback pool + ( + fee_recipient, + fee_recipient_token_account, + is_cashback, + ) = await get_pumpswap_fee_recipients(client, market) + + # WSOL ATA of user_volume_accumulator — only required for cashback pools. + user_volume_accumulator_quote_ata = get_associated_token_address( + user_volume_accumulator, SOL, SYSTEM_TOKEN_PROGRAM ) # Build account list for buy instruction @@ -528,23 +529,34 @@ async def buy_pump_swap( AccountMeta(pubkey=user_volume_accumulator, is_signer=False, is_writable=True), AccountMeta(pubkey=find_fee_config(), is_signer=False, is_writable=False), AccountMeta(pubkey=PUMP_FEE_PROGRAM, is_signer=False, is_writable=False), - # pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account. - AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False), ] - # 2 new accounts required from 2026-04-28 16:00 UTC pump-swap program upgrade, - # AFTER pool-v2: breaking-upgrade fee recipient (readonly, second-to-last) and - # its quote-mint ATA (mutable, last). Set to True to opt in. - # Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md - INCLUDE_BREAKING_FEE_ACCOUNTS = False # flip True after 2026-04-28 16:00 UTC - if INCLUDE_BREAKING_FEE_ACCOUNTS: - breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS) - breaking_fee_quote_ata = get_associated_token_address( - breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM + # Cashback pools require user_volume_accumulator_quote_ata (writable) BEFORE + # pool-v2. Confirmed against on-chain post-cutover cashback buy + # (sig 4JaWdExj6zzU3aGNWqNFhmtCyhbjRU3zLrsA3vASGu9krQrLjAfbBygP9i7yXmruSbuYn4StgMdMFBi22oQCfvjK). + if is_cashback: + accounts.append( + AccountMeta( + pubkey=user_volume_accumulator_quote_ata, + is_signer=False, + is_writable=True, + ) ) - accounts.extend([ - AccountMeta(pubkey=breaking_fee_recipient, is_signer=False, is_writable=False), - AccountMeta(pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True), - ]) + # pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account. + accounts.append( + AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False) + ) + # 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER + # pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable). + # Buy counts: 26 non-cashback / 27 cashback. + # Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md + breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS) + breaking_fee_quote_ata = get_associated_token_address( + breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM + ) + accounts.extend([ + AccountMeta(pubkey=breaking_fee_recipient, is_signer=False, is_writable=False), + AccountMeta(pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True), + ]) # Instruction data format: # discriminator (8 bytes) + amount_out (8 bytes) + max_in (8 bytes) + track_volume (1 byte) diff --git a/learning-examples/pumpswap/manual_sell_pumpswap.py b/learning-examples/pumpswap/manual_sell_pumpswap.py index 7b126da..7067bca 100644 --- a/learning-examples/pumpswap/manual_sell_pumpswap.py +++ b/learning-examples/pumpswap/manual_sell_pumpswap.py @@ -92,6 +92,7 @@ BREAKING_FEE_RECIPIENTS = [ POOL_DISCRIMINATOR_SIZE = 8 POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored +POOL_IS_CASHBACK_OFFSET = 244 # Where is_cashback_coin flag is stored POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag # GlobalConfig structure offsets @@ -246,6 +247,16 @@ def find_pool_v2(base_mint: Pubkey) -> Pubkey: return derived_address +def find_user_volume_accumulator(user: Pubkey) -> Pubkey: + """Derive the per-user volume accumulator PDA (pump-amm). Required for + cashback-pool sells (along with its WSOL ATA).""" + derived_address, _ = Pubkey.find_program_address( + [b"user_volume_accumulator", bytes(user)], + PUMP_AMM_PROGRAM_ID, + ) + return derived_address + + # ============================================================================ # Mayhem Mode Fee Handling # ============================================================================ @@ -279,18 +290,11 @@ async def get_reserved_fee_recipient_pumpswap(client: AsyncClient) -> Pubkey: async def get_pumpswap_fee_recipients( client: AsyncClient, pool: Pubkey -) -> tuple[Pubkey, Pubkey]: - """Determine the correct fee recipient based on pool's mayhem mode status. - - This function checks if mayhem mode is enabled for the pool and returns - the appropriate fee recipient and their WSOL token account. - - Args: - client: Solana RPC client - pool: Address of the AMM pool +) -> tuple[Pubkey, Pubkey, bool]: + """Determine fee recipient + whether the pool is cashback. Returns: - Tuple of (fee_recipient_pubkey, fee_recipient_token_account) + Tuple of (fee_recipient_pubkey, fee_recipient_token_account, is_cashback) """ response = await client.get_account_info(pool, encoding="base64") if not response.value or not response.value.data: @@ -299,12 +303,13 @@ async def get_pumpswap_fee_recipients( pool_data = response.value.data - # Check if mayhem mode flag exists and is enabled is_mayhem_mode = len(pool_data) >= POOL_MAYHEM_MODE_MIN_SIZE and bool( pool_data[POOL_MAYHEM_MODE_OFFSET] ) + is_cashback = len(pool_data) > POOL_IS_CASHBACK_OFFSET and bool( + pool_data[POOL_IS_CASHBACK_OFFSET] + ) - # Select appropriate fee recipient if is_mayhem_mode: fee_recipient = await get_reserved_fee_recipient_pumpswap(client) else: @@ -315,7 +320,7 @@ async def get_pumpswap_fee_recipients( fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM ) - return (fee_recipient, fee_recipient_token_account) + return (fee_recipient, fee_recipient_token_account, is_cashback) # ============================================================================ @@ -475,9 +480,18 @@ async def sell_pump_swap( print(f"Selling {token_balance_decimal} tokens") print(f"Minimum SOL output: {min_sol_output / LAMPORTS_PER_SOL:.10f} SOL") - # Get fee recipient based on mayhem mode - fee_recipient, fee_recipient_token_account = await get_pumpswap_fee_recipients( - client, market + # Get fee recipient based on mayhem mode + detect cashback pool + ( + fee_recipient, + fee_recipient_token_account, + is_cashback, + ) = await get_pumpswap_fee_recipients(client, market) + + # Cashback-pool sells append two writable accounts (user_volume_accumulator + # quote ATA, then the accumulator PDA itself) BEFORE pool-v2. + user_volume_accumulator = find_user_volume_accumulator(payer.pubkey()) + user_volume_accumulator_quote_ata = get_associated_token_address( + user_volume_accumulator, SOL, SYSTEM_TOKEN_PROGRAM ) # Build account list for sell instruction @@ -516,23 +530,38 @@ async def sell_pump_swap( ), AccountMeta(pubkey=find_fee_config(), is_signer=False, is_writable=False), AccountMeta(pubkey=PUMP_FEE_PROGRAM, is_signer=False, is_writable=False), - # pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account. - AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False), ] - # 2 new accounts required from 2026-04-28 16:00 UTC pump-swap program upgrade, - # AFTER pool-v2: breaking-upgrade fee recipient (readonly, second-to-last) and - # its quote-mint ATA (mutable, last). Set to True to opt in. - # Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md - INCLUDE_BREAKING_FEE_ACCOUNTS = False # flip True after 2026-04-28 16:00 UTC - if INCLUDE_BREAKING_FEE_ACCOUNTS: - breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS) - breaking_fee_quote_ata = get_associated_token_address( - breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM - ) + # Cashback pools require user_volume_accumulator_quote_ata + the accumulator + # PDA itself (both writable) BEFORE pool-v2. Confirmed against on-chain + # post-cutover cashback sell + # (sig 4ei1cJV7uaENJeb5p8prVKiTApTouTh94r9HqPNbj7oJH52X8mEiXhrVNKUgtB9WeZB8jZANnmuSkdTuJ59y8NP3). + if is_cashback: accounts.extend([ - AccountMeta(pubkey=breaking_fee_recipient, is_signer=False, is_writable=False), - AccountMeta(pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True), + AccountMeta( + pubkey=user_volume_accumulator_quote_ata, + is_signer=False, + is_writable=True, + ), + AccountMeta( + pubkey=user_volume_accumulator, is_signer=False, is_writable=True + ), ]) + # pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account. + accounts.append( + AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False) + ) + # 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER + # pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable). + # Sell counts: 24 non-cashback / 26 cashback. + # Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md + breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS) + breaking_fee_quote_ata = get_associated_token_address( + breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM + ) + accounts.extend([ + AccountMeta(pubkey=breaking_fee_recipient, is_signer=False, is_writable=False), + AccountMeta(pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True), + ]) # Instruction data format: discriminator (8 bytes) + amount (8 bytes) + min_out (8 bytes) # All integers are little-endian (<) diff --git a/learning-examples/pumpswap/sample_cashback_pumpswap.py b/learning-examples/pumpswap/sample_cashback_pumpswap.py new file mode 100644 index 0000000..7323bf4 --- /dev/null +++ b/learning-examples/pumpswap/sample_cashback_pumpswap.py @@ -0,0 +1,134 @@ +"""Post-cutover sampler: find a successful cashback PumpSwap buy/sell on mainnet. + +Goal: identify the seed/position of the +1 account that the program requires for +cashback pools (27-account buy / 26-account sell vs 26 / 24 non-cashback). + +Strategy: +1. Pull recent signatures for pAMM program. +2. For each tx, fetch full tx, find the pAMM buy/sell ix. +3. Resolve the pool account from the ix; fetch its data; check byte 244 + (is_cashback_coin) — only proceed if it's 1. +4. Print the full account list with counts so we can diff against the known + 26/24 non-cashback layout in manual_buy/sell_pumpswap.py. + +Usage: + uv run learning-examples/pumpswap/sample_cashback_pumpswap.py [LIMIT] + +Env: SOLANA_NODE_RPC_ENDPOINT (defaults to public mainnet) +""" + +import asyncio +import os +import sys + +from solana.rpc.async_api import AsyncClient +from solders.pubkey import Pubkey +from solders.signature import Signature + +PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA") +BUY_DISCRIMINATOR = bytes.fromhex("66063d1201daebea") +SELL_DISCRIMINATOR = bytes.fromhex("33e685a4017f83ad") + +# Pool layout: byte 244 = is_cashback_coin (per CLAUDE.md / PR #167 notes). +POOL_IS_CASHBACK_OFFSET = 244 + +RPC = os.environ.get( + "SOLANA_NODE_RPC_ENDPOINT", "https://api.mainnet-beta.solana.com" +) + + +async def is_cashback_pool(client: AsyncClient, pool: Pubkey) -> bool | None: + resp = await client.get_account_info(pool, encoding="base64") + if resp.value is None: + return None + data = resp.value.data + if len(data) <= POOL_IS_CASHBACK_OFFSET: + return False + return data[POOL_IS_CASHBACK_OFFSET] == 1 + + +def classify_ix(ix_data: bytes) -> str | None: + if ix_data.startswith(BUY_DISCRIMINATOR): + return "buy" + if ix_data.startswith(SELL_DISCRIMINATOR): + return "sell" + return None + + +async def inspect_tx(client: AsyncClient, sig: Signature) -> dict | None: + """Return diagnostic dict if this tx contains a cashback buy/sell.""" + resp = await client.get_transaction( + sig, encoding="base64", max_supported_transaction_version=0 + ) + if resp.value is None or resp.value.transaction.meta is None: + return None + if resp.value.transaction.meta.err is not None: + return None # only successful txs + + tx = resp.value.transaction.transaction + msg = tx.message + account_keys = list(msg.account_keys) + # include loaded addresses from ALTs + loaded = resp.value.transaction.meta.loaded_addresses + if loaded is not None: + account_keys.extend(loaded.writable) + account_keys.extend(loaded.readonly) + + for ix in msg.instructions: + program_id = account_keys[ix.program_id_index] + if program_id != PUMP_AMM_PROGRAM_ID: + continue + kind = classify_ix(bytes(ix.data)) + if kind is None: + continue + + # PumpSwap convention: account index 0 of the ix is the pool. + if not ix.accounts: + continue + pool = account_keys[ix.accounts[0]] + cashback = await is_cashback_pool(client, pool) + if not cashback: + continue + + return { + "sig": str(sig), + "kind": kind, + "pool": str(pool), + "n_accounts": len(ix.accounts), + "accounts": [str(account_keys[i]) for i in ix.accounts], + } + return None + + +async def main(limit: int = 200) -> None: + async with AsyncClient(RPC) as client: + print(f"Sampling up to {limit} recent pAMM signatures from {RPC}") + sigs_resp = await client.get_signatures_for_address( + PUMP_AMM_PROGRAM_ID, limit=limit + ) + sigs = [s.signature for s in sigs_resp.value if s.err is None] + print(f" got {len(sigs)} successful signatures") + + for sig in sigs: + try: + hit = await inspect_tx(client, sig) + except (ValueError, RuntimeError) as e: + print(f" [skip] {sig}: {e}") + continue + if hit is None: + continue + print() + print(f"=== CASHBACK {hit['kind'].upper()} ===") + print(f" sig: {hit['sig']}") + print(f" pool: {hit['pool']}") + print(f" count: {hit['n_accounts']} accounts") + for i, a in enumerate(hit["accounts"]): + print(f" [{i:2d}] {a}") + return + + print("No cashback PumpSwap buy/sell found in window.") + + +if __name__ == "__main__": + n = int(sys.argv[1]) if len(sys.argv) > 1 else 200 + asyncio.run(main(n))