From 317877bc029c84d2620aa9d317967f6afa5c6739 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 8 Nov 2026 22:15:54 +0100 Subject: [PATCH] fix(migrate): pass the new pool's boost vault accounts to migrate_v2 PumpSwap creates the pool's boost vault during the migration and reverts without its authority and quote token account, which the IDL doesn't list. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 + src/pumpfun_cli/protocol/address.py | 6 ++ src/pumpfun_cli/protocol/instructions.py | 11 ++- .../test_protocol/test_extras_instructions.py | 56 ++++++++++++ tests/test_protocol/test_instructions.py | 2 +- tests/test_surfpool/test_extras.py | 87 +++++++++++++++++++ 6 files changed, 163 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35a4de4..591fd62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,9 @@ lists; these are the ones this CLI depends on. pool, then `pool-v2` **only on a canonical pool** (`coin_creator` set), then the buyback recipient and its quote ATA last. The canonical pool is derived from the mint and the curve's quote mint. +- `migrate_v2` also carries accounts the IDL omits: after its 27, the new pool's + boost vault authority (`["boost_vault", pool]` on PumpSwap) and that + authority's quote token account. Without them PumpSwap reverts with 6027. - `send_tx` sends v0 and falls back to v1 when the transaction exceeds 1232 bytes (a cashback-pool buy, a launch with a buy). v1 carries the compute budget in its header. diff --git a/src/pumpfun_cli/protocol/address.py b/src/pumpfun_cli/protocol/address.py index 5532cf2..b905c38 100644 --- a/src/pumpfun_cli/protocol/address.py +++ b/src/pumpfun_cli/protocol/address.py @@ -149,6 +149,12 @@ def derive_amm_pool(mint: Pubkey, quote_mint: Pubkey = WSOL_MINT) -> Pubkey: return addr +def derive_amm_boost_vault_authority(pool: Pubkey) -> Pubkey: + """Derive a PumpSwap pool's boost vault authority PDA.""" + addr, _ = Pubkey.find_program_address([b"boost_vault", bytes(pool)], PUMP_AMM_PROGRAM) + return addr + + def derive_amm_lp_mint(pool: Pubkey) -> Pubkey: """Derive a PumpSwap pool's LP mint.""" addr, _ = Pubkey.find_program_address([b"pool_lp_mint", bytes(pool)], PUMP_AMM_PROGRAM) diff --git a/src/pumpfun_cli/protocol/instructions.py b/src/pumpfun_cli/protocol/instructions.py index 668d6c9..f50df67 100644 --- a/src/pumpfun_cli/protocol/instructions.py +++ b/src/pumpfun_cli/protocol/instructions.py @@ -20,6 +20,7 @@ from spl.token.models import CloseAccountParams, InitializeAccountParams from pumpfun_cli.protocol.address import ( + derive_amm_boost_vault_authority, derive_amm_creator_vault, derive_amm_fee_config, derive_amm_global_volume_accumulator, @@ -654,12 +655,15 @@ def build_migrate_v2_instruction( ) -> Instruction: """Move a complete curve's liquidity into its canonical PumpSwap pool. - `withdraw_authority` is read from pump.fun's Global account. + `withdraw_authority` is read from pump.fun's Global account. After the IDL's + 27 accounts come the new pool's boost vault authority and its quote token + account: PumpSwap creates the boost vault during the migration. """ bonding_curve = derive_bonding_curve(mint) pool_authority = derive_pool_authority(mint) pool = derive_amm_pool(mint, quote_mint) lp_mint = derive_amm_lp_mint(pool) + boost_vault_authority = derive_amm_boost_vault_authority(pool) return Instruction( PUMP_PROGRAM, MIGRATE_V2_DISCRIMINATOR, @@ -691,5 +695,10 @@ def build_migrate_v2_instruction( _meta(RENT), _meta(PUMP_EVENT_AUTHORITY), _meta(PUMP_PROGRAM), + _meta(boost_vault_authority), + _meta( + find_ata(boost_vault_authority, quote_mint, quote_token_program), + writable=True, + ), ], ) diff --git a/tests/test_protocol/test_extras_instructions.py b/tests/test_protocol/test_extras_instructions.py index b95a348..1d2fae2 100644 --- a/tests/test_protocol/test_extras_instructions.py +++ b/tests/test_protocol/test_extras_instructions.py @@ -3,6 +3,7 @@ from solders.pubkey import Pubkey from pumpfun_cli.protocol.address import ( + derive_amm_boost_vault_authority, derive_amm_creator_vault, derive_amm_lp_mint, derive_amm_pool, @@ -80,3 +81,58 @@ def test_migrate_v2_targets_the_quote_specific_pool(): assert keys[15] == derive_amm_lp_mint(pool) assert keys[18] == find_ata(pool, QUOTE, TOKEN_2022_PROGRAM) assert keys[24] == RENT + + +def test_migrate_v2_appends_the_pools_boost_vault(): + keys = _keys( + build_migrate_v2_instruction( + mint=MINT, + user=USER, + withdraw_authority=Pubkey.new_unique(), + quote_mint=QUOTE, + quote_token_program=TOKEN_2022_PROGRAM, + ) + ) + authority = derive_amm_boost_vault_authority(derive_amm_pool(MINT, QUOTE)) + assert keys[27:] == [authority, find_ata(authority, QUOTE, TOKEN_2022_PROGRAM)] + + +# A migrate_v2 that landed on mainnet (SOL-priced coin), account by account. +MAINNET_MIGRATE_V2 = [ + "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", + "39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg", + "AvgXW4hP7ozPe8P2VVnyEcCgfyKGotsKsKTzVhHSpump", + "So11111111111111111111111111111111111111112", + "4SxVXjWAVRJtiQz3c15jEvyMVchLnYypphtGwPo4Ue1p", + "Gy7y5cqYHWujSgAkhqauMVuup1etnYBaZcd7EJwpTRSD", + "27gKsZFVnG1dsrUEiqo7JeXNeihmWbLFhwKk5fxaK16Q", + "9C4nRvhhVquCKATjDCx5FKvNS9PNgNqgyWy9AcoDjYv5", + "11111111111111111111111111111111", + "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", + "4nUaWTVN4tXficDXbDPBa1zkz22mMaKsSc9PcZVxkDLU", + "Cq2HTwfL5atMtti6X5kGxu2tJcfHBfTBgPibEg3kSp1X", + "EYjAvmPPvryzoEcUHg7tW3spfwvp8WyGhVdgNVAoh36i", + "EscT6HjfPeMG7zrgPSrxP6EEdZPTVL61RrbdRQdTgS4x", + "ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw", + "BHLrVmhAMT8SwM3pmwxboXaPmucPmG9F4SECdBEqsJRi", + "CdEVwiDne4DgfYtZv8YzF5g8rnXsrRp8QXHh3RED5M48", + "67vgDuzFqPZaCvRjgjBv1sHrPpn7i9WaVEinjeLXBKUr", + "9Q3LxsWLB3rMzD3FibrMkn1KMrqRwe9dvM4vv9C9EvwB", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR", + "SysvarRent111111111111111111111111111111111", + "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", + "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", + "4jAkipqL5ESZJBau53Lwa8XMhtuRuvMPtSDXyEroxwC7", + "3edD3kcShSfFwXYgeiWXhxNikatkKbbK2ww5thELSW8y", +] + + +def test_migrate_v2_matches_a_mainnet_migration(): + want = [Pubkey.from_string(k) for k in MAINNET_MIGRATE_V2] + built = build_migrate_v2_instruction(mint=want[2], user=want[7], withdraw_authority=want[1]) + assert _keys(built) == want + assert built.accounts[-1].is_writable and not built.accounts[-2].is_writable diff --git a/tests/test_protocol/test_instructions.py b/tests/test_protocol/test_instructions.py index d8745cc..a370e19 100644 --- a/tests/test_protocol/test_instructions.py +++ b/tests/test_protocol/test_instructions.py @@ -102,7 +102,7 @@ def assert_matches_idl(idl: dict, name: str, built: Instruction, prefix: int | N ( "migrate_v2", lambda: ix.build_migrate_v2_instruction(mint=MINT, user=USER, withdraw_authority=CREATOR), - 27, + 29, ), ] diff --git a/tests/test_surfpool/test_extras.py b/tests/test_surfpool/test_extras.py index ea007fa..01233c0 100644 --- a/tests/test_surfpool/test_extras.py +++ b/tests/test_surfpool/test_extras.py @@ -84,3 +84,90 @@ async def test_collect_creator_fees_no_fees( ) assert result["error"] == "no_fees" assert "No creator fees" in result["message"] + + +@pytest.mark.asyncio +async def test_migrate_a_completed_curve( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """Launch a coin, buy its whole curve, then migrate it into its PumpSwap pool.""" + from solana.rpc.async_api import AsyncClient + from solders.pubkey import Pubkey + + from pumpfun_cli.core.info import get_token_info + from pumpfun_cli.core.launch import launch_token + from pumpfun_cli.core.trade import buy_token + from pumpfun_cli.protocol.address import derive_amm_pool, derive_bonding_curve + from pumpfun_cli.protocol.client import RpcClient + from pumpfun_cli.protocol.curve import parse_bonding_curve + from pumpfun_cli.protocol.instructions import build_buy_v2_instruction + + client = AsyncClient(surfpool_rpc) + try: + await client.request_airdrop(funded_keypair.pubkey(), 200_000_000_000) + finally: + await client.close() + + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Migrate", + ticker="MIG", + description="migrate test", + uri="https://example.com/migrate.json", + confirm=True, + ) + assert "error" not in launched, launched + mint = launched["mint"] + mint_pk = Pubkey.from_string(mint) + + # A small buy creates the token account; buy_v2 then takes every token left. + bought = await buy_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=mint, + amount=0.01, + confirm=True, + ) + assert "error" not in bought, bought + client = RpcClient(surfpool_rpc) + try: + curve = parse_bonding_curve( + bytes((await client.get_account_info(derive_bonding_curve(mint_pk))).value.data) + ) + await client.send_tx( + [ + build_buy_v2_instruction( + mint=mint_pk, + creator=curve["creator"], + user=funded_keypair.pubkey(), + token_amount=curve["real_token_reserves"], + max_quote_cost=150_000_000_000, + ) + ], + [funded_keypair], + compute_units=400_000, + confirm=True, + ) + finally: + await client.close() + + migrated = await migrate_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=mint, + confirm=True, + ) + assert "error" not in migrated, migrated + + client = AsyncClient(surfpool_rpc) + try: + pool = await client.get_account_info(derive_amm_pool(mint_pk)) + finally: + await client.close() + assert pool.value is not None + info = await get_token_info(surfpool_rpc, mint) + assert info["graduated"] is True