diff --git a/CLAUDE.md b/CLAUDE.md index ff86347..3f6be0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,10 +178,10 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md). - `confirm_transaction` and `verify_transaction_succeeded` deliberately stay bools. Returning the enum would be silent: every member is truthy, so every `if await client.confirm_transaction(sig):` would start passing unconditionally. -- `calculate_price` returns `0.0` for a curve with no virtual token reserves — it - does not raise, and the seller rejects it before its own error handling. Never - store a non-positive read as the last known price or floor a sell against one; - `0.0` also satisfies the stop-loss comparison. +- `calculate_price` returns `0.0` for a curve with no virtual token reserves, and + raises `CurveGraduatedError` for a graduated one; price and sell then go through + the platform's `GraduatedMarket`. Never store a non-positive read as the last + known price or floor a sell against one; `0.0` satisfies the stop-loss check. ## Config notes diff --git a/cookbook/README.md b/cookbook/README.md index fdb097f..702337d 100644 --- a/cookbook/README.md +++ b/cookbook/README.md @@ -164,6 +164,8 @@ SOL, a tokenized stock, another coin. Amounts are in that asset. *Reward* coins | [`stonkfun/read/stonkfun_read_pool.py`](stonkfun/read/stonkfun_read_pool.py) | Price, quote asset, fees, transfer fee and graduation progress for one coin | | 💸 [`stonkfun/trade/stonkfun_buy_token.py`](stonkfun/trade/stonkfun_buy_token.py) | Spend an exact amount of the quote asset. `--dry-run` simulates | | 💸 [`stonkfun/trade/stonkfun_sell_token.py`](stonkfun/trade/stonkfun_sell_token.py) | Sell your position; `--close` also harvests withheld fees and closes the account | +| 💸 [`stonkfun/trade/stonkfun_buy_token_cpmm.py`](stonkfun/trade/stonkfun_buy_token_cpmm.py) | Buy a graduated coin from its Raydium CPMM pool. `--dry-run` simulates | +| 💸 [`stonkfun/trade/stonkfun_sell_token_cpmm.py`](stonkfun/trade/stonkfun_sell_token_cpmm.py) | Sell a graduated coin into its Raydium CPMM pool | ## legacy diff --git a/cookbook/pumpswap/pumpswap_buy_token.py b/cookbook/pumpswap/pumpswap_buy_token.py index 0ffe91d..a6c174c 100644 --- a/cookbook/pumpswap/pumpswap_buy_token.py +++ b/cookbook/pumpswap/pumpswap_buy_token.py @@ -118,14 +118,10 @@ POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16 POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag # GlobalConfig structure offsets -GLOBALCONFIG_DISCRIMINATOR_SIZE = 8 -GLOBALCONFIG_ADMIN_SIZE = 32 -GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32 -GLOBALCONFIG_RESERVED_FEE_OFFSET = ( - GLOBALCONFIG_DISCRIMINATOR_SIZE - + GLOBALCONFIG_ADMIN_SIZE - + GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE -) +# GlobalConfig.reserved_fee_recipient, the mayhem-mode fee recipient: after the +# discriminator, admin, lp and protocol fee bps (u64 each), disable_flags (u8), +# protocol_fee_recipients[8], coin_creator_fee bps (u64) and two authorities. +GLOBALCONFIG_RESERVED_FEE_OFFSET = 8 + 32 + 8 + 8 + 1 + 8 * 32 + 8 + 32 + 32 # Fee recipients STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string( diff --git a/cookbook/pumpswap/pumpswap_sell_token.py b/cookbook/pumpswap/pumpswap_sell_token.py index 72abf35..3a6885d 100644 --- a/cookbook/pumpswap/pumpswap_sell_token.py +++ b/cookbook/pumpswap/pumpswap_sell_token.py @@ -108,14 +108,10 @@ POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16 POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag # GlobalConfig structure offsets -GLOBALCONFIG_DISCRIMINATOR_SIZE = 8 -GLOBALCONFIG_ADMIN_SIZE = 32 -GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32 -GLOBALCONFIG_RESERVED_FEE_OFFSET = ( - GLOBALCONFIG_DISCRIMINATOR_SIZE - + GLOBALCONFIG_ADMIN_SIZE - + GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE -) +# GlobalConfig.reserved_fee_recipient, the mayhem-mode fee recipient: after the +# discriminator, admin, lp and protocol fee bps (u64 each), disable_flags (u8), +# protocol_fee_recipients[8], coin_creator_fee bps (u64) and two authorities. +GLOBALCONFIG_RESERVED_FEE_OFFSET = 8 + 32 + 8 + 8 + 1 + 8 * 32 + 8 + 32 + 32 # Fee recipients STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string( diff --git a/cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py b/cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py new file mode 100644 index 0000000..3e7d1b7 --- /dev/null +++ b/cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py @@ -0,0 +1,363 @@ +"""Buy a graduated StonkFun coin from its Raydium CPMM pool, using swap_base_input. + +WARNING: this submits a real transaction and spends real funds. + +Usage: + uv run cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py + uv run cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py 0.001 --dry-run + +AMOUNT is in the pool's quote asset and is spent in full. For SOL the script +wraps it into a throwaway account; for anything else you must already hold it. + +When a LaunchLab curve raises its target, Raydium migrates the coin to a CPMM +pool and the curve stops taking trades. The pool is not searched for: CPMM +derives it from the AMM config the platform names (`PlatformConfig.cpswap_config`) +and the two mints, ordered by their bytes. + +The price is the vault balances less the fees sitting in them unclaimed. The +pool takes its trade fee and, when enabled, a creator fee; a *reward* coin then +loses its transfer fee on the way to you. The expected amount accounts for all +three, the creator fee as if charged on the input, which is close enough for a +slippage floor. +""" + +import argparse +import asyncio +import hashlib +import os +import secrets +import struct +import sys +from pathlib import Path + +# solana_transaction_status.py lives in cookbook/solana/. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "solana")) + +import base58 +import solana_transaction_status as tx_status +from dotenv import load_dotenv +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.core import DataSliceOpts, MemcmpOpts, TxOptsModel +from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price +from solders.instruction import AccountMeta, Instruction +from solders.keypair import Keypair +from solders.message import MessageV0 +from solders.pubkey import Pubkey +from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed +from solders.transaction import VersionedTransaction +from spl.token.instructions import ( + close_account, + create_idempotent_associated_token_account, + get_associated_token_address, + initialize_account, +) +from spl.token.models import CloseAccountParams, InitializeAccountParams + +load_dotenv() + +RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") +PRIVATE_KEY = os.environ.get("SOLANA_PRIVATE_KEY") + +DEFAULT_SLIPPAGE = 0.1 +PRIORITY_FEE_MICROLAMPORTS = 1_000 +COMPUTE_UNIT_LIMIT = 150_000 + +LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj") +CPMM_PROGRAM = Pubkey.from_string("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C") +CPMM_AUTHORITY = Pubkey.find_program_address( + [b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM +)[0] +TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") +WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112") + +SWAP_BASE_INPUT = hashlib.sha256(b"global:swap_base_input").digest()[:8] +TOKEN_ACCOUNT_SIZE = 165 +TOKEN_ACCOUNT_RENT = 2_039_280 +FEE_RATE_DENOMINATOR = 1_000_000 +MIGRATED = 2 + +# LaunchLab PoolState: discriminator, epoch, auth_bump, status, then after the +# amounts and the vesting schedule, global_config and platform_config. +LAUNCHLAB_POOL_SIZE = 429 +LAUNCHLAB_STATUS_OFFSET = 17 +LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32 +LAUNCHLAB_BASE_MINT_OFFSET = LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32 +LAUNCHLAB_QUOTE_MINT_OFFSET = LAUNCHLAB_BASE_MINT_OFFSET + 32 +# PlatformConfig.cpswap_config: after the scales, fee_rate, name, web and img. +PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256 +# CPMM PoolState: ten pubkeys, five u8, seven u64, two u8, padding, two u64. +CPMM_KEYS = ( + "amm_config", + "pool_creator", + "token_0_vault", + "token_1_vault", + "lp_mint", + "token_0_mint", + "token_1_mint", + "token_0_program", + "token_1_program", + "observation_key", +) +CPMM_DECIMALS_OFFSET = 8 + 10 * 32 + 3 +CPMM_FEES_OFFSET = 8 + 10 * 32 + 5 + 8 +CPMM_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1 +CPMM_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6 +# AmmConfig: bump, disable flag, index u16, trade_fee_rate, three more u64, two +# owners, creator_fee_rate. +CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2 +CONFIG_CREATOR_FEE_OFFSET = CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32 +TOKEN_2022_EXTENSIONS_OFFSET = 166 +TRANSFER_FEE_CONFIG_EXTENSION = 1 + + +def transfer_fee(mint_data: bytes) -> tuple[int, int]: + """The higher of a mint's scheduled transfer fees, as (basis points, max raw).""" + offset = TOKEN_2022_EXTENSIONS_OFFSET + while offset + 4 <= len(mint_data): + kind, length = struct.unpack_from(" tuple[Pubkey, dict]: + """Find a graduated LaunchLab coin's CPMM pool, oriented around the coin. + + Raises: + ValueError: If the coin is not a LaunchLab coin or has not migrated + """ + found = await client.get_program_accounts( + LAUNCHLAB_PROGRAM, + encoding="base64", + data_slice=DataSliceOpts(offset=0, length=0), + filters=[ + LAUNCHLAB_POOL_SIZE, + MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)), + ], + ) + if not found.value: + raise ValueError(f"No LaunchLab pool found for {mint}") + curve = (await client.get_account_info(found.value[0].pubkey)).value.data + if curve[LAUNCHLAB_STATUS_OFFSET] != MIGRATED: + raise ValueError(f"{mint} is still on its curve — use stonkfun_buy_token.py") + platform_config = Pubkey.from_bytes( + curve[LAUNCHLAB_PLATFORM_CONFIG_OFFSET : LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32] + ) + quote_mint = Pubkey.from_bytes( + curve[LAUNCHLAB_QUOTE_MINT_OFFSET : LAUNCHLAB_QUOTE_MINT_OFFSET + 32] + ) + platform = (await client.get_account_info(platform_config)).value.data + amm_config = Pubkey.from_bytes( + platform[PLATFORM_CPSWAP_CONFIG_OFFSET : PLATFORM_CPSWAP_CONFIG_OFFSET + 32] + ) + token_0, token_1 = sorted([mint, quote_mint], key=bytes) + address = Pubkey.find_program_address( + [b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM + )[0] + data = (await client.get_account_info(address)).value.data + keys = { + name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)]) + for i, name in enumerate(CPMM_KEYS) + } + base, quote = (0, 1) if keys["token_0_mint"] == mint else (1, 0) + fees = struct.unpack_from("<4Q", data, CPMM_FEES_OFFSET) + creator_fees = struct.unpack_from("<2Q", data, CPMM_CREATOR_FEES_OFFSET) + accrued = [fees[i] + fees[2 + i] + creator_fees[i] for i in (0, 1)] + return address, { + "amm_config": keys["amm_config"], + "observation": keys["observation_key"], + "base_mint": mint, + "quote_mint": keys[f"token_{quote}_mint"], + "base_vault": keys[f"token_{base}_vault"], + "quote_vault": keys[f"token_{quote}_vault"], + "base_program": keys[f"token_{base}_program"], + "quote_program": keys[f"token_{quote}_program"], + "base_decimals": data[CPMM_DECIMALS_OFFSET + base], + "quote_decimals": data[CPMM_DECIMALS_OFFSET + quote], + "base_accrued": accrued[base], + "quote_accrued": accrued[quote], + "creator_fee_enabled": bool(data[CPMM_CREATOR_FEE_FLAG_OFFSET]), + } + + +async def buy(mint: Pubkey, amount: float, slippage: float, *, dry_run: bool) -> None: + """Spend exactly `amount` of the pool's quote asset on the coin.""" + payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY)) + user = payer.pubkey() + + async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client: + address, pool = await find_cpmm_pool(client, mint) + quote_unit = 10 ** pool["quote_decimals"] + base_unit = 10 ** pool["base_decimals"] + amount_in = int(amount * quote_unit) + + config, base_vault, quote_vault, mint_info = ( + await client.get_multiple_accounts( + [pool["amm_config"], pool["base_vault"], pool["quote_vault"], mint] + ) + ).value + fee_rate = struct.unpack_from("= {minimum_out / base_unit:,.6f} tokens") + + user_base = get_associated_token_address(user, mint, pool["base_program"]) + instructions = [ + set_compute_unit_limit(COMPUTE_UNIT_LIMIT), + set_compute_unit_price(PRIORITY_FEE_MICROLAMPORTS), + create_idempotent_associated_token_account( + user, user, mint, token_program_id=pool["base_program"] + ), + ] + close_after = [] + if pool["quote_mint"] == WSOL_MINT: + seed = secrets.token_hex(16) + user_quote = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM) + instructions += [ + create_account_with_seed( + CreateAccountWithSeedParams( + from_pubkey=user, + to_pubkey=user_quote, + base=user, + seed=seed, + lamports=amount_in + TOKEN_ACCOUNT_RENT, + space=TOKEN_ACCOUNT_SIZE, + owner=TOKEN_PROGRAM, + ) + ), + initialize_account( + InitializeAccountParams( + program_id=TOKEN_PROGRAM, + account=user_quote, + mint=WSOL_MINT, + owner=user, + ) + ), + ] + close_after.append( + close_account( + CloseAccountParams( + program_id=TOKEN_PROGRAM, + account=user_quote, + dest=user, + owner=user, + ) + ) + ) + else: + user_quote = get_associated_token_address( + user, pool["quote_mint"], pool["quote_program"] + ) + held = 0 + if (await client.get_account_info(user_quote)).value is not None: + held = int( + (await client.get_token_account_balance(user_quote)).value.amount + ) + if held < amount_in: + raise ValueError( + f"Need {amount_in} raw units of {pool['quote_mint']}, hold {held}" + ) + + accounts = [ + AccountMeta(user, is_signer=True, is_writable=True), + AccountMeta(CPMM_AUTHORITY, is_signer=False, is_writable=False), + AccountMeta(pool["amm_config"], is_signer=False, is_writable=False), + AccountMeta(address, is_signer=False, is_writable=True), + AccountMeta(user_quote, is_signer=False, is_writable=True), + AccountMeta(user_base, is_signer=False, is_writable=True), + AccountMeta(pool["quote_vault"], is_signer=False, is_writable=True), + AccountMeta(pool["base_vault"], is_signer=False, is_writable=True), + AccountMeta(pool["quote_program"], is_signer=False, is_writable=False), + AccountMeta(pool["base_program"], is_signer=False, is_writable=False), + AccountMeta(pool["quote_mint"], is_signer=False, is_writable=False), + AccountMeta(mint, is_signer=False, is_writable=False), + AccountMeta(pool["observation"], is_signer=False, is_writable=True), + ] + data = SWAP_BASE_INPUT + struct.pack(" None: + """Parse the command line and run the buy.""" + parser = argparse.ArgumentParser( + description="Buy a graduated StonkFun coin from its Raydium CPMM pool" + ) + parser.add_argument("mint", help="The coin's mint address") + parser.add_argument( + "amount", type=float, help="Amount to spend, in the quote asset" + ) + parser.add_argument( + "--slippage", + type=float, + default=DEFAULT_SLIPPAGE, + help=f"How far below the expected token count to accept (default {DEFAULT_SLIPPAGE})", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Simulate the buy instead of submitting it — spends nothing", + ) + args = parser.parse_args() + asyncio.run( + buy( + Pubkey.from_string(args.mint), + args.amount, + args.slippage, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py b/cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py new file mode 100644 index 0000000..3c3c628 --- /dev/null +++ b/cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py @@ -0,0 +1,366 @@ +"""Sell a graduated StonkFun coin into its Raydium CPMM pool, using swap_base_input. + +WARNING: this submits a real transaction and spends real funds. + +Usage: + uv run cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py + uv run cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py --amount 1000 --dry-run + +When a LaunchLab curve raises its target, Raydium migrates the coin to a CPMM +pool and the curve stops taking trades. The pool is not searched for: CPMM +derives it from the AMM config the platform names (`PlatformConfig.cpswap_config`) +and the two mints, ordered by their bytes. + +The price is the vault balances less the fees sitting in them unclaimed. The +pool charges its trade fee and, when enabled, a creator fee; a *reward* coin +also loses its transfer fee on the way into the pool. The expected payout +accounts for all three. SOL proceeds arrive through a throwaway wrapped-SOL +account closed in the same transaction. +""" + +import argparse +import asyncio +import hashlib +import os +import secrets +import struct +import sys +from pathlib import Path + +# solana_transaction_status.py lives in cookbook/solana/. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "solana")) + +import base58 +import solana_transaction_status as tx_status +from dotenv import load_dotenv +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.core import DataSliceOpts, MemcmpOpts, TxOptsModel +from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price +from solders.instruction import AccountMeta, Instruction +from solders.keypair import Keypair +from solders.message import MessageV0 +from solders.pubkey import Pubkey +from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed +from solders.transaction import VersionedTransaction +from spl.token.instructions import ( + close_account, + create_idempotent_associated_token_account, + get_associated_token_address, + initialize_account, +) +from spl.token.models import CloseAccountParams, InitializeAccountParams + +load_dotenv() + +RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") +PRIVATE_KEY = os.environ.get("SOLANA_PRIVATE_KEY") + +DEFAULT_SLIPPAGE = 0.1 +PRIORITY_FEE_MICROLAMPORTS = 1_000 +COMPUTE_UNIT_LIMIT = 150_000 + +LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj") +CPMM_PROGRAM = Pubkey.from_string("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C") +CPMM_AUTHORITY = Pubkey.find_program_address( + [b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM +)[0] +TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") +WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112") + +SWAP_BASE_INPUT = hashlib.sha256(b"global:swap_base_input").digest()[:8] +TOKEN_ACCOUNT_SIZE = 165 +TOKEN_ACCOUNT_RENT = 2_039_280 +FEE_RATE_DENOMINATOR = 1_000_000 +MIGRATED = 2 + +# LaunchLab PoolState: discriminator, epoch, auth_bump, status, then after the +# amounts and the vesting schedule, global_config and platform_config. +LAUNCHLAB_POOL_SIZE = 429 +LAUNCHLAB_STATUS_OFFSET = 17 +LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32 +LAUNCHLAB_BASE_MINT_OFFSET = LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32 +LAUNCHLAB_QUOTE_MINT_OFFSET = LAUNCHLAB_BASE_MINT_OFFSET + 32 +# PlatformConfig.cpswap_config: after the scales, fee_rate, name, web and img. +PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256 +# CPMM PoolState: ten pubkeys, five u8, seven u64, two u8, padding, two u64. +CPMM_KEYS = ( + "amm_config", + "pool_creator", + "token_0_vault", + "token_1_vault", + "lp_mint", + "token_0_mint", + "token_1_mint", + "token_0_program", + "token_1_program", + "observation_key", +) +CPMM_DECIMALS_OFFSET = 8 + 10 * 32 + 3 +CPMM_FEES_OFFSET = 8 + 10 * 32 + 5 + 8 +CPMM_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1 +CPMM_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6 +# AmmConfig: bump, disable flag, index u16, trade_fee_rate, three more u64, two +# owners, creator_fee_rate. +CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2 +CONFIG_CREATOR_FEE_OFFSET = CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32 +TOKEN_2022_EXTENSIONS_OFFSET = 166 +TRANSFER_FEE_CONFIG_EXTENSION = 1 + + +def transfer_fee(mint_data: bytes) -> tuple[int, int]: + """The higher of a mint's scheduled transfer fees, as (basis points, max raw).""" + offset = TOKEN_2022_EXTENSIONS_OFFSET + while offset + 4 <= len(mint_data): + kind, length = struct.unpack_from(" tuple[Pubkey, dict]: + """Find a graduated LaunchLab coin's CPMM pool, oriented around the coin. + + Raises: + ValueError: If the coin is not a LaunchLab coin or has not migrated + """ + found = await client.get_program_accounts( + LAUNCHLAB_PROGRAM, + encoding="base64", + data_slice=DataSliceOpts(offset=0, length=0), + filters=[ + LAUNCHLAB_POOL_SIZE, + MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)), + ], + ) + if not found.value: + raise ValueError(f"No LaunchLab pool found for {mint}") + curve = (await client.get_account_info(found.value[0].pubkey)).value.data + if curve[LAUNCHLAB_STATUS_OFFSET] != MIGRATED: + raise ValueError(f"{mint} is still on its curve — use stonkfun_sell_token.py") + platform_config = Pubkey.from_bytes( + curve[LAUNCHLAB_PLATFORM_CONFIG_OFFSET : LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32] + ) + quote_mint = Pubkey.from_bytes( + curve[LAUNCHLAB_QUOTE_MINT_OFFSET : LAUNCHLAB_QUOTE_MINT_OFFSET + 32] + ) + platform = (await client.get_account_info(platform_config)).value.data + amm_config = Pubkey.from_bytes( + platform[PLATFORM_CPSWAP_CONFIG_OFFSET : PLATFORM_CPSWAP_CONFIG_OFFSET + 32] + ) + token_0, token_1 = sorted([mint, quote_mint], key=bytes) + address = Pubkey.find_program_address( + [b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM + )[0] + data = (await client.get_account_info(address)).value.data + keys = { + name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)]) + for i, name in enumerate(CPMM_KEYS) + } + base, quote = (0, 1) if keys["token_0_mint"] == mint else (1, 0) + fees = struct.unpack_from("<4Q", data, CPMM_FEES_OFFSET) + creator_fees = struct.unpack_from("<2Q", data, CPMM_CREATOR_FEES_OFFSET) + accrued = [fees[i] + fees[2 + i] + creator_fees[i] for i in (0, 1)] + return address, { + "amm_config": keys["amm_config"], + "observation": keys["observation_key"], + "base_mint": mint, + "quote_mint": keys[f"token_{quote}_mint"], + "base_vault": keys[f"token_{base}_vault"], + "quote_vault": keys[f"token_{quote}_vault"], + "base_program": keys[f"token_{base}_program"], + "quote_program": keys[f"token_{quote}_program"], + "base_decimals": data[CPMM_DECIMALS_OFFSET + base], + "quote_decimals": data[CPMM_DECIMALS_OFFSET + quote], + "base_accrued": accrued[base], + "quote_accrued": accrued[quote], + "creator_fee_enabled": bool(data[CPMM_CREATOR_FEE_FLAG_OFFSET]), + } + + +async def sell( + mint: Pubkey, amount: float | None, slippage: float, *, dry_run: bool +) -> None: + """Sell `amount` tokens, or the whole balance, into the coin's CPMM pool.""" + payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY)) + user = payer.pubkey() + + async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client: + address, pool = await find_cpmm_pool(client, mint) + user_base = get_associated_token_address(user, mint, pool["base_program"]) + held = int((await client.get_token_account_balance(user_base)).value.amount) + base_unit = 10 ** pool["base_decimals"] + amount_in = held if amount is None else int(amount * base_unit) + if amount_in <= 0 or amount_in > held: + raise ValueError(f"Cannot sell {amount_in} raw; you hold {held}") + + config, base_vault, quote_vault, mint_info = ( + await client.get_multiple_accounts( + [pool["amm_config"], pool["base_vault"], pool["quote_vault"], mint] + ) + ).value + fee_rate = struct.unpack_from("= {minimum_out / quote_unit:.10f} quote") + + instructions = [ + set_compute_unit_limit(COMPUTE_UNIT_LIMIT), + set_compute_unit_price(PRIORITY_FEE_MICROLAMPORTS), + ] + close_after = [] + if pool["quote_mint"] == WSOL_MINT: + seed = secrets.token_hex(16) + user_quote = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM) + instructions += [ + create_account_with_seed( + CreateAccountWithSeedParams( + from_pubkey=user, + to_pubkey=user_quote, + base=user, + seed=seed, + lamports=TOKEN_ACCOUNT_RENT, + space=TOKEN_ACCOUNT_SIZE, + owner=TOKEN_PROGRAM, + ) + ), + initialize_account( + InitializeAccountParams( + program_id=TOKEN_PROGRAM, + account=user_quote, + mint=WSOL_MINT, + owner=user, + ) + ), + ] + close_after.append( + close_account( + CloseAccountParams( + program_id=TOKEN_PROGRAM, + account=user_quote, + dest=user, + owner=user, + ) + ) + ) + else: + user_quote = get_associated_token_address( + user, pool["quote_mint"], pool["quote_program"] + ) + instructions.append( + create_idempotent_associated_token_account( + user, + user, + pool["quote_mint"], + token_program_id=pool["quote_program"], + ) + ) + + accounts = [ + AccountMeta(user, is_signer=True, is_writable=True), + AccountMeta(CPMM_AUTHORITY, is_signer=False, is_writable=False), + AccountMeta(pool["amm_config"], is_signer=False, is_writable=False), + AccountMeta(address, is_signer=False, is_writable=True), + AccountMeta(user_base, is_signer=False, is_writable=True), + AccountMeta(user_quote, is_signer=False, is_writable=True), + AccountMeta(pool["base_vault"], is_signer=False, is_writable=True), + AccountMeta(pool["quote_vault"], is_signer=False, is_writable=True), + AccountMeta(pool["base_program"], is_signer=False, is_writable=False), + AccountMeta(pool["quote_program"], is_signer=False, is_writable=False), + AccountMeta(mint, is_signer=False, is_writable=False), + AccountMeta(pool["quote_mint"], is_signer=False, is_writable=False), + AccountMeta(pool["observation"], is_signer=False, is_writable=True), + ] + data = SWAP_BASE_INPUT + struct.pack(" None: + """Parse the command line and run the sell.""" + parser = argparse.ArgumentParser( + description="Sell a graduated StonkFun coin into its Raydium CPMM pool" + ) + parser.add_argument("mint", help="The coin's mint address") + parser.add_argument( + "--amount", + type=float, + default=None, + help="Tokens to sell, in whole tokens (default: your whole balance)", + ) + parser.add_argument( + "--slippage", + type=float, + default=DEFAULT_SLIPPAGE, + help=f"How far below the expected payout to accept (default {DEFAULT_SLIPPAGE})", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Simulate the sell instead of submitting it — spends nothing", + ) + args = parser.parse_args() + asyncio.run( + sell( + Pubkey.from_string(args.mint), + args.amount, + args.slippage, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/docs/launchlab-protocol.md b/docs/launchlab-protocol.md index 2d80db9..27ebf72 100644 --- a/docs/launchlab-protocol.md +++ b/docs/launchlab-protocol.md @@ -80,8 +80,25 @@ StonkFun reward coins are Token-2022 mints with a 1% or 3% transfer fee. first. Standard coins' mints have no transfer-fee extension, and harvesting against one fails, so only harvest when the account holds withheld fees. +## Graduation + +When a curve raises its target, Raydium's migrate wallet moves it to a pool, and +the curve takes no more trades. `PoolState.status` is 1 while it waits and 2 once +migrated; a migrated pool keeps its final reserves, so the curve manager reports +`graduated` and `calculate_price` raises rather than return a frozen price. + +`migrate_type` 1 means Raydium CPMM, which is where every StonkFun coin goes. The +CPMM pool is `PDA(["pool", amm_config, token_0, token_1])` under the CPMM program, +with `amm_config` the platform's `PlatformConfig.cpswap_config` and the two mints +ordered by their bytes, so it is derived, never searched for. A sell is +`swap_base_input`, 13 accounts; the creator fee accrues inside the pool and adds +none. Price is the vault balances less the protocol, fund and creator fees still +sitting in them. `migrate_type` 0, Raydium AMM v4, has no market in the bot; a +coin there is reported, not sold. + ## Verifiers -`verify_launchlab_trades`, `verify_launchlab_launch_parsing` and -`verify_cleanup_harvests_withheld_fees` cover all of the above against captured +`verify_launchlab_trades`, `verify_launchlab_launch_parsing`, +`verify_cleanup_harvests_withheld_fees` and `verify_sell_after_graduation` cover +all of the above against captured mainnet transactions. diff --git a/docs/pumpfun-protocol.md b/docs/pumpfun-protocol.md index d2ace6a..64dfdc7 100644 --- a/docs/pumpfun-protocol.md +++ b/docs/pumpfun-protocol.md @@ -115,6 +115,19 @@ The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-publi byte-identical to upstream, so it cannot be the reference: it lists 23 for `buy` against 25-26 on chain, and 21 for `sell` against 23-26. +## Graduation + +A complete curve has handed its reserves to PumpSwap: every reserve reads zero +and `complete` is set. The curve manager reports `graduated` instead of raising +on the zero reserves, and `calculate_price` raises `CurveGraduatedError`. + +The pool a migration creates is canonical and derived, not searched for: +`PDA(["pool", u16 0, pool_authority, base_mint, quote_mint])` under pump-amm, with +`pool_authority = PDA(["pool-authority", base_mint])` under the pump.fun program. +`platforms/pumpfun/pumpswap_market.py` sells into it with the account rules +above, taking the fee, mayhem and buyback recipients from PumpSwap's +`GlobalConfig` — the mayhem one is `reserved_fee_recipient`, at offset 385. + ## Coin creation - `create_v2` takes **eight args**: `name (str), symbol (str), uri (str), diff --git a/docs/regression-tests.md b/docs/regression-tests.md index feaf110..f6155c4 100644 --- a/docs/regression-tests.md +++ b/docs/regression-tests.md @@ -42,6 +42,7 @@ individual scripts for a subset. | `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx | | `verify_no_rpc_credentials_logged.py` | credentials masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first | | `verify_prices_without_hardcoded_sol_unit.py` | nothing in `core/`, `trading/` or `platforms/` scales an amount by `LAMPORTS_PER_SOL` | +| `verify_sell_after_graduation.py` | a graduated pump.fun curve and a migrated LaunchLab pool report `graduated` and refuse a price; the PumpSwap and CPMM pools derive from the mint; each market's sell matches a real sell on chain; prices come from the pools; the seller and the trader's price read switch to the market, and a pool not created yet sends nothing | | `verify_pumpswap_account_layout.py` | pump-amm's `pool-v2` account is gated on `coin_creator`, the buyback pair stays last, and base-token decimals are resolved rather than assumed; `--live` re-reads the authorized recipients from `GlobalConfig` | | `verify_launchlab_trades.py` | against real StonkFun trades, the LaunchLab curve math reproduces each buy and sell to the unit — fee rate read from the configs, transfer fee from the mint; price comes from virtual plus real reserves; the swap names the on-chain 18 accounts with fee vaults keyed by the pool's quote mint; an exact-in buy gets the configured amount unpadded; buy and sell floors are net of curve fee and transfer fee; single-token mode waits past a coin its filters refuse instead of exiting on it | | `verify_launchlab_launch_parsing.py` | legacy, v0 and v1 StonkFun launches parse from every account slot over blockSubscribe and geyser alike, lookup-table accounts included; the creator slot wins over the payer; a router-issued launch is found; letsbonk and StonkFun each keep to their own platform configs; LaunchLab listeners subscribe on platform configs, pump.fun on its program | diff --git a/src/core/quote_account.py b/src/core/quote_account.py new file mode 100644 index 0000000..7ebd317 --- /dev/null +++ b/src/core/quote_account.py @@ -0,0 +1,81 @@ +"""The token account a trade's quote side settles through.""" + +import secrets + +from solders.instruction import Instruction +from solders.pubkey import Pubkey +from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed +from spl.token.instructions import ( + close_account, + create_idempotent_associated_token_account, + get_associated_token_address, + initialize_account, +) +from spl.token.models import CloseAccountParams, InitializeAccountParams + +from core.pubkeys import ( + TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE, + TOKEN_ACCOUNT_SIZE, + WSOL_MINT, + SystemAddresses, +) + + +def quote_settlement_account( + user: Pubkey, quote_mint: Pubkey, quote_token_program: Pubkey, funding: int = 0 +) -> tuple[Pubkey, list[Instruction], list[Instruction]]: + """The account to pay from or into, with its setup and teardown instructions. + + Wrapped SOL settles through a throwaway account at a fresh seed, funded with + `funding` lamports and closed in the same transaction, so proceeds arrive as + SOL and any WSOL the wallet already holds is left alone. Any other quote + uses the wallet's ATA, created if missing; buying with it needs the balance + already there. + + Args: + funding: Lamports to wrap, for a buy; 0 for a sell + + Returns: + (account, instructions before the trade, instructions after it) + """ + if quote_mint != WSOL_MINT: + create_ata = create_idempotent_associated_token_account( + user, user, quote_mint, quote_token_program + ) + ata = get_associated_token_address(user, quote_mint, quote_token_program) + return ata, [create_ata], [] + + seed = secrets.token_hex(16) + wsol_account = Pubkey.create_with_seed(user, seed, SystemAddresses.TOKEN_PROGRAM) + open_ixs = [ + create_account_with_seed( + CreateAccountWithSeedParams( + from_pubkey=user, + to_pubkey=wsol_account, + base=user, + seed=seed, + lamports=funding + TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE, + space=TOKEN_ACCOUNT_SIZE, + owner=SystemAddresses.TOKEN_PROGRAM, + ) + ), + initialize_account( + InitializeAccountParams( + program_id=SystemAddresses.TOKEN_PROGRAM, + account=wsol_account, + mint=WSOL_MINT, + owner=user, + ) + ), + ] + close_ixs = [ + close_account( + CloseAccountParams( + program_id=SystemAddresses.TOKEN_PROGRAM, + account=wsol_account, + dest=user, + owner=user, + ) + ) + ] + return wsol_account, open_ixs, close_ixs diff --git a/src/interfaces/core.py b/src/interfaces/core.py index 9950296..10894eb 100644 --- a/src/interfaces/core.py +++ b/src/interfaces/core.py @@ -292,6 +292,60 @@ class InstructionBuilder(ABC): pass +class CurveGraduatedError(Exception): + """The bonding curve has graduated and takes no more trades. + + Raised by `CurveManager.calculate_price`; the coin's price is now its + `GraduatedMarket`'s. + """ + + +class GraduatedMarket(ABC): + """The AMM pool a coin trades in once its bonding curve has graduated. + + A curve that has graduated can no longer be sold into. Its sell methods + match `InstructionBuilder`'s, so the seller swaps one for the other. + """ + + @abstractmethod + async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]: + """Read the pool the coin migrated to. + + Returns: + At least `price_per_token`, in whole quote units per whole token, + and `fee_fraction`, the share of a sell's proceeds taken as fees + + Raises: + ValueError: If the pool does not exist yet — migration lands a + moment after graduation — or cannot be read + """ + + @abstractmethod + async def build_sell_instruction( + self, + token_info: TokenInfo, + user: Pubkey, + amount_in: int, + minimum_amount_out: int, + address_provider: AddressProvider, + ) -> list[Instruction]: + """Build a sell of exactly `amount_in` raw base tokens into the pool. + + Args: + minimum_amount_out: Raw quote units that must be paid out + """ + + @abstractmethod + def get_required_accounts_for_sell( + self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider + ) -> list[Pubkey]: + """Writable accounts a sell locks, for priority-fee estimation.""" + + @abstractmethod + def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int: + """Compute units for a sell into the pool.""" + + class CurveManager(ABC): """Abstract interface for platform-specific price calculations and pool state management.""" diff --git a/src/platforms/__init__.py b/src/platforms/__init__.py index f6bf314..8740f66 100644 --- a/src/platforms/__init__.py +++ b/src/platforms/__init__.py @@ -8,6 +8,7 @@ from interfaces.core import ( AddressProvider, CurveManager, EventParser, + GraduatedMarket, InstructionBuilder, Platform, ) @@ -25,6 +26,8 @@ class PlatformImplementations: instruction_builder: InstructionBuilder curve_manager: CurveManager event_parser: EventParser + # Where the coin trades once its curve graduates; None where unsupported. + graduated_market: GraduatedMarket | None = None class PlatformRegistry: @@ -41,6 +44,7 @@ class PlatformRegistry: instruction_builder_class: type[InstructionBuilder], curve_manager_class: type[CurveManager], event_parser_class: type[EventParser], + graduated_market_class: type[GraduatedMarket] | None = None, ) -> None: """Register platform implementations. @@ -52,6 +56,7 @@ class PlatformRegistry: "instruction_builder": instruction_builder_class, "curve_manager": curve_manager_class, "event_parser": event_parser_class, + "graduated_market": graduated_market_class, } def create_platform_implementations( @@ -112,11 +117,13 @@ class PlatformRegistry: curve_manager = impl_classes["curve_manager"](client) event_parser = impl_classes["event_parser"]() + market_class = impl_classes["graduated_market"] implementations = PlatformImplementations( address_provider=address_provider, instruction_builder=instruction_builder, curve_manager=curve_manager, event_parser=event_parser, + graduated_market=market_class(client) if market_class else None, ) self._instances[cache_key] = implementations @@ -192,6 +199,7 @@ class PlatformFactory: PumpFunEventParser, PumpFunInstructionBuilder, ) + from platforms.pumpfun.pumpswap_market import PumpSwapMarket self.registry.register_platform( Platform.PUMP_FUN, @@ -199,6 +207,7 @@ class PlatformFactory: PumpFunInstructionBuilder, PumpFunCurveManager, PumpFunEventParser, + PumpSwapMarket, ) except ImportError as e: @@ -212,6 +221,7 @@ class PlatformFactory: LetsBonkEventParser, LetsBonkInstructionBuilder, ) + from platforms.launchlab.cpmm_market import CpmmMarket self.registry.register_platform( Platform.LETS_BONK, @@ -219,6 +229,7 @@ class PlatformFactory: LetsBonkInstructionBuilder, LetsBonkCurveManager, LetsBonkEventParser, + CpmmMarket, ) except ImportError as e: @@ -231,6 +242,7 @@ class PlatformFactory: StonkFunEventParser, StonkFunInstructionBuilder, ) + from platforms.launchlab.cpmm_market import CpmmMarket self.registry.register_platform( Platform.STONK_FUN, @@ -238,6 +250,7 @@ class PlatformFactory: StonkFunInstructionBuilder, StonkFunCurveManager, StonkFunEventParser, + CpmmMarket, ) except ImportError as e: diff --git a/src/platforms/launchlab/cpmm_market.py b/src/platforms/launchlab/cpmm_market.py new file mode 100644 index 0000000..b6bc339 --- /dev/null +++ b/src/platforms/launchlab/cpmm_market.py @@ -0,0 +1,277 @@ +"""Raydium CPMM, where a LaunchLab pool migrates when its curve graduates.""" + +import hashlib +import struct +from dataclasses import dataclass +from typing import Any, Final + +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey +from spl.token.instructions import get_associated_token_address + +from core.client import SolanaClient +from core.pubkeys import normalize_quote_mint +from core.quote_account import quote_settlement_account +from interfaces.core import AddressProvider, GraduatedMarket, TokenInfo + +CPMM_PROGRAM: Final[Pubkey] = Pubkey.from_string( + "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C" +) +_AUTHORITY: Final[Pubkey] = Pubkey.find_program_address( + [b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM +)[0] +_SWAP_BASE_INPUT: Final[bytes] = hashlib.sha256(b"global:swap_base_input").digest()[:8] + +FEE_RATE_DENOMINATOR = 1_000_000 + +# PlatformConfig.cpswap_config: discriminator, epoch, two wallets, three u64 +# scales, fee_rate, name[64], web[256], img[256]. +_PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256 +# LaunchLab PoolState.migrate_type: discriminator, epoch, auth_bump, status and +# the two decimals bytes. 0 migrates to AMM v4, 1 to CPMM. +_LAUNCHLAB_MIGRATE_TYPE_OFFSET = 8 + 8 + 4 +_MIGRATE_TO_AMM_V4 = 0 +# LaunchLab PoolState.platform_config: discriminator, epoch, five u8 flags, ten +# u64 amounts, a five-u64 vesting schedule, global_config. +_LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32 + +# CPMM PoolState: discriminator, then ten pubkeys, five u8, seven u64, two u8, +# six bytes of padding and two u64 creator-fee counters. +_POOL_KEYS = ( + "amm_config", + "pool_creator", + "token_0_vault", + "token_1_vault", + "lp_mint", + "token_0_mint", + "token_1_mint", + "token_0_program", + "token_1_program", + "observation_key", +) +_POOL_DECIMALS_OFFSET = 8 + 10 * 32 + 3 +_POOL_FEES_OFFSET = 8 + 10 * 32 + 5 + 8 +_POOL_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1 +_POOL_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6 +# AmmConfig: discriminator, bump, disable_create_pool, index u16, trade_fee_rate, +# three more u64, two owners, creator_fee_rate. +_CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2 +_CONFIG_CREATOR_FEE_OFFSET = _CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32 +# SPL token account amount. +_TOKEN_AMOUNT_OFFSET = 64 + + +@dataclass(frozen=True) +class CpmmPool: + """One CPMM pool, oriented around the coin being sold.""" + + address: Pubkey + amm_config: Pubkey + observation: Pubkey + base_mint: Pubkey + quote_mint: Pubkey + base_vault: Pubkey + quote_vault: Pubkey + base_program: Pubkey + quote_program: Pubkey + base_decimals: int + quote_decimals: int + base_is_token_0: bool + + +def cpmm_pool_address(amm_config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey: + """CPMM orders a pair's mints by their raw bytes before deriving the pool.""" + token_0, token_1 = sorted([mint_a, mint_b], key=bytes) + return Pubkey.find_program_address( + [b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM + )[0] + + +def decode_pool(address: Pubkey, data: bytes, base_mint: Pubkey) -> CpmmPool: + """Decode a CPMM PoolState, oriented so `base` is `base_mint`.""" + keys = { + name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)]) + for i, name in enumerate(_POOL_KEYS) + } + if base_mint not in (keys["token_0_mint"], keys["token_1_mint"]): + raise ValueError(f"CPMM pool {address} does not trade {base_mint}") # noqa: TRY003 + base = 0 if keys["token_0_mint"] == base_mint else 1 + quote = 1 - base + + def pick(field: str, index: int) -> Pubkey: + return keys[f"token_{index}_{field}"] + + decimals = data[_POOL_DECIMALS_OFFSET : _POOL_DECIMALS_OFFSET + 2] + return CpmmPool( + address=address, + amm_config=keys["amm_config"], + observation=keys["observation_key"], + base_mint=base_mint, + quote_mint=pick("mint", quote), + base_vault=pick("vault", base), + quote_vault=pick("vault", quote), + base_program=pick("program", base), + quote_program=pick("program", quote), + base_decimals=decimals[base], + quote_decimals=decimals[quote], + base_is_token_0=base == 0, + ) + + +def accrued_fees(data: bytes, base_is_token_0: bool) -> tuple[int, int]: + """Fees sitting in the vaults that are not reserves, as (base, quote) raw. + + The vaults hold the protocol, fund and creator fees until they are claimed; + the curve trades on the vault balance less these. + """ + p0, p1, f0, f1 = struct.unpack_from("<4Q", data, _POOL_FEES_OFFSET) + c0, c1 = struct.unpack_from("<2Q", data, _POOL_CREATOR_FEES_OFFSET) + token_0, token_1 = p0 + f0 + c0, p1 + f1 + c1 + return (token_0, token_1) if base_is_token_0 else (token_1, token_0) + + +class CpmmMarket(GraduatedMarket): + """Sells a graduated LaunchLab coin into its Raydium CPMM pool. + + The pool is derived, not searched for: CPMM keys it by the platform's + `cpswap_config` and the two mints. Platform configs and AMM configs are + shared by many pools and read once. + """ + + def __init__(self, client: SolanaClient) -> None: + self.client = client + self._cpswap_configs: dict[Pubkey, Pubkey] = {} + self._pools: dict[Pubkey, CpmmPool] = {} + + async def _platform_config(self, token_info: TokenInfo) -> Pubkey: + if token_info.platform_config: + return token_info.platform_config + pool = await self.client.get_account_info(token_info.pool_state) + offset = _LAUNCHLAB_PLATFORM_CONFIG_OFFSET + return Pubkey.from_bytes(pool.data[offset : offset + 32]) + + async def _resolve(self, token_info: TokenInfo) -> CpmmPool: + """Find and decode the coin's CPMM pool, cached per mint.""" + cached = self._pools.get(token_info.mint) + if cached: + return cached + platform_config = await self._platform_config(token_info) + amm_config = self._cpswap_configs.get(platform_config) + if amm_config is None: + config = await self.client.get_account_info(platform_config) + offset = _PLATFORM_CPSWAP_CONFIG_OFFSET + amm_config = Pubkey.from_bytes(config.data[offset : offset + 32]) + self._cpswap_configs[platform_config] = amm_config + quote_mint = normalize_quote_mint(token_info.quote_mint) + address = cpmm_pool_address(amm_config, token_info.mint, quote_mint) + account = await self.client.get_account_info(address) + pool = decode_pool(address, account.data, token_info.mint) + self._pools[token_info.mint] = pool + return pool + + async def _migrated_to_amm_v4(self, token_info: TokenInfo) -> bool: + """Whether the LaunchLab pool was set to migrate to AMM v4, not CPMM.""" + if not token_info.pool_state: + return False + try: + pool = await self.client.get_account_info(token_info.pool_state) + except ValueError: + return False + return pool.data[_LAUNCHLAB_MIGRATE_TYPE_OFFSET] == _MIGRATE_TO_AMM_V4 + + async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]: + """Price from the vault balances less accrued fees; fees from the AMM config. + + Raises: + ValueError: If the CPMM pool does not exist yet + """ + try: + pool = await self._resolve(token_info) + except ValueError as e: + if await self._migrated_to_amm_v4(token_info): + raise ValueError( # noqa: TRY003 + f"{token_info.mint} migrated to Raydium AMM v4, which the bot " + f"cannot sell into" + ) from e + raise ValueError( # noqa: TRY003 + f"No CPMM pool for {token_info.mint} yet: {e}" + ) from e + accounts = await self.client.get_multiple_accounts( + [pool.address, pool.amm_config, pool.base_vault, pool.quote_vault] + ) + if any(account is None for account in accounts): + raise ValueError(f"CPMM pool {pool.address} accounts unreadable") # noqa: TRY003 + pool_account, config, base_vault, quote_vault = accounts + base_fees, quote_fees = accrued_fees(pool_account.data, pool.base_is_token_0) + base_reserve = ( + struct.unpack_from(" 0 + else 0.0, + "fee_fraction": (trade_fee + creator_fee) / FEE_RATE_DENOMINATOR, + "pool": pool.address, + } + + async def build_sell_instruction( + self, + token_info: TokenInfo, + user: Pubkey, + amount_in: int, + minimum_amount_out: int, + address_provider: AddressProvider, # noqa: ARG002 + ) -> list[Instruction]: + """Build swap_base_input selling `amount_in` raw tokens for the quote.""" + pool = await self._resolve(token_info) + user_base = get_associated_token_address( + user, pool.base_mint, pool.base_program + ) + quote_account, open_ixs, close_ixs = quote_settlement_account( + user, pool.quote_mint, pool.quote_program + ) + accounts = [ + AccountMeta(user, is_signer=True, is_writable=True), + AccountMeta(_AUTHORITY, is_signer=False, is_writable=False), + AccountMeta(pool.amm_config, is_signer=False, is_writable=False), + AccountMeta(pool.address, is_signer=False, is_writable=True), + AccountMeta(user_base, is_signer=False, is_writable=True), + AccountMeta(quote_account, is_signer=False, is_writable=True), + AccountMeta(pool.base_vault, is_signer=False, is_writable=True), + AccountMeta(pool.quote_vault, is_signer=False, is_writable=True), + AccountMeta(pool.base_program, is_signer=False, is_writable=False), + AccountMeta(pool.quote_program, is_signer=False, is_writable=False), + AccountMeta(pool.base_mint, is_signer=False, is_writable=False), + AccountMeta(pool.quote_mint, is_signer=False, is_writable=False), + AccountMeta(pool.observation, is_signer=False, is_writable=True), + ] + data = _SWAP_BASE_INPUT + struct.pack(" list[Pubkey]: + """The pool and its vaults, once the pool has been resolved.""" + pool = self._pools.get(token_info.mint) + if pool is None: + return [CPMM_PROGRAM] + return [pool.address, pool.base_vault, pool.quote_vault, CPMM_PROGRAM] + + def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int: + """Compute units for a CPMM sell.""" + return config_override if config_override is not None else 150_000 diff --git a/src/platforms/launchlab/curve_manager.py b/src/platforms/launchlab/curve_manager.py index b21820f..23a4d72 100644 --- a/src/platforms/launchlab/curve_manager.py +++ b/src/platforms/launchlab/curve_manager.py @@ -7,7 +7,7 @@ from typing import Any from solders.pubkey import Pubkey from core.client import SolanaClient -from interfaces.core import CurveManager +from interfaces.core import CurveGraduatedError, CurveManager from platforms.launchlab.curve_math import ( FEE_RATE_DENOMINATOR, buy_amount_out, @@ -52,6 +52,7 @@ class LaunchLabCurveManager(CurveManager): - `fee_rate`: total curve fee in parts per million, and `fee_fraction` - `transfer_fee_bps` / `transfer_fee_max`: the coin's Token-2022 transfer fee, zero for a mint without one + - `graduated`: the curve takes no more trades; the coin trades on CPMM """ def __init__(self, client: SolanaClient, idl_parser: IDLParser) -> None: @@ -176,11 +177,23 @@ class LaunchLabCurveManager(CurveManager): "fee_fraction": fee_rate / FEE_RATE_DENOMINATOR, "transfer_fee_bps": transfer_bps, "transfer_fee_max": transfer_max, + # Status 1 is waiting for Raydium to migrate it, 2 is migrated. The + # curve takes no trades either way, and a migrated pool keeps its + # final reserves, so its price would read as frozen. + "graduated": pool["status"] != 0, } async def calculate_price(self, pool_address: Pubkey) -> float: - """Spot price in whole quote units per whole token; 0.0 for an empty curve.""" - return (await self.get_pool_state(pool_address))["price_per_token"] + """Spot price in whole quote units per whole token; 0.0 for an empty curve. + + Raises: + CurveGraduatedError: If the curve has graduated — a migrated pool + keeps its final reserves, so its price would read as frozen + """ + pool_state = await self.get_pool_state(pool_address) + if pool_state["graduated"]: + raise CurveGraduatedError(str(pool_address)) + return pool_state["price_per_token"] async def calculate_buy_amount_out( self, pool_address: Pubkey, amount_in: int diff --git a/src/platforms/launchlab/instruction_builder.py b/src/platforms/launchlab/instruction_builder.py index 04809ee..2f60302 100644 --- a/src/platforms/launchlab/instruction_builder.py +++ b/src/platforms/launchlab/instruction_builder.py @@ -1,25 +1,12 @@ """Raydium LaunchLab buy_exact_in / sell_exact_in instructions, for any quote mint.""" -import secrets import struct from solders.instruction import AccountMeta, Instruction from solders.pubkey import Pubkey -from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed -from spl.token.instructions import ( - close_account, - create_idempotent_associated_token_account, - get_associated_token_address, - initialize_account, -) -from spl.token.models import CloseAccountParams, InitializeAccountParams +from spl.token.instructions import create_idempotent_associated_token_account -from core.pubkeys import ( - TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE, - TOKEN_ACCOUNT_SIZE, - WSOL_MINT, - SystemAddresses, -) +from core.quote_account import quote_settlement_account from interfaces.core import InstructionBuilder, TokenInfo from platforms.launchlab.address_provider import LaunchLabAddressProvider from utils.idl_parser import IDLParser @@ -139,58 +126,9 @@ class LaunchLabInstructionBuilder(InstructionBuilder): def _quote_account( self, accounts: dict[str, Pubkey], user: Pubkey, funding: int ) -> tuple[Pubkey, list[Instruction], list[Instruction]]: - """The account the quote side settles through, with its setup and teardown. - - Returns: - (account, instructions before the swap, instructions after it) - """ - quote_mint = accounts["quote_token_mint"] - if quote_mint != WSOL_MINT: - quote_program = accounts["quote_token_program"] - create_ata = create_idempotent_associated_token_account( - user, user, quote_mint, quote_program - ) - ata = get_associated_token_address(user, quote_mint, quote_program) - return ata, [create_ata], [] - - # A fresh seed per trade: two trades in flight never collide on one - # address, and any WSOL the wallet already holds is left alone. - seed = secrets.token_hex(16) - wsol_account = Pubkey.create_with_seed( - user, seed, SystemAddresses.TOKEN_PROGRAM + return quote_settlement_account( + user, accounts["quote_token_mint"], accounts["quote_token_program"], funding ) - open_ixs = [ - create_account_with_seed( - CreateAccountWithSeedParams( - from_pubkey=user, - to_pubkey=wsol_account, - base=user, - seed=seed, - lamports=funding + TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE, - space=TOKEN_ACCOUNT_SIZE, - owner=SystemAddresses.TOKEN_PROGRAM, - ) - ), - initialize_account( - InitializeAccountParams( - program_id=SystemAddresses.TOKEN_PROGRAM, - account=wsol_account, - mint=WSOL_MINT, - owner=user, - ) - ), - ] - close_ixs = [ - close_account( - CloseAccountParams( - program_id=SystemAddresses.TOKEN_PROGRAM, - account=wsol_account, - dest=user, - owner=user, - ) - ) - ] - return wsol_account, open_ixs, close_ixs def _swap( self, diff --git a/src/platforms/pumpfun/curve_manager.py b/src/platforms/pumpfun/curve_manager.py index 0405a08..bb4a5c4 100644 --- a/src/platforms/pumpfun/curve_manager.py +++ b/src/platforms/pumpfun/curve_manager.py @@ -11,7 +11,7 @@ from core.pubkeys import ( is_sol_paired, normalize_quote_mint, ) -from interfaces.core import CurveManager, Platform +from interfaces.core import CurveGraduatedError, CurveManager, Platform from utils.idl_parser import IDLParser from utils.logger import get_logger @@ -122,6 +122,8 @@ class PumpFunCurveManager(CurveManager): """ pool_state = await self.get_pool_state(pool_address) + if pool_state["graduated"]: + raise CurveGraduatedError(str(pool_address)) if pool_state["virtual_token_reserves"] <= 0: return 0.0 @@ -256,6 +258,8 @@ class PumpFunCurveManager(CurveManager): "real_quote_reserves": decoded_curve_state.get("real_quote_reserves", 0), "token_total_supply": decoded_curve_state.get("token_total_supply", 0), "complete": decoded_curve_state.get("complete", False), + # The coin now trades on PumpSwap; the curve's reserves are zero. + "graduated": decoded_curve_state.get("complete", False), "creator": decoded_curve_state.get("creator", ""), "is_mayhem_mode": decoded_curve_state.get("is_mayhem_mode", False), # Decoded for every coin. create_v2 no longer mints cashback coins @@ -272,7 +276,13 @@ class PumpFunCurveManager(CurveManager): curve_data["virtual_sol_reserves"] = curve_data["virtual_quote_reserves"] curve_data["real_sol_reserves"] = curve_data["real_quote_reserves"] - # Calculate additional metrics + # A graduated curve has handed its reserves to PumpSwap and has no + # price; reporting that, rather than raising, lets a holder route the + # sell to the pool. + if curve_data["graduated"]: + curve_data["price_per_token"] = None + return curve_data + # Validate reserves are positive before calculating price if curve_data["virtual_token_reserves"] <= 0: raise ValueError( diff --git a/src/platforms/pumpfun/pumpswap_market.py b/src/platforms/pumpfun/pumpswap_market.py new file mode 100644 index 0000000..a1b8757 --- /dev/null +++ b/src/platforms/pumpfun/pumpswap_market.py @@ -0,0 +1,305 @@ +"""PumpSwap, where a pump.fun coin trades once its bonding curve completes.""" + +import random +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final + +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey +from spl.token.instructions import get_associated_token_address + +from core.client import SolanaClient +from core.pubkeys import ( + TOKEN_DECIMALS, + SystemAddresses, + normalize_quote_mint, + quote_units_per_token, +) +from core.quote_account import quote_settlement_account +from interfaces.core import AddressProvider, GraduatedMarket, TokenInfo +from platforms.pumpfun.address_provider import PumpFunAddresses +from utils.idl_parser import IDLParser + +PUMP_AMM_PROGRAM: Final[Pubkey] = Pubkey.from_string( + "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA" +) +PUMP_FEE_PROGRAM: Final[Pubkey] = Pubkey.from_string( + "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ" +) + + +def _amm_pda(*seeds: bytes) -> Pubkey: + return Pubkey.find_program_address(list(seeds), PUMP_AMM_PROGRAM)[0] + + +_PUMP_SWAP_IDL = Path(__file__).resolve().parents[3] / "idl" / "pump_swap_idl.json" + +GLOBAL_CONFIG: Final[Pubkey] = _amm_pda(b"global_config") +EVENT_AUTHORITY: Final[Pubkey] = _amm_pda(b"__event_authority") +FEE_CONFIG: Final[Pubkey] = Pubkey.find_program_address( + [b"fee_config", bytes(PUMP_AMM_PROGRAM)], PUMP_FEE_PROGRAM +)[0] + +# Pool, after the discriminator: pool_bump u8, index u16, creator, base_mint, +# quote_mint, lp_mint, the two pool token accounts, lp_supply u64, coin_creator, +# is_mayhem_mode, is_cashback_coin, virtual_quote_reserves i128. +_POOL_BASE_ACCOUNT_OFFSET = 139 +_POOL_QUOTE_ACCOUNT_OFFSET = 171 +_POOL_COIN_CREATOR_OFFSET = 211 +_POOL_MAYHEM_OFFSET = 243 +_POOL_CASHBACK_OFFSET = 244 +_POOL_VIRTUAL_QUOTE_OFFSET = 245 + +# GlobalConfig, after the discriminator: admin, lp/protocol fee bps (u64 each), +# disable_flags u8, protocol_fee_recipients[8], coin_creator_fee bps u64, two +# authorities, reserved_fee_recipient, mayhem flag, reserved_fee_recipients[7], +# cashback flag, buyback_fee_recipients[8]. +_CONFIG_LP_FEE_OFFSET = 40 +_CONFIG_PROTOCOL_FEE_OFFSET = 48 +_CONFIG_PROTOCOL_RECIPIENTS_OFFSET = 57 +_CONFIG_CREATOR_FEE_OFFSET = 313 +_CONFIG_RESERVED_RECIPIENT_OFFSET = 385 +_CONFIG_BUYBACK_RECIPIENTS_OFFSET = 643 +_BASIS_POINTS = 10_000 +_TOKEN_AMOUNT_OFFSET = 64 + + +@dataclass(frozen=True) +class PumpSwapPool: + """A canonical PumpSwap pool: the one a pump.fun curve migrated into.""" + + address: Pubkey + base_mint: Pubkey + quote_mint: Pubkey + base_account: Pubkey + quote_account: Pubkey + base_program: Pubkey + quote_program: Pubkey + coin_creator: Pubkey + is_mayhem_mode: bool + is_cashback_coin: bool + + +def canonical_pool_address(base_mint: Pubkey, quote_mint: Pubkey) -> Pubkey: + """The pool a pump.fun migration creates: index 0, owned by the curve's authority.""" + pool_authority = Pubkey.find_program_address( + [b"pool-authority", bytes(base_mint)], PumpFunAddresses.PROGRAM + )[0] + return _amm_pda( + b"pool", + struct.pack(" list[Pubkey]: + return [ + Pubkey.from_bytes(data[offset + 32 * i : offset + 32 * (i + 1)]) + for i in range(count) + ] + + +class PumpSwapMarket(GraduatedMarket): + """Sells a graduated pump.fun coin into its canonical PumpSwap pool. + + The pool is derived from the mint, so finding it costs no search. The + accounts follow PumpSwap's `sell`: the IDL's 21, then the cashback pair on a + cashback pool, `pool-v2` on a pool with a coin creator — which every + canonical pool has — and a buyback recipient with its quote account last, + which the program reads from the end of the list. + """ + + def __init__(self, client: SolanaClient) -> None: + self.client = client + self._sell_discriminator = IDLParser( + str(_PUMP_SWAP_IDL) + ).get_instruction_discriminators()["sell"] + self._config: bytes | None = None + self._pools: dict[Pubkey, PumpSwapPool] = {} + + async def _global_config(self) -> bytes: + if self._config is None: + self._config = (await self.client.get_account_info(GLOBAL_CONFIG)).data + return self._config + + async def _resolve(self, token_info: TokenInfo) -> tuple[PumpSwapPool, bytes]: + """The pool, and its current account data. Raises ValueError if it does not exist.""" + quote_mint = normalize_quote_mint(token_info.quote_mint) + address = canonical_pool_address(token_info.mint, quote_mint) + pool_data = (await self.client.get_account_info(address)).data + pool = self._pools.get(token_info.mint) + if pool is None: + base_account, quote_account = ( + Pubkey.from_bytes(pool_data[o : o + 32]) + for o in (_POOL_BASE_ACCOUNT_OFFSET, _POOL_QUOTE_ACCOUNT_OFFSET) + ) + vaults = await self.client.get_multiple_accounts( + [base_account, quote_account] + ) + pool = PumpSwapPool( + address=address, + base_mint=token_info.mint, + quote_mint=quote_mint, + base_account=base_account, + quote_account=quote_account, + base_program=vaults[0].owner, + quote_program=vaults[1].owner, + coin_creator=Pubkey.from_bytes( + pool_data[ + _POOL_COIN_CREATOR_OFFSET : _POOL_COIN_CREATOR_OFFSET + 32 + ] + ), + is_mayhem_mode=bool(pool_data[_POOL_MAYHEM_OFFSET]), + is_cashback_coin=bool(pool_data[_POOL_CASHBACK_OFFSET]), + ) + self._pools[token_info.mint] = pool + return pool, pool_data + + async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]: + """Price from the pool's balances plus its virtual quote reserves. + + Raises: + ValueError: If the pool does not exist yet + """ + try: + pool, pool_data = await self._resolve(token_info) + except ValueError as e: + raise ValueError(f"No PumpSwap pool for {token_info.mint} yet: {e}") from e # noqa: TRY003 + base_vault, quote_vault = await self.client.get_multiple_accounts( + [pool.base_account, pool.quote_account] + ) + base_reserve = struct.unpack_from(" 0 + else 0.0, + "fee_fraction": fee_bps / _BASIS_POINTS, + "pool": pool.address, + } + + async def build_sell_instruction( + self, + token_info: TokenInfo, + user: Pubkey, + amount_in: int, + minimum_amount_out: int, + address_provider: AddressProvider, # noqa: ARG002 + ) -> list[Instruction]: + """Build PumpSwap `sell` for `amount_in` raw tokens.""" + pool, _ = await self._resolve(token_info) + config = await self._global_config() + quote_mint, quote_program = pool.quote_mint, pool.quote_program + + if pool.is_mayhem_mode: + fee_recipient = _pubkeys(config, _CONFIG_RESERVED_RECIPIENT_OFFSET, 1)[0] + else: + fee_recipient = random.choice( + _pubkeys(config, _CONFIG_PROTOCOL_RECIPIENTS_OFFSET, 8) + ) + buyback_recipient = random.choice( + _pubkeys(config, _CONFIG_BUYBACK_RECIPIENTS_OFFSET, 8) + ) + creator_vault = _amm_pda(b"creator_vault", bytes(pool.coin_creator)) + user_quote, open_ixs, close_ixs = quote_settlement_account( + user, quote_mint, quote_program + ) + + def ata(owner: Pubkey) -> Pubkey: + return get_associated_token_address(owner, quote_mint, quote_program) + + accounts = [ + AccountMeta(pool.address, is_signer=False, is_writable=True), + AccountMeta(user, is_signer=True, is_writable=True), + AccountMeta(GLOBAL_CONFIG, is_signer=False, is_writable=False), + AccountMeta(pool.base_mint, is_signer=False, is_writable=False), + AccountMeta(quote_mint, is_signer=False, is_writable=False), + AccountMeta( + get_associated_token_address(user, pool.base_mint, pool.base_program), + is_signer=False, + is_writable=True, + ), + AccountMeta(user_quote, is_signer=False, is_writable=True), + AccountMeta(pool.base_account, is_signer=False, is_writable=True), + AccountMeta(pool.quote_account, is_signer=False, is_writable=True), + AccountMeta(fee_recipient, is_signer=False, is_writable=False), + AccountMeta(ata(fee_recipient), is_signer=False, is_writable=True), + AccountMeta(pool.base_program, is_signer=False, is_writable=False), + AccountMeta(quote_program, is_signer=False, is_writable=False), + AccountMeta( + SystemAddresses.SYSTEM_PROGRAM, is_signer=False, is_writable=False + ), + AccountMeta( + SystemAddresses.ASSOCIATED_TOKEN_PROGRAM, + is_signer=False, + is_writable=False, + ), + AccountMeta(EVENT_AUTHORITY, is_signer=False, is_writable=False), + AccountMeta(PUMP_AMM_PROGRAM, is_signer=False, is_writable=False), + AccountMeta(ata(creator_vault), is_signer=False, is_writable=True), + AccountMeta(creator_vault, is_signer=False, is_writable=False), + AccountMeta(FEE_CONFIG, is_signer=False, is_writable=False), + AccountMeta(PUMP_FEE_PROGRAM, is_signer=False, is_writable=False), + ] + if pool.is_cashback_coin: + accumulator = _amm_pda(b"user_volume_accumulator", bytes(user)) + accounts += [ + AccountMeta(ata(accumulator), is_signer=False, is_writable=True), + AccountMeta(accumulator, is_signer=False, is_writable=True), + ] + if pool.coin_creator != Pubkey.default(): + accounts.append( + AccountMeta( + _amm_pda(b"pool-v2", bytes(pool.base_mint)), + is_signer=False, + is_writable=False, + ) + ) + accounts += [ + AccountMeta(buyback_recipient, is_signer=False, is_writable=False), + AccountMeta(ata(buyback_recipient), is_signer=False, is_writable=True), + ] + data = self._sell_discriminator + struct.pack( + " list[Pubkey]: + """The pool and its token accounts, once the pool has been resolved.""" + pool = self._pools.get(token_info.mint) + if pool is None: + return [PUMP_AMM_PROGRAM] + return [pool.address, pool.base_account, pool.quote_account, PUMP_AMM_PROGRAM] + + def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int: + """Compute units for a PumpSwap sell.""" + return config_override if config_override is not None else 200_000 diff --git a/src/trading/platform_aware.py b/src/trading/platform_aware.py index 8b3dbd1..58ce6ca 100644 --- a/src/trading/platform_aware.py +++ b/src/trading/platform_aware.py @@ -697,6 +697,8 @@ class PlatformAwareSeller(Trader): # the transfer fee on the way into the pool and takes its curve fee # from the quote side. Zero where the pool state carries neither. sell_deductions = (0.0, 0) + # Unknown when the refresh below fails; the curve is the default. + graduated = False # The sell account list is 16 (non-cashback) vs 17 (cashback) and # fee_recipient differs in mayhem mode; both can change between buy @@ -721,6 +723,7 @@ class PlatformAwareSeller(Trader): pool_state.get("fee_fraction", 0.0), pool_state.get("transfer_fee_bps", 0), ) + graduated = pool_state.get("graduated", False) # The program may delegate BC.creator to a PFEE-owned PDA after # the initial creator buy, so the create-time vault on # token_info goes stale before the sell lands and the sell @@ -746,6 +749,31 @@ class PlatformAwareSeller(Trader): f"is_cashback_coin={token_info.is_cashback_coin}" ) + # A graduated curve takes no sells; the coin trades in the AMM pool + # it migrated to, which builds the sell instead. + seller = instruction_builder + if graduated: + market = implementations.graduated_market + try: + if market is None: + raise ValueError( # noqa: TRY301 + f"no post-graduation market for {token_info.platform.value}" + ) + market_state = await market.get_market_state(token_info) + except ValueError as e: + return TradeResult( + success=False, + platform=token_info.platform, + error_message=f"{token_info.symbol} has graduated: {e}", + failure_reason=TradeFailureReason.SUBMIT_FAILED, + ) + logger.info( + f"{token_info.symbol} has graduated; selling into " + f"{market_state['pool']}" + ) + seller = market + sell_deductions = (market_state["fee_fraction"], sell_deductions[1]) + quote_unit = quote_units_per_token(quote_mint) quote_label = quote_symbol(quote_mint) @@ -793,7 +821,7 @@ class PlatformAwareSeller(Trader): ) # Build sell instructions using platform-specific builder - instructions = await instruction_builder.build_sell_instruction( + instructions = await seller.build_sell_instruction( token_info, self.wallet.pubkey, token_balance, # amount_in (tokens) @@ -802,7 +830,7 @@ class PlatformAwareSeller(Trader): ) # Get accounts for priority fee calculation - priority_accounts = instruction_builder.get_required_accounts_for_sell( + priority_accounts = seller.get_required_accounts_for_sell( token_info, self.wallet.pubkey, address_provider ) @@ -814,7 +842,7 @@ class PlatformAwareSeller(Trader): priority_fee=await self.priority_fee_manager.calculate_priority_fee( priority_accounts ), - compute_unit_limit=instruction_builder.get_sell_compute_unit_limit( + compute_unit_limit=seller.get_sell_compute_unit_limit( self._get_cu_override("sell", token_info.platform) ), account_data_size_limit=self._get_cu_override( diff --git a/src/trading/universal_trader.py b/src/trading/universal_trader.py index db4650e..6b5e4fd 100644 --- a/src/trading/universal_trader.py +++ b/src/trading/universal_trader.py @@ -29,6 +29,7 @@ from core.pubkeys import ( from core.wallet import Wallet from interfaces.core import ( ConfirmationStatus, + CurveGraduatedError, Platform, TokenInfo, TradeFailureReason, @@ -321,6 +322,8 @@ class UniversalTrader: self.token_queue: asyncio.Queue = asyncio.Queue() self.processing: bool = False self.processed_tokens: set[str] = set() + # Coins whose graduation has been logged, so it is said once. + self._graduated_mints: set[Pubkey] = set() self.token_timestamps: dict[str, float] = {} async def _resolve_quote_token_programs(self) -> None: @@ -899,6 +902,33 @@ class UniversalTrader: ) return ExitSellVerdict.STOP + async def _read_price(self, token_info: TokenInfo) -> float: + """The coin's price where it trades now: its curve, or after graduation its AMM pool. + + Returns: + Whole quote units per whole token; 0.0 for a pool with nothing in it + + Raises: + ValueError: If a graduated coin's pool cannot be priced yet — it + lands a moment after graduation + """ + implementations = self.platform_implementations + try: + return await implementations.curve_manager.calculate_price( + self._get_pool_address(token_info) + ) + except CurveGraduatedError: + market = implementations.graduated_market + if market is None: + raise ValueError( # noqa: TRY003 + f"{token_info.symbol} has graduated and " + f"{self.platform.value} has no post-graduation market" + ) from None + if token_info.mint not in self._graduated_mints: + self._graduated_mints.add(token_info.mint) + logger.info(f"{token_info.symbol} has graduated; pricing off its pool") + return (await market.get_market_state(token_info))["price_per_token"] + async def _current_price_or(self, token_info: TokenInfo, fallback: float) -> float: """Read the current price, falling back to the last known one. @@ -910,10 +940,7 @@ class UniversalTrader: The freshly read price, or `fallback` if it could not be read """ try: - curve_manager = self.platform_implementations.curve_manager - price = await curve_manager.calculate_price( - self._get_pool_address(token_info) - ) + price = await self._read_price(token_info) except Exception as e: # noqa: BLE001 logger.warning( f"Could not re-read price for {token_info.symbol} ({e}); " @@ -936,9 +963,6 @@ class UniversalTrader: f"Starting position monitoring (check interval: {self.price_check_interval}s)" ) - # Platform-agnostic pool address for price monitoring - pool_address = self._get_pool_address(token_info) - curve_manager = self.platform_implementations.curve_manager exit_sell_attempts = 0 # Every price below is denominated in the coin's quote asset, which is # not always SOL. @@ -951,7 +975,7 @@ class UniversalTrader: while position.is_active: try: - current_price = await curve_manager.calculate_price(pool_address) + current_price = await self._read_price(token_info) # A curve with no virtual token reserves prices at 0.0 rather # than raising, and the seller rejects a non-positive price # before its own try block, so a stored 0.0 would escape the diff --git a/tests/regression/raw_graduated_markets_from_getaccountinfo.json b/tests/regression/raw_graduated_markets_from_getaccountinfo.json new file mode 100644 index 0000000..773f2f7 --- /dev/null +++ b/tests/regression/raw_graduated_markets_from_getaccountinfo.json @@ -0,0 +1,1080 @@ +{ + "pumpfun": { + "mint": "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "curve": "3gssU4VTiBwWWsovxhhgGNjBqY7gMbjdUkhwpdEJsmVM", + "pool": "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "accounts": { + "3gssU4VTiBwWWsovxhhgGNjBqY7gMbjdUkhwpdEJsmVM": { + "owner": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", + "data": "F7f4N2DYrGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAxqR+jQMAAZReNxJJhUQdaBTpxJgp1jAS2hdIlKMoRGQ7jKfTbt72AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8": { + "owner": "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", + "data": "8ZptBBGxbbz+AAAYvzUvHEBS4PBAp7d9MLa5XqNsKSduYcM9GbZtR87IgLa5EXneKqTD3hNO9J6HMnYGVHeYOWKN4uAbXNiAp2NvBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAHNme0274SgT9LaxlBZ9BLC+BZRuWG7pscA8RGieDTKIgrI2y45hOYMxtx9sbZiVHxqMm7NJqrRwG4F5H3aGjJWpAQC5mkm/y8JuBPO9GhsDjXP3h0wvxdOr/vWOtZjDmOAQ2tZ0AMAAJReNxJJhUQdaBTpxJgp1jAS2hdIlKMoRGQ7jKfTbt72AADJQR4YBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw": { + "owner": "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", + "data": "lQicyqD8sNnTu4yrNBzgUoRX8sOBfTJ4RBlj3NVf7Vi6JMmZ3awCqhQAAAAAAAAABQAAAAAAAAAASsL40N1cvJfjKJwZfLUGKlTz2Va5zm5RFfllZ6pcs+ZgjMwd/OlhtDt3nBkVBabi079F1aTbRhitdsgtYXVFNWODcwAOoiyyZNNK/2SgS176v7t03c0EiZexmBVH19EQg4R0KS5nWpS0NuywqZiJQjKKg93GIzgClhJnxc1hF8uNGBoMhJ+pN6bzSt7TCB75VwCqywybs9kJpLkUdSek69eqj7Bg2CkbTE1HXa/3Yslr3A2s6zbAEurRLtOpSEFh4ATIfOuY+lzkf4A4Bv0seUXSlSSVmuwA3tl4FPOPeEb/g4OBi6j6KMPNO21ek/n6uPCXm8NyFazFskaHe6jDyQUAAAAAAAAAByFdmUB5NpThFgZs5Fm4GP35u6DHtBt4P6OhIMpBlTKii1/SarR5pqnMbL9rCyPrYYhaNx4BIKypE77vPROKeOiTFB+xjp8VdNgQ4XjhnjBgTjF1qi5KMt/IYAcn0QcJATWEU2JWCU+RKBkSfvpORGtDM3IXk9E4dvmq2/PcfQtfbnUBgiD5QmdwAyN7TWtFN1m0pcaQtZw12bsYegkMvSozmHqeuxNnmatZklsT5dyLMIHfAF20J8FHj6Rv+MNHoHTpVD8+N6LQRiJ63ctOnHdMRCWMQ+3ySqiq4fACFGZb2kw4zW23Q49ZtAi7nsO0yp6K0fHyRlPEmbV5bCDb+bMt3Z7qPzmzchFccYR8GEXPpTbGhQdOAw5E0CHePvnjXEy3gPCO4v7oS+xEald4Jdpo1Dn6il2jsMXP9Q9j9FRrAUOeZRDAPWX62THonQS+C7cNUZcfUcQV+zRMB9tBnyEiAiNVFqkXE0xnWIxJOCCuFV7pZmVXesG3GNpH3c8qBQ7mp+IgaLuIZAqlf5CTCMYf73EaAWP1p1XAcLyGDR9jZyB87NpbzGyx6vDxbWhARWaxjVbSSBrLMXAyZW6QVRx4RJZB+ElY3HOnaoXYdW9VwCzayom6GTJ5DDaKsVfpLXPFS5a1yTGUHkbqS+Lg4xEndE/Gt0z7RV7+r4vVcXks7UT8H3j5SjPQkJxea1+wIVcK2Nutjej9s9IO0c2Z645Oh3AVfuvrZ4plXbmbN/axMmx2V9uQz7ioer74x7byyGmIEwAAAAAAAPSEYofB0vyr1vGBX2l/VABW/RZVaGEbANPK6XSCwSxlAQEsAQAAAAAAAA==" + }, + "j6iUvMo6Z2yivpHmsME2nXrafjbhtbCJqdevsJstunV": { + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "trkRed4qpMPeE070nocydgZUd5g5Yo3i4Btc2ICnY2+WwutDHabNP/Av8usCrx9bqh1ZXijsfoH4PxmYLaT6GcAQO0vgrwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgcAAAA=" + }, + "C3FQnUxE56fXfbTZTFLvddizyrQqXJsG7VRTvdSPk3uU": { + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAGWwutDHabNP/Av8usCrx9bqh1ZXijsfoH4PxmYLaT6GUwF6CgRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAAAA4thYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + "sell_transaction": { + "transaction": { + "signatures": [ + "TKsTp2znT79YCQAwfX1eC94mZ8qeH9xVRyQn3kaVCXmqxFeysWPsDYqaPGGX6Krs6w2EiAxAGdhnAgiXmeN3VUx" + ], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 17 + }, + "accountKeys": [ + "3nj6DtKAYLBun3vbWefYnhJXA4YZNgtS81PUcHPSLE4G", + "7xQYoUjUJF1Kg6WVczoTAkaNhn5syQYcbvjmFrhjWpx", + "j6iUvMo6Z2yivpHmsME2nXrafjbhtbCJqdevsJstunV", + "nnRiVSSGCTgGGQbYbLNwNLgsvdRWpUppu2Wh7CAvU83", + "5iuB6dgptS5NAsZdRSMMMW93VLggz8b41Dff8yfV6uwt", + "8MtY6ujKeCCgrJENaqa5a6iCkG5FnxLzBg6kQ2UW4eAq", + "8d8GETameoUafnPq4fFEXPxh9GQtt7FG46FWhFAWPRHj", + "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "C3FQnUxE56fXfbTZTFLvddizyrQqXJsG7VRTvdSPk3uU", + "CASRL2zkwDnppxEFQ4LgdwgR9pdz5Q8R8nEMKVZ9QoLp", + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111", + "So11111111111111111111111111111111111111112", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", + "pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ", + "2a47Lm8m8MHfwfBTgZPkj7qKCTqEm3sgasSWXGcydCNb", + "2bo4oWgR4L67Lgghp7YQRUGvQGAa3mgnHMkK3GfjZRNK", + "5PHirr8joyTMp9JMm6nW7hNDVyEYdkzDqazxPD7RaTjx", + "5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD", + "ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "DvwaKr5uVYZUUzdmzEnXEBqvbJC6Fp6C7caDso4GhBA1", + "FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz", + "GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR" + ], + "recentBlockhash": "JABtNJXxVESQPdD6fGudojKcSCt5smrxYEcW5PsGC6em", + "instructions": [ + { + "programIdIndex": 11, + "accounts": [], + "data": "ES3pFy", + "stackHeight": 1 + }, + { + "programIdIndex": 11, + "accounts": [], + "data": "3cDeqiGMb6md", + "stackHeight": 1 + }, + { + "programIdIndex": 11, + "accounts": [], + "data": "TB8bQB", + "stackHeight": 1 + }, + { + "programIdIndex": 17, + "accounts": [ + 5, + 7, + 0, + 21, + 23, + 12, + 3, + 6, + 2, + 8, + 25, + 1, + 14, + 13, + 10, + 22, + 26, + 15, + 4, + 24, + 19, + 16, + 18, + 20, + 9 + ], + "data": "24KdxQYCmH4yjAvwC8nHtK9PcE2hmx6yMkG2tgzBhiaPqVzQb2EZBEGX2H7LmZY7TTBQSNxxKWKRBCS", + "stackHeight": 1 + } + ] + } + }, + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 5486, + "preBalances": [ + 1079677764, + 4103464824720, + 1513840, + 1488440, + 1650462153, + 2113280, + 21244752283, + 2179320, + 79213955161, + 2230286316, + 1, + 1, + 1849280399649, + 200653906, + 70228638, + 1178687650, + 6046721, + 833120, + 0, + 33103977, + 4505061452, + 9272823, + 3388612899, + 2788920, + 0, + 7557128300918, + 4641093 + ], + "postBalances": [ + 1079672278, + 4103464978676, + 1513840, + 1488440, + 1656312463, + 2113280, + 21853184520, + 2179320, + 78599364702, + 2230440272, + 1, + 1, + 1849280399649, + 200653906, + 70228638, + 1178687650, + 6046721, + 833120, + 0, + 33103977, + 4505061452, + 9272823, + 3388612899, + 2788920, + 0, + 7557128300918, + 4641093 + ], + "innerInstructions": [ + { + "index": 3, + "instructions": [ + { + "programIdIndex": 15, + "accounts": [ + 7, + 0, + 21, + 23, + 12, + 3, + 6, + 2, + 8, + 25, + 1, + 14, + 13, + 10, + 22, + 26, + 15, + 4, + 24, + 19, + 16, + 18, + 20, + 9 + ], + "data": "5jRcjdixRUDgSXo6LxDJuoTBxat3gYb7M", + "stackHeight": 2 + }, + { + "programIdIndex": 16, + "accounts": [ + 19, + 15 + ], + "data": "KX69VKtJ7u4EDGCgugJ8SfmqCGhKNcxLtNUnVNgfj3LY7dAv9Q9GdkSeYSQYsPYsAwRcrkb6BwCZyz", + "stackHeight": 3 + }, + { + "programIdIndex": 14, + "accounts": [ + 3, + 23, + 2, + 0 + ], + "data": "ipcdzJvoExkzh", + "stackHeight": 3 + }, + { + "programIdIndex": 13, + "accounts": [ + 8, + 12, + 6, + 7 + ], + "data": "hW7T8B5N9b1qz", + "stackHeight": 3 + }, + { + "programIdIndex": 13, + "accounts": [ + 8, + 12, + 1, + 7 + ], + "data": "hP2n5dkJSBx4Y", + "stackHeight": 3 + }, + { + "programIdIndex": 13, + "accounts": [ + 8, + 12, + 4, + 7 + ], + "data": "idKcgGReGAwoE", + "stackHeight": 3 + }, + { + "programIdIndex": 13, + "accounts": [ + 8, + 12, + 9, + 7 + ], + "data": "hP2n5dkJSBx4Y", + "stackHeight": 3 + }, + { + "programIdIndex": 15, + "accounts": [ + 26 + ], + "data": "2rerrzSaKmbarSSaQantaMgYhLwz4JjR8818WKNXcx7gvWnuEyZ6iUu4EPGq3XRCnsCDFABX4hayouzLgaM1vjNHW67deqhb9vYfftcPULexqL5HyfGhUeD6nfWzqDXFDMrcMhvmyqheXChiijKxiLkEBAw91dwmQCZ43J21RkoDeJ9Hkq3caH6NwArcoJz5ZY6NKZBMbCATLZgeuX5yZ3WVsMAUdR7fE2TfweArPRa4B739MLUA2YdsSvc3cRfp3STAtbmFCoy4TGQRcuDDwtqsC4VkUfUVvYAnBfuPJXXn9jM5TvybLFokLNCDTNy8cfzuRUdYmsLtW5kp61DTrhzw8Vf4mh9WMYUP5QLSFofvrKS1hfswQeZuXGkxnJGw39DWdgvT9CbHYRZDL22wY5NJsZKnfRF1qoowbjgPG5yHH13W4pGQ96mUsgDaL2hD79nYxpAHf3efsptqCmidyHpRbB9tsXzwA4kM1DZHBtCvgxcThof5rkBNeW6NgamHY1ysgtRguioD6bt6sSSjisEGqkZ2N8LXVNoL7aKVyh3RJDaKmADUnqZkcv3D82X4YkVjPxKRwRVf7P3JRzN1hxr7CKRTBTwQmnNJrj", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program 2a47Lm8m8MHfwfBTgZPkj7qKCTqEm3sgasSWXGcydCNb invoke [1]", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA invoke [2]", + "Program log: Instruction: Sell", + "Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [3]", + "Program log: Instruction: GetFeesWithQuoteMint", + "Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 6107 of 47874 compute units", + "Program return: pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ FAAAAAAAAAAFAAAAAAAAAF8AAAAAAAAA", + "Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 2442 of 37976 compute units", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 32957 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 29985 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 24156 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 18026 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program data: Pi83CqUD3Cq/qsJqAAAAANWQeNEPAQAAgKsiJAAAAADVkHjRDwEAAGN7MvIEAAAA3SDnetWlAAAhUm5xEgAAABiztCQAAAAAFAAAAAAAAAAdyxIAAAAAAAUAAAAAAAAAyLIEAAAAAAD756EkAAAAAG3wQyQAAAAAlsLrQx2mzT/wL/LrAq8fW6odWV4o7H6B+D8ZmC2k+hkpbhoDnnsgCfITXS1r2fYLmZ5YTM08LHKQGx7hknuxWQu6f/kHPRBabGwqfCyZYSNG+W0YyZvgaR0Fs4YJ7IqccULt8p7COdWl/PcOO+Uk46DQFZVh+i2czjahyvIZPIrXqo+wYNgpG0xNR12v92LJa9wNrOs2wBLq0S7TqUhBYQHIIfOo8I/viNwxQkp2gK6MloFwTPHl9ciOJ5m3+YIhlF43EkmFRB1oFOnEmCnWMBLaF0iUoyhEZDuMp9Nu3vZfAAAAAAAAAMZEWQAAAAAAAAAAAAAAAAAAAAAAAAAAAIgTAAAAAAAAZFkCAAAAAADJQR4YBAAAAAAAAAAAAAAAAXdvS6phhwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA invoke [3]", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA consumed 2113 of 10585 compute units", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA success", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA consumed 83636 of 90298 compute units", + "Program pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA success", + "Program 2a47Lm8m8MHfwfBTgZPkj7qKCTqEm3sgasSWXGcydCNb consumed 90058 of 96587 compute units", + "Program 2a47Lm8m8MHfwfBTgZPkj7qKCTqEm3sgasSWXGcydCNb success" + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 4103.46296915, + "decimals": 9, + "amount": "4103462969150", + "uiAmountString": "4103.46296915" + }, + "owner": "FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 2, + "mint": "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "uiTokenAmount": { + "uiAmount": 182336308.584669, + "decimals": 6, + "amount": "182336308584669", + "uiAmountString": "182336308.584669" + }, + "owner": "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 3, + "mint": "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "uiTokenAmount": { + "uiAmount": 1167450.476757, + "decimals": 6, + "amount": "1167450476757", + "uiAmountString": "1167450.476757" + }, + "owner": "3nj6DtKAYLBun3vbWefYnhJXA4YZNgtS81PUcHPSLE4G", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 4, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 1.648973713, + "decimals": 9, + "amount": "1648973713", + "uiAmountString": "1.648973713" + }, + "owner": "DvwaKr5uVYZUUzdmzEnXEBqvbJC6Fp6C7caDso4GhBA1", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 21.243263843, + "decimals": 9, + "amount": "21243263843", + "uiAmountString": "21.243263843" + }, + "owner": "3nj6DtKAYLBun3vbWefYnhJXA4YZNgtS81PUcHPSLE4G", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 8, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 79.212466721, + "decimals": 9, + "amount": "79212466721", + "uiAmountString": "79.212466721" + }, + "owner": "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 9, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 2.228247036, + "decimals": 9, + "amount": "2228247036", + "uiAmountString": "2.228247036" + }, + "owner": "5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 4103.463123106, + "decimals": 9, + "amount": "4103463123106", + "uiAmountString": "4103.463123106" + }, + "owner": "FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 2, + "mint": "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "uiTokenAmount": { + "uiAmount": 183503759.061426, + "decimals": 6, + "amount": "183503759061426", + "uiAmountString": "183503759.061426" + }, + "owner": "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 3, + "mint": "DJGsptd7CEMEKrHdmVPastxKcPNSqGJT5K7HWcn5pump", + "uiTokenAmount": { + "uiAmount": null, + "decimals": 6, + "amount": "0", + "uiAmountString": "0" + }, + "owner": "3nj6DtKAYLBun3vbWefYnhJXA4YZNgtS81PUcHPSLE4G", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 4, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 1.654824023, + "decimals": 9, + "amount": "1654824023", + "uiAmountString": "1.654824023" + }, + "owner": "DvwaKr5uVYZUUzdmzEnXEBqvbJC6Fp6C7caDso4GhBA1", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 6, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 21.85169608, + "decimals": 9, + "amount": "21851696080", + "uiAmountString": "21.85169608" + }, + "owner": "3nj6DtKAYLBun3vbWefYnhJXA4YZNgtS81PUcHPSLE4G", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 8, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 78.597876262, + "decimals": 9, + "amount": "78597876262", + "uiAmountString": "78.597876262" + }, + "owner": "B9WYSKaLDWnegupPNG1h99XiiokMUYj6k64Dbqv18qr8", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 9, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 2.228400992, + "decimals": 9, + "amount": "2228400992", + "uiAmountString": "2.228400992" + }, + "owner": "5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 90508, + "costUnits": 97447 + }, + "version": "legacy", + "slot": 453352242, + "transactionIndex": 238, + "blockTime": 1791142591 + } + }, + "launchlab": { + "mint": "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "platform": "stonk_fun", + "launchlab_pool": "2NdMyxQWNG7CxjfR2CVvonz8xtRJif4EZGwtZYBmMT2Z", + "platform_config": "4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7", + "quote_mint": "So11111111111111111111111111111111111111112", + "cpmm_pool": "nEnhtdXrHocxDQnmfTWgaLodJP8jxFbY6958iv28GzV", + "accounts": { + "2NdMyxQWNG7CxjfR2CVvonz8xtRJif4EZGwtZYBmMT2Z": { + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "9+3j9dfD3kYJBAAAAAAAAPoCBgkBAIDGpH6NAwAAeMX7UdECAN50Dj7pzwMA168w/AYAAAAAeMX7UdECAAoSZcoTAAAAABJlyhMAAABldjUfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVxqOAcjfeCD51ms8c2W40eSvqBt4VMwu91zvWL0Ihn4v76UoyW8/kSO/XTQhGtcPshtjskAzEmFgU9mvfUhN3JI5g21ea4G86ckOHgJELAb5noHk/ruZfwYde7dst0s4BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAEYk5bynWAXnTQc0ffVWWMDEbLsT7CzzR9IaKPcYZ68jj7RAqiE1Ny6GexLsbCm5USjE/Pz3gYwYEKkhbCznhW+4ghpxgX95NGhqAd05/CicO0sqmbxrFW8hbQXT6aCls8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7": { + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "oE6AAPhT5qAEBAAAAAAAAD5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5Pk+EKn0LDJ7I4k6gOjHJ+HmjXcMEdYkaqu5dnrAIL/lAQg8AAAAAAAAAAAAAAAAAAAAAAAAAAAAQJwAAAAAAAFN0b25rRnVuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABodHRwczovL3d3dy5zdG9ua2Z1bi54eXoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaHR0cHM6Ly93d3cuc3RvbmtmdW4ueHl6L3N0b25rZnVuLWxvZ28ucG5nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKmyHxd+m5dujuEp9BrvN4SqxRSUkJWwAuYRfVnQNpaPAAAAAAAAAABALVyvepfFNJdljlYbJiPQ449TQPIIrdUKTvRUCwrLLj5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5AAAAAAAAAAA+T4QqfQsMnsjiTqA6Mcn4eaNdwwR1iRqq7l2esAgv+QABGv1mAdFtt7hdam3csaoALEDujVMj8YuY7QspWaQA7JUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "nEnhtdXrHocxDQnmfTWgaLodJP8jxFbY6958iv28GzV": { + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "9+3j9dfD3kapsh8XfpuXbo7hKfQa7zeEqsUUlJCVsALmEX1Z0DaWjz5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5ej6i6zFKhlf2fR3Pli+h3BHee5MwsBHpvBhwbrLNcY/WopjxIG+O7s3LojikRxNBisJQU7GVvA2UE1yFyQuLqiBvKjGy+X8ijRqZtlIM1rN6aUgPJ0sesLMZATgof8ddBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAGSOYNtXmuBvOnJDh4CRCwG+Z6B5P67mX8GHXu3bLdLOAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBt324e51j94YQl285GzN2rYa/E2DuQ0n/r35KNihi/xhZR/FJJjHS4dtY5oM9CQdygYRa1H8l5Sufbbut5aAlP0ACQkGN1CYZ9ADAABJTDYAAAAAADwBMwAAAAAAdoI5AgAAAACnElqvBQAAAOcBpmoAAAAAGQQAAAAAAAABAQAAAAAAACOukAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "CRRS5ieQmBrZjWhcj99JuGrT5tyuWDaGAXLXLFjbAtjQ": { + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "2vQhaMvLK2/+AAsAxAkAAAAAAADA1AEAAAAAAECcAAAAAAAAgNHwCAAAAAAF2xU6D/SH9pzeiQAf7ExGlbbUIV6iRLh71Otyh/hXqNcGSjj20XsRPl9m3PtjuHN//OJkSr8KALmLNDVtY4WDECcAAAAAAABQwwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "FSr3rtJ5tPMVuG7ePMmaZvp75c6Syg2hb2ZkNEa5N7z1": { + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "kjmDbV5rgbzpyQ4eAkQsBvmegeT+u5l/Bh17t2y3SzjrANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ9yv3dotewAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "9EC8qQGtw7wsBU5ySb9hQdYRcduhjiftSdPhW52WHmXc": { + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAHrANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ131NjcyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAAAA4thYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX": { + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "lQicyqD8sNnBAwAAAAAAAAAAAAAAAAAAAAAAxAkAAAAAAAAQJwAAAAAAAICWmAAAAAAAADUMAAAAAABADQMAAAAAAPBJAgAAAAAAAPCClgUAAAAGm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQyz4e5t0cIQzcDSX4MHTIDK0v9FiO4+tNA3FD3gRgUiDLPevnctauoG6DOOMPSKGFjctO/P9tXw/LdYKgFdwIUGML1SYnBjHI8sW0M/s3jLoiH369IfbHsD8odEFvzLFAYwvGw1LgPHgfQBOLmbBdQ7GAZ6dG85H/e4gZH21Q8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9": { + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyZFGFjuLAwAGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJI5g21ea4G86ckOHgJELAb5noHk/ruZfwYde7dst0s4EwCpAAeDqBUmIQ+UoOjoTSCHADs6QmDSnOedrP4hpOp3H6YokjmDbV5rgbzpyQ4eAkQsBvmegeT+u5l/Bh17t2y3SzgNAAAAU3RvbmsxMCBJbmRleAcAAABTVE9OSzEwRQAAAGh0dHBzOi8vZ2F0ZXdheS5pcnlzLnh5ei9Ibk1YVXppTkdFMnBMTTNUN1llalNKbkxFcmFyNllyWFRjYkVSUU05V3paMwAAAAA=" + } + }, + "sell_transaction": { + "transaction": { + "signatures": [ + "2EX54zBKfDetGxWh5NVrEiigauZYfz5LrMti2XKpwj1rT7Rof1aMt9bf2zB3ZFooSVvXGWhwWdzsC7GUQiuGjajT" + ], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 4 + }, + "accountKeys": [ + "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "2iQGfR5LAuDiBkPD6PvU25CPqJyzXTyZgbMCwURUNPoL", + "2UYALq5MMJbDsxyftrjWjP1jDfQ3iQHXTEXh6zGxoo9H", + "ETRpp8jWpMzySp5M9mSJCsPwu9nGEUMKnjRAb1XNmRvy", + "GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL", + "CRRS5ieQmBrZjWhcj99JuGrT5tyuWDaGAXLXLFjbAtjQ", + "HfERMT5DRA6C1TAqecrJQFpmkf3wsWTMncqnj3RDg5aw", + "GWq12s9Hb3viVbykSPXJj5C3eFBuzzzjodGcsrnKSXQh", + "routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS", + "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", + "ComputeBudget111111111111111111111111111111" + ], + "recentBlockhash": "HZByg7Cg4vkm1gbE2tfeh49RhX8sxbRdhyMvdhDaq6V", + "instructions": [ + { + "programIdIndex": 8, + "accounts": [ + 24, + 25, + 26, + 27, + 0, + 1, + 2, + 9, + 3, + 12, + 13, + 4, + 5, + 14, + 15, + 16, + 17, + 28, + 2, + 13, + 18, + 6, + 19, + 20, + 21, + 22, + 10, + 23, + 7 + ], + "data": "15aWFieR45mENZxj3yw7SS3", + "stackHeight": 1 + }, + { + "programIdIndex": 11, + "accounts": [], + "data": "3vJJFFDUmFsd", + "stackHeight": 1 + }, + { + "programIdIndex": 11, + "accounts": [], + "data": "JmM41M", + "stackHeight": 1 + } + ], + "addressTableLookups": [ + { + "accountKey": "2immgwYNHBbyVQKVGCEkgWpi53bLwWNRMB5G2nbgYV17", + "writableIndexes": [], + "readonlyIndexes": [ + 2, + 3, + 7, + 0, + 16 + ] + }, + { + "accountKey": "AtRuoUV4uisgcvxQW5fTjoHs4WqN4UrmikFAQ5hhJRwi", + "writableIndexes": [ + 87, + 2, + 85, + 89, + 88, + 91 + ], + "readonlyIndexes": [] + }, + { + "accountKey": "47Fi43gX1EHVN1LX1Nu73MDpmZ3yooB8fwtBuxufyzX7", + "writableIndexes": [ + 24, + 22, + 25, + 26, + 27, + 28 + ], + "readonlyIndexes": [] + } + ] + } + }, + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 15001, + "preBalances": [ + 2974885941, + 1513840, + 1488440, + 1488440, + 1274748140005, + 2533440, + 1705200, + 72161280, + 1329965468, + 50333585, + 523015235, + 1, + 2717800, + 1849280398207, + 3886200, + 1488440, + 213120049119, + 21351240, + 346696437393, + 529980657, + 16602001274013, + 2039293, + 32093560, + 15641604, + 200653906, + 70228638, + 3388612899, + 1, + 1857247175 + ], + "postBalances": [ + 2974870940, + 1513840, + 1488440, + 1488440, + 1274748140005, + 2533440, + 1705200, + 72161280, + 1329965468, + 50333585, + 523015235, + 1, + 2717800, + 1849280398207, + 3886200, + 1488440, + 213102947483, + 21351240, + 346696437393, + 529980657, + 16602018375649, + 2039293, + 32093560, + 15641604, + 200653906, + 70228638, + 3388612899, + 1, + 1857247175 + ], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 9, + "accounts": [ + 0, + 4, + 5, + 14, + 1, + 3, + 15, + 16, + 25, + 24, + 12, + 13, + 17 + ], + "data": "E73fXHPWvSR58EFZsNoQqeJTQ5LhfMLAB", + "stackHeight": 2 + }, + { + "programIdIndex": 25, + "accounts": [ + 1, + 12, + 15, + 0 + ], + "data": "gaKJeZQZnmtLV", + "stackHeight": 3 + }, + { + "programIdIndex": 24, + "accounts": [ + 16, + 13, + 3, + 4 + ], + "data": "gyrKVFLUfEgRN", + "stackHeight": 3 + }, + { + "programIdIndex": 28, + "accounts": [ + 0, + 6, + 19, + 3, + 2, + 20, + 21, + 22, + 24, + 25, + 10, + 13, + 18, + 23, + 7 + ], + "data": "ASCsAbe1UnE8AYJgaUixHp8aUDvr7LzKUpJrPxCyGdrpQ3Hhn18kbBjv", + "stackHeight": 2 + }, + { + "programIdIndex": 24, + "accounts": [ + 3, + 13, + 20, + 0 + ], + "data": "gyrKVFLUfEgRN", + "stackHeight": 3 + }, + { + "programIdIndex": 24, + "accounts": [ + 21, + 18, + 2, + 19 + ], + "data": "ixLVw6e4FUgbX", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS invoke [1]", + "Program log: process_swap_base_in_with_user_account:RouteSwapBaseInArgs { amount_in: 11142173221, minimum_amount_out: 974953 }", + "Program CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C invoke [2]", + "Program log: Instruction: SwapBaseInput", + "Program data: QMbN6CYIceILlroAcO2tJdyx4kD52uVs2VWb4rtrkVklzv7+7Ro6Kgjr0GGlfAAAO75amTEAAAAlEiCYAgAAAETzBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAGSOYNtXmuBvOnJDh4CRCwG+Z6B5P67mX8GHXu3bLdLOAabiFf+q4GE+2h/Y0YYwDXaxDncGus7VZig8AAAAAABSgqpAQAAAADIogIAAAAAAAA=", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]", + "Program log: Instruction: TransferChecked", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 2442 of 87347 compute units", + "Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 82842 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C consumed 28123 of 109243 compute units", + "Program CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C success", + "Program log: 11142173221 -> 17101636", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK invoke [2]", + "Program log: Instruction: SwapV2", + "Program data: QMbN6CYIceIRTG6ayGJiNpKyNY9MROK+tKCwgbHboOh8/E9rRpdDcwXbFToP9If2nN6JAB/sTEaVttQhXqJEuHvU63KH+Feox+0ZngIKtoaAV+objHYfiGH5m2HgXH8C0OOhRIA1T4oV6ZpKrYFLLzdxepKwLFHlu3D5qX7q1uUn2+QZHWd1GETzBAEAAAAAAAAAAAAAAADgBg8AAAAAAAAAAAAAAAAAASY8YHpijHI9AAAAAAAAAAChcTr9HhUAAAAAAAAAAAAAgpD//2chAAAAAAAAAAAAAAAAAAA=", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 112 of 19460 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 105 of 17081 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK consumed 47294 of 62051 compute units", + "Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK success", + "Program log: 17101636 -> 984800", + "Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS consumed 118139 of 130230 compute units", + "Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success" + ], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "uiTokenAmount": { + "uiAmount": 11142.173221, + "decimals": 6, + "amount": "11142173221", + "uiAmountString": "11142.173221" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 2, + "mint": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "uiTokenAmount": { + "uiAmount": 4.993657, + "decimals": 6, + "amount": "4993657", + "uiAmountString": "4.993657" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 3, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": null, + "decimals": 9, + "amount": "0", + "uiAmountString": "0" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 15, + "mint": "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "uiTokenAmount": { + "uiAmount": 137074168.16775, + "decimals": 6, + "amount": "137074168167750", + "uiAmountString": "137074168.16775" + }, + "owner": "GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 16, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 213.118560679, + "decimals": 9, + "amount": "213118560679", + "uiAmountString": "213.118560679" + }, + "owner": "GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 20, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 16601.990977481, + "decimals": 9, + "amount": "16601990977481", + "uiAmountString": "16601.990977481" + }, + "owner": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 21, + "mint": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "uiTokenAmount": { + "uiAmount": 1118669.867343, + "decimals": 6, + "amount": "1118669867343", + "uiAmountString": "1118669.867343" + }, + "owner": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "uiTokenAmount": { + "uiAmount": null, + "decimals": 6, + "amount": "0", + "uiAmountString": "0" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 2, + "mint": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "uiTokenAmount": { + "uiAmount": 5.978457, + "decimals": 6, + "amount": "5978457", + "uiAmountString": "5.978457" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 3, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": null, + "decimals": 9, + "amount": "0", + "uiAmountString": "0" + }, + "owner": "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 15, + "mint": "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "uiTokenAmount": { + "uiAmount": 137085310.340971, + "decimals": 6, + "amount": "137085310340971", + "uiAmountString": "137085310.340971" + }, + "owner": "GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "accountIndex": 16, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 213.101459043, + "decimals": 9, + "amount": "213101459043", + "uiAmountString": "213.101459043" + }, + "owner": "GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 20, + "mint": "So11111111111111111111111111111111111111112", + "uiTokenAmount": { + "uiAmount": 16602.008079117, + "decimals": 9, + "amount": "16602008079117", + "uiAmountString": "16602.008079117" + }, + "owner": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "accountIndex": 21, + "mint": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "uiTokenAmount": { + "uiAmount": 1118668.882543, + "decimals": 6, + "amount": "1118668882543", + "uiAmountString": "1118668.882543" + }, + "owner": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "rewards": [], + "loadedAddresses": { + "writable": [ + "AqoPZcUumKUBHrnfBsNtoNuneYEjoimaiWYq8GH8gpX9", + "So11111111111111111111111111111111111111112", + "nEnhtdXrHocxDQnmfTWgaLodJP8jxFbY6958iv28GzV", + "FSr3rtJ5tPMVuG7ePMmaZvp75c6Syg2hb2ZkNEa5N7z1", + "9EC8qQGtw7wsBU5ySb9hQdYRcduhjiftSdPhW52WHmXc", + "7ZByvGQ2xwREo3ZwMSXgU6gL1wQUJWmzQ4hiEvuCajMD", + "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "9Jgp8NpqEDFd5d3RQPfuRY7gMgRFByTNFmi68Ph1yvVb", + "Be1CFyoPAr8aBGxpvCPD2LD21hdz2vjYNq8EcypnmgGD", + "DCURDhS5do6w9EytNmFxUNp3kYqXxfkv61Gs7FtLcH5a", + "7mJwQZo15Q8nVS42WsdUbeqTDHUcLtGPg3j9p3fqNQcX" + ], + "readonly": [ + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "11111111111111111111111111111111", + "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK" + ] + }, + "computeUnitsConsumed": 118439, + "costUnits": 126454 + }, + "version": 0, + "slot": 453343280, + "transactionIndex": 59, + "blockTime": 1791140173 + } + } +} \ No newline at end of file diff --git a/tests/regression/verify_launchlab_trades.py b/tests/regression/verify_launchlab_trades.py index 1285d28..37a9ea3 100644 --- a/tests/regression/verify_launchlab_trades.py +++ b/tests/regression/verify_launchlab_trades.py @@ -187,6 +187,7 @@ def _pool_before(entry: dict, event: dict, base_decimals: int = 6) -> dict: "global_config": Pubkey.from_string(entry["global_config"]), "platform_config": Pubkey.from_string(entry["platform_config"]), "base_mint": Pubkey.from_string(entry["base_mint"]), + "status": 0, } diff --git a/tests/regression/verify_sell_after_graduation.py b/tests/regression/verify_sell_after_graduation.py new file mode 100644 index 0000000..241211e --- /dev/null +++ b/tests/regression/verify_sell_after_graduation.py @@ -0,0 +1,447 @@ +"""Verify a held coin that graduates is priced and sold in the pool it migrated to. + +A bonding curve that completes takes no more trades: pump.fun hands the coin to +PumpSwap, Raydium LaunchLab to a CPMM pool. The bot only knew the curve. A +graduated pump.fun curve has zero reserves, so its decoder raised and every +price read and pre-sell refresh failed; a migrated LaunchLab pool keeps its +final reserves, so it reported a frozen price. Either way the exit sold into a +curve that refuses it, and the position was stranded. + +Fixture: `raw_graduated_markets_from_getaccountinfo.json` — for one graduated +pump.fun coin and one graduated StonkFun coin: the curve, the AMM pool, its +token accounts and config, and a real sell into that pool. + +Offline machine checks, no network and no funds moved: + + A. Both curve managers report `graduated` for the fixture curves, and + `calculate_price` raises CurveGraduatedError instead of returning a price. + B. The AMM pool is derived from the mint alone and matches the fixture's. + C. Each market's sell names the same accounts as the real sell on chain, + apart from the seller's own accounts and the randomly picked fee and + buyback recipients, which come from PumpSwap's GlobalConfig. + D. Each market prices off its pool: PumpSwap from the token-account balances + plus the pool's virtual quote reserves, CPMM from the vault balances less + the fees accrued in them. + E. The seller sells a graduated coin through the market, floored by the + market's fee and the coin's transfer fee; with the pool not readable yet + it sends nothing and reports SUBMIT_FAILED. + F. The trader's price read switches to the market when the curve graduates. + +Usage: + uv run tests/regression/verify_sell_after_graduation.py +""" + +import asyncio +import base64 +import json +import struct +import sys +from pathlib import Path +from types import SimpleNamespace + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from solders.pubkey import Pubkey # noqa: E402 + +from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402 +from interfaces.core import ( # noqa: E402 + CurveGraduatedError, + Platform, + TokenInfo, + TradeFailureReason, +) +from platforms.launchlab.cpmm_market import CPMM_PROGRAM, CpmmMarket # noqa: E402 +from platforms.pumpfun.curve_manager import PumpFunCurveManager # noqa: E402 +from platforms.pumpfun.pumpswap_market import ( # noqa: E402 + _CONFIG_BUYBACK_RECIPIENTS_OFFSET, + _CONFIG_PROTOCOL_RECIPIENTS_OFFSET, + PUMP_AMM_PROGRAM, + PumpSwapMarket, + canonical_pool_address, +) +from platforms.stonkfun import StonkFunCurveManager # noqa: E402 +from trading import platform_aware # noqa: E402 +from trading.platform_aware import PlatformAwareSeller # noqa: E402 +from trading.universal_trader import UniversalTrader # noqa: E402 +from utils.idl_manager import get_idl_manager # noqa: E402 + +FIXTURE = json.loads( + Path(__file__) + .with_name("raw_graduated_markets_from_getaccountinfo.json") + .read_text() +) +PUMP, LAUNCHLAB = FIXTURE["pumpfun"], FIXTURE["launchlab"] + + +class _FixtureClient: + """Serves the fixture's accounts by address; anything else does not exist.""" + + def __init__(self, accounts: dict) -> None: + self.accounts = { + Pubkey.from_string(k): SimpleNamespace( + data=base64.b64decode(v["data"]), owner=Pubkey.from_string(v["owner"]) + ) + for k, v in accounts.items() + } + + async def get_account_info(self, address: Pubkey, commitment: str | None = None): # noqa: ARG002 + if address not in self.accounts: + raise ValueError(f"Account {address} not found") # noqa: TRY003 + return self.accounts[address] + + async def get_multiple_accounts( + self, addresses: list, commitment: str | None = None + ): # noqa: ARG002 + return [self.accounts.get(a) for a in addresses] + + +def _pump_token() -> TokenInfo: + return TokenInfo( + name="", + symbol="PUMP", + uri="", + mint=Pubkey.from_string(PUMP["mint"]), + platform=Platform.PUMP_FUN, + bonding_curve=Pubkey.from_string(PUMP["curve"]), + quote_mint=WSOL_MINT, + token_program_id=SystemAddresses.TOKEN_2022_PROGRAM, + ) + + +def _launchlab_token() -> TokenInfo: + return TokenInfo( + name="", + symbol="STONK", + uri="", + mint=Pubkey.from_string(LAUNCHLAB["mint"]), + platform=Platform.STONK_FUN, + pool_state=Pubkey.from_string(LAUNCHLAB["launchlab_pool"]), + platform_config=Pubkey.from_string(LAUNCHLAB["platform_config"]), + quote_mint=Pubkey.from_string(LAUNCHLAB["quote_mint"]), + token_program_id=SystemAddresses.TOKEN_2022_PROGRAM, + ) + + +def _chain_sell(fixture: dict, program: Pubkey) -> list[str]: + """Accounts of the real sell into the fixture pool.""" + tx = fixture["sell_transaction"] + loaded = tx["meta"].get("loadedAddresses") or {} + keys = ( + tx["transaction"]["message"]["accountKeys"] + + loaded.get("writable", []) + + loaded.get("readonly", []) + ) + instructions = list(tx["transaction"]["message"]["instructions"]) + [ + ix + for group in tx["meta"].get("innerInstructions") or [] + for ix in group["instructions"] + ] + for ix in instructions: + accounts = [keys[i] for i in ix["accounts"]] + if keys[ix["programIdIndex"]] == str(program) and len(accounts) > 12: + return accounts + raise AssertionError("fixture has no sell into the pool") + + +def check_a_curves_report_graduation() -> bool: + """A: graduated, and calculate_price refuses to price the curve.""" + parser = get_idl_manager().get_parser(Platform.PUMP_FUN) + results = [] + for manager, pool in ( + ( + PumpFunCurveManager(_FixtureClient(PUMP["accounts"]), parser), + PUMP["curve"], + ), + ( + StonkFunCurveManager( + _FixtureClient(LAUNCHLAB["accounts"]), + get_idl_manager().get_parser(Platform.STONK_FUN), + ), + LAUNCHLAB["launchlab_pool"], + ), + ): + address = Pubkey.from_string(pool) + state = asyncio.run(manager.get_pool_state(address)) + try: + asyncio.run(manager.calculate_price(address)) + raised = False + except CurveGraduatedError: + raised = True + results.append(state["graduated"] and raised) + if not all(results): + print(f" pump.fun, LaunchLab: {results}") + return all(results) + + +def check_b_pools_derive_from_the_mint() -> bool: + """B: canonical PumpSwap pool and CPMM pool, no search.""" + pump_pool = canonical_pool_address(Pubkey.from_string(PUMP["mint"]), WSOL_MINT) + market = CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"])) + cpmm_pool = asyncio.run(market._resolve(_launchlab_token())).address + ok = str(pump_pool) == PUMP["pool"] and str(cpmm_pool) == LAUNCHLAB["cpmm_pool"] + if not ok: + print(f" pumpswap {pump_pool}, cpmm {cpmm_pool}") + return ok + + +def _config_recipients(offset: int) -> set[str]: + config = base64.b64decode( + next( + v["data"] + for k, v in PUMP["accounts"].items() + if len(base64.b64decode(v["data"])) > 900 + ) + ) + return { + str(Pubkey.from_bytes(config[offset + 32 * i : offset + 32 * (i + 1)])) + for i in range(8) + } + + +def check_c_sell_accounts_match_chain() -> bool: + """C: same accounts as the on-chain sells, bar the seller's own and random picks.""" + ok = True + for name, market, token, program in ( + ( + "pumpswap", + PumpSwapMarket(_FixtureClient(PUMP["accounts"])), + _pump_token(), + PUMP_AMM_PROGRAM, + ), + ( + "cpmm", + CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"])), + _launchlab_token(), + CPMM_PROGRAM, + ), + ): + fixture = PUMP if name == "pumpswap" else LAUNCHLAB + chain = _chain_sell(fixture, program) + seller = Pubkey.from_string(chain[1] if name == "pumpswap" else chain[0]) + instructions = asyncio.run( + market.build_sell_instruction(token, seller, 1, 1, None) + ) + ours = [ + str(m.pubkey) + for m in next( + ix for ix in instructions if ix.program_id == program + ).accounts + ] + # The seller's own token accounts: the bot uses its ATA and a throwaway + # WSOL account, the on-chain seller whatever it held. + variable = {5, 6} if name == "pumpswap" else {4, 5} + if name == "pumpswap": + protocol = _config_recipients(_CONFIG_PROTOCOL_RECIPIENTS_OFFSET) + buyback = _config_recipients(_CONFIG_BUYBACK_RECIPIENTS_OFFSET) + # Recipient picks are random: check membership, skip their ATAs. + if ours[9] not in protocol or chain[9] not in protocol: + print(f" {name}: fee recipient not from GlobalConfig") + ok = False + if ours[-2] not in buyback or chain[-2] not in buyback: + print(f" {name}: buyback recipient not from GlobalConfig") + ok = False + variable |= {9, 10, len(chain) - 2, len(chain) - 1} + differs = [ + i + for i in range(max(len(ours), len(chain))) + if i not in variable + and (i >= len(ours) or i >= len(chain) or ours[i] != chain[i]) + ] + if differs or len(ours) != len(chain): + print( + f" {name}: {len(ours)} vs {len(chain)} accounts, differ at {differs}" + ) + ok = False + return ok + + +def _amount(fixture: dict, address: Pubkey) -> int: + data = base64.b64decode(fixture["accounts"][str(address)]["data"]) + return struct.unpack_from(" bool: + """D: price = reserves ratio as the program counts them.""" + pump_market = PumpSwapMarket(_FixtureClient(PUMP["accounts"])) + pump_state = asyncio.run(pump_market.get_market_state(_pump_token())) + pump_pool = pump_market._pools[Pubkey.from_string(PUMP["mint"])] + pool_data = base64.b64decode(PUMP["accounts"][PUMP["pool"]]["data"]) + virtual = int.from_bytes(pool_data[245:261], "little", signed=True) + pump_expected = ((_amount(PUMP, pump_pool.quote_account) + virtual) / 1e9) / ( + _amount(PUMP, pump_pool.base_account) / 1e6 + ) + + cpmm_market = CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"])) + cpmm_state = asyncio.run(cpmm_market.get_market_state(_launchlab_token())) + pool = cpmm_market._pools[Pubkey.from_string(LAUNCHLAB["mint"])] + data = base64.b64decode(LAUNCHLAB["accounts"][LAUNCHLAB["cpmm_pool"]]["data"]) + fees = struct.unpack_from("<4Q", data, 8 + 320 + 5 + 8) + creator = struct.unpack_from("<2Q", data, 8 + 320 + 5 + 56 + 2 + 6) + accrued = [fees[i] + fees[2 + i] + creator[i] for i in (0, 1)] + base_i = 0 if pool.base_is_token_0 else 1 + cpmm_expected = ( + (_amount(LAUNCHLAB, pool.quote_vault) - accrued[1 - base_i]) + / 10**pool.quote_decimals + ) / ( + (_amount(LAUNCHLAB, pool.base_vault) - accrued[base_i]) / 10**pool.base_decimals + ) + + ok = ( + abs(pump_state["price_per_token"] - pump_expected) <= pump_expected * 1e-12 + and abs(cpmm_state["price_per_token"] - cpmm_expected) <= cpmm_expected * 1e-12 + ) + if not ok: + print( + f" pumpswap {pump_state} vs {pump_expected}; cpmm {cpmm_state} vs {cpmm_expected}" + ) + return ok + + +class _StubClient: + def __init__(self) -> None: + self.sent: list = [] + + async def build_and_send_transaction(self, instructions, *_a, **_k) -> str: + self.sent.append(instructions) + return "STUB_SIGNATURE" + + async def confirm_transaction_detailed(self, *_a, **_k): + return platform_aware.ConfirmationStatus.REVERTED + + +class _GraduatedCurve: + async def get_pool_state(self, _pool, commitment=None) -> dict: # noqa: ARG002 + return {"graduated": True, "quote_mint": WSOL_MINT, "transfer_fee_bps": 100} + + async def calculate_price(self, _pool) -> float: + raise CurveGraduatedError("stub") + + +class _Market: + def __init__(self, *, ready: bool) -> None: + self.ready = ready + self.minimum_out: int | None = None + + async def get_market_state(self, _token) -> dict: + if not self.ready: + raise ValueError("pool not created yet") # noqa: TRY003 + return {"price_per_token": 0.00000005, "fee_fraction": 0.0125, "pool": "POOL"} + + async def build_sell_instruction(self, _token, _user, _amount, minimum_out, _ap): + self.minimum_out = minimum_out + return ["market-sell"] + + def get_required_accounts_for_sell(self, *_a) -> list: + return [] + + def get_sell_compute_unit_limit(self, _override=None) -> int: + return 150_000 + + +async def _no_fee(_accounts) -> None: + return None + + +def _sell(market: _Market) -> tuple[object, _StubClient]: + client = _StubClient() + platform_aware.get_platform_implementations = lambda _p, _c: SimpleNamespace( + address_provider=SimpleNamespace(), + instruction_builder=SimpleNamespace(), + curve_manager=_GraduatedCurve(), + graduated_market=market, + ) + seller = PlatformAwareSeller( + client, + SimpleNamespace(pubkey=Pubkey.new_unique(), keypair=None), + SimpleNamespace(calculate_priority_fee=_no_fee), + slippage=0.2, + max_retries=1, + ) + token = TokenInfo( + name="", + symbol="T", + uri="", + mint=Pubkey.new_unique(), + platform=Platform.STONK_FUN, + pool_state=Pubkey.new_unique(), + quote_mint=WSOL_MINT, + ) + result = asyncio.run(seller.execute(token, 1_000_000.0, 0.00000005)) + return result, client + + +def check_e_seller_routes_to_market() -> bool: + """E: graduated -> market sell with fee- and tax-net floor; not ready -> nothing sent.""" + ready = _Market(ready=True) + _, client = _sell(ready) + expected = int(1_000_000 * (1 - 0.01) * 0.00000005 * (1 - 0.0125) * (1 - 0.2) * 1e9) + routed = ( + client.sent == [["market-sell"]] + and abs((ready.minimum_out or 0) - expected) <= 1 + ) + + result, client = _sell(_Market(ready=False)) + held = ( + not client.sent + and not result.success + and result.failure_reason is TradeFailureReason.SUBMIT_FAILED + ) + if not (routed and held): + print( + f" routed={routed} floor={ready.minimum_out} expected {expected} held={held}" + ) + return routed and held + + +def check_f_trader_price_follows_graduation() -> bool: + """F: CurveGraduatedError -> the market's price.""" + trader = SimpleNamespace( + platform=Platform.STONK_FUN, + platform_implementations=SimpleNamespace( + curve_manager=_GraduatedCurve(), graduated_market=_Market(ready=True) + ), + _graduated_mints=set(), + _get_pool_address=lambda _token: Pubkey.new_unique(), + ) + token = TokenInfo( + name="", + symbol="T", + uri="", + mint=Pubkey.new_unique(), + platform=Platform.STONK_FUN, + ) + price = asyncio.run(UniversalTrader._read_price(trader, token)) + ok = price == 0.00000005 + if not ok: + print(f" price {price}") + return ok + + +def main() -> int: + """Run every check and report.""" + checks = [ + ("A: curves report graduation", check_a_curves_report_graduation), + ("B: AMM pools derive from the mint", check_b_pools_derive_from_the_mint), + ("C: market sells match the chain", check_c_sell_accounts_match_chain), + ("D: markets price off their pools", check_d_markets_price_off_their_pools), + ( + "E: seller routes a graduated coin to the market", + check_e_seller_routes_to_market, + ), + ( + "F: trader prices off the market after graduation", + check_f_trader_price_follows_graduation, + ), + ] + passed = 0 + for label, check in checks: + ok = check() + print(f"{'PASS' if ok else 'FAIL'} {label}") + passed += ok + print(f"\n{passed}/{len(checks)} checks passed") + return 0 if passed == len(checks) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/live_graduated_sell.py b/tools/live_graduated_sell.py new file mode 100644 index 0000000..75db76e --- /dev/null +++ b/tools/live_graduated_sell.py @@ -0,0 +1,162 @@ +"""Price and sell a coin you already hold whose curve has graduated, via the bot's path. + +WARNING: this submits a real transaction and sells real funds. + +The bot only holds coins it bought at launch, and a curve rarely graduates while +it holds one, so that hand-off cannot be staged with the bot itself. This runs +the two pieces it would use on the coin you name: the trader's price read +(curve -> CurveGraduatedError -> the platform's GraduatedMarket) and +PlatformAwareSeller, which reads the curve, sees `graduated` and sells into the +AMM pool. Buy the coin first with a cookbook AMM buy script. + +Usage: + uv run tools/live_graduated_sell.py pump_fun + uv run tools/live_graduated_sell.py stonk_fun --slippage 0.3 +""" + +import argparse +import asyncio +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from dotenv import load_dotenv # noqa: E402 +from solana.rpc.core import DataSliceOpts, MemcmpOpts # noqa: E402 +from solders.pubkey import Pubkey # noqa: E402 + +from core.client import SolanaClient # noqa: E402 +from core.priority_fee.manager import PriorityFeeManager # noqa: E402 +from core.pubkeys import TOKEN_DECIMALS, resolve_quote_token_program # noqa: E402 +from core.wallet import Wallet # noqa: E402 +from interfaces.core import Platform, TokenInfo # noqa: E402 +from platforms import get_platform_implementations # noqa: E402 +from platforms.launchlab import LAUNCHLAB_PROGRAM # noqa: E402 +from trading.platform_aware import PlatformAwareSeller # noqa: E402 +from trading.universal_trader import UniversalTrader # noqa: E402 +from utils.logger import install_secret_redaction # noqa: E402 + +load_dotenv(PROJECT_ROOT / ".env") + +PRIORITY_FEE = 1_000_000 +DEFAULT_SLIPPAGE = 0.3 +LAUNCHLAB_POOL_SIZE = 429 +LAUNCHLAB_BASE_MINT_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 2 * 32 + + +async def token_info_for( + platform: Platform, mint: Pubkey, client: SolanaClient, impl +) -> TokenInfo: + """Build the TokenInfo the bot would hold, from the curve's own state.""" + mint_owner = (await client.get_account_info(mint)).owner + if platform == Platform.PUMP_FUN: + curve = impl.address_provider.derive_pool_address(mint) + state = await impl.curve_manager.get_pool_state(curve) + return TokenInfo( + name="", + symbol=str(mint)[:6], + uri="", + mint=mint, + platform=platform, + bonding_curve=curve, + creator=Pubkey.from_string(str(state["creator"])), + quote_mint=state["quote_mint"], + token_program_id=mint_owner, + ) + found = await (await client.get_client()).get_program_accounts( + LAUNCHLAB_PROGRAM, + encoding="base64", + data_slice=DataSliceOpts(offset=0, length=0), + filters=[ + LAUNCHLAB_POOL_SIZE, + MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)), + ], + ) + pool = found.value[0].pubkey + state = await impl.curve_manager.get_pool_state(pool) + return TokenInfo( + name="", + symbol=str(mint)[:6], + uri="", + mint=mint, + platform=platform, + pool_state=pool, + base_vault=state["base_vault"], + quote_vault=state["quote_vault"], + global_config=state["global_config"], + platform_config=state["platform_config"], + creator=state["creator"], + quote_mint=state["quote_mint"], + token_program_id=mint_owner, + ) + + +async def run(platform: Platform, mint: Pubkey, slippage: float) -> int: + """Read the price the bot would read, then sell the whole balance.""" + client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"]) + wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"]) + try: + impl = get_platform_implementations(platform, client) + token_info = await token_info_for(platform, mint, client, impl) + await resolve_quote_token_program( + token_info.quote_mint, client.get_account_info + ) + + trader = SimpleNamespace( + platform=platform, + platform_implementations=impl, + _graduated_mints=set(), + _get_pool_address=lambda t: t.bonding_curve or t.pool_state, + ) + price = await UniversalTrader._read_price(trader, token_info) + print(f"Price via the trader: {price:.12f} quote per token") + + ata = wallet.get_associated_token_address(mint, token_info.token_program_id) + balance = await client.get_token_account_balance(ata) + print(f"Holding: {balance / 10**TOKEN_DECIMALS:,.6f} tokens") + + seller = PlatformAwareSeller( + client, + wallet, + PriorityFeeManager( + client=client, + enable_dynamic_fee=False, + enable_fixed_fee=True, + fixed_fee=PRIORITY_FEE, + extra_fee=0.0, + hard_cap=PRIORITY_FEE, + ), + slippage=slippage, + max_retries=2, + ) + result = await seller.execute(token_info, balance / 10**TOKEN_DECIMALS, price) + print( + f"Sell: success={result.success} tx={result.tx_signature} error={result.error_message}" + ) + return 0 if result.success else 1 + finally: + await client.close() + + +def main() -> None: + """Parse the command line and run.""" + parser = argparse.ArgumentParser( + description="Sell a held, graduated coin via the bot's path" + ) + parser.add_argument("platform", choices=[p.value for p in Platform]) + parser.add_argument("mint", help="The coin's mint address") + parser.add_argument("--slippage", type=float, default=DEFAULT_SLIPPAGE) + args = parser.parse_args() + install_secret_redaction() + sys.exit( + asyncio.run( + run(Platform(args.platform), Pubkey.from_string(args.mint), args.slippage) + ) + ) + + +if __name__ == "__main__": + main()