diff --git a/CLAUDE.md b/CLAUDE.md index 35a4de4..81e4e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,8 @@ src/pumpfun_cli/ ├── crypto.py # AES-256-GCM wallet encryption (scrypt KDF) ├── output.py # render() + error() — TTY-aware output ├── commands/ # Thin CLI layer (config, info, launch, tokens, trade, tx_status, wallet) -├── core/ # Business logic (config, info, launch, pumpswap, tokens, trade, tx_status, validate, wallet) -└── protocol/ # Solana primitives (address, client, contracts, curve, idl_parser, instructions, pumpswap, quote) +├── core/ # Business logic (config, cpmm, info, launch, launchlab, pumpswap, tokens, trade, tx_status, validate, wallet) +└── protocol/ # Solana primitives (address, client, contracts, cpmm, curve, idl_parser, instructions, launchlab, pumpswap, quote) tests/ ├── test_commands/ # CLI smoke tests @@ -68,7 +68,7 @@ tests/ **Error handling:** `error()` raises `SystemExit` — code after it is unreachable. Core functions return `dict` with `"error"` key for expected failures (graduated tokens, not found, slippage exceeded, insufficient_balance). Catch `ValueError` for wrong wallet password in every command that decrypts. Buy/sell functions perform pre-trade balance validation — SOL for fees and rent, the quote asset for the spend, token balance for sells with specific amounts. -**Auto-routing:** When `buy_token` / `sell_token` returns `{"error": "graduated"}`, the command layer in `commands/trade.py` automatically retries via `buy_pumpswap` / `sell_pumpswap` (PumpSwap AMM). Never suppress or swallow the `"graduated"` error in core — the routing decision belongs in the command layer. +**Auto-routing:** `_route` in `commands/trade.py` picks the venue from the errors core returns: `{"error": "graduated"}` from `buy_token` / `sell_token` retries on PumpSwap, `{"error": "not_found"}` retries on LaunchLab (`buy_launchlab` / `sell_launchlab`), and LaunchLab's `graduated` retries on Raydium CPMM (`buy_cpmm` / `sell_cpmm`). `--force-amm` tries PumpSwap and falls back to CPMM only when PumpSwap reports no pool (`pool_missing`); PumpSwap's error stands unless CPMM recognises the coin as LaunchLab's. Never suppress or swallow those errors in core — the routing decision belongs in the command layer. `info` falls back to LaunchLab, and to its CPMM pool, the same way in `core/info.py`. **Imports:** stdlib → third-party → local. Example: ```python @@ -130,6 +130,21 @@ lists; these are the ones this CLI depends on. pool, then `pool-v2` **only on a canonical pool** (`coin_creator` set), then the buyback recipient and its quote ATA last. The canonical pool is derived from the mint and the curve's quote mint. +- `migrate_v2` also carries accounts the IDL omits: after its 27, the new pool's + boost vault authority (`["boost_vault", pool]` on PumpSwap) and that + authority's quote token account. Without them PumpSwap reverts with 6027. +- LaunchLab (`protocol/launchlab.py`) pools are keyed by base and quote mint. + The SOL pool is derived; any other quote is found by `getProgramAccounts` + on the base mint (PoolState offset 205). Swaps are the IDL's 15 accounts plus + the system program and the platform and creator fee vaults + (`[platform_config, quote]`, `[creator, quote]`). The curve fee is + GlobalConfig's trade fee plus PlatformConfig's fee and creator fee, read + live; a Token-2022 coin's transfer fee comes off what arrives. +- A graduated LaunchLab coin's CPMM pool (`protocol/cpmm.py`) is derived from + its PlatformConfig's `cpswap_config` and the two mints sorted by bytes. + Reserves are the vault balances less the protocol, fund and creator fees the + pool still owes. Both sides are `swap_base_input`, input token's accounts + first. `migrate_type` 0 (AMM v4) is refused. - `send_tx` sends v0 and falls back to v1 when the transaction exceeds 1232 bytes (a cashback-pool buy, a launch with a buy). v1 carries the compute budget in its header. diff --git a/README.md b/README.md index 22292fd..aef84bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pumpfun-cli -A standalone command-line tool for launching and trading tokens with the [pump.fun](https://pump.fun). +A standalone command-line tool for launching and trading tokens on [pump.fun](https://pump.fun), and for trading coins launched on Raydium LaunchLab (letsbonk.fun, StonkFun and other launchpads). Built from experience developing [pumpfun-bot](https://github.com/chainstacklabs/pumpfun-bonkfun-bot), distilled into a stateless CLI designed for humans, scripts, and LLM agents alike. @@ -73,6 +73,17 @@ pump.fun no longer accepts anonymous metadata uploads, so `launch` takes the URI of a metadata JSON you host (`name`, `symbol`, `description`, `image`). Without `--uri` it still tries the upload and says so when it is refused. +`buy`, `sell` and `info` also work on coins launched on Raydium LaunchLab, the +program behind letsbonk.fun, StonkFun and other launchpads: a mint with no +pump.fun bonding curve is looked up there, and the output says `"venue": +"launchlab"` and which launchpad (`platform`). A LaunchLab coin priced in +anything but SOL is found with `getProgramAccounts`, so it needs an RPC that +serves it. Token-2022 transfer fees are taken into account in the amounts. +Once a LaunchLab coin graduates it trades on Raydium CPMM (`"venue": "cpmm"`), +the same way a graduated pump.fun coin trades on PumpSwap; `--force-amm` goes +straight to whichever pool the coin has. A coin that graduated to Raydium AMM +v4 instead is refused. + Amounts are in the coin's quote asset. Most pump.fun coins are priced in SOL, but a coin can be priced in USDC or another admitted mint; `buy`, `sell` and `info` read it from the coin and label their output with it. Buying such a coin @@ -89,6 +100,12 @@ without `tokens_received_raw`, so check for the field. Dry-run fields (`expected_tokens_raw`, `expected_quote_raw`, `min_*_out_raw`) are exact integers of the quote the transaction would be built from, not executed amounts. +A bonding curve pays a sell out of its real reserves, which on some coins hold +less than the price promises. `sell` then sells the most the curve can pay for +and reports the rest as `tokens_kept`; when it can pay nothing it sends nothing +(`curve_short`). A buy bigger than what is left on the curve fails with +`exceeds_curve` and the tokens left in `curve_tokens_left`. + ## Commands ``` @@ -100,7 +117,7 @@ Token discovery (zero config; price and progress added when an RPC is set): pumpfun tokens search Search by name or ticker Token info (requires RPC): - pumpfun info Price, bonding progress, reserves + pumpfun info Price, bonding progress, reserves (pump.fun or LaunchLab, and their pools) Trading (requires RPC + wallet): pumpfun buy [--slippage N] [--force-amm] [--dry-run] [--confirm] @@ -151,9 +168,9 @@ Global options: | `tokens recommended` | `frontend-api-v3.pump.fun/coins/recommendations` | HTTPS | | `tokens search` | `frontend-api-v3.pump.fun/coins/search-unrestricted` | HTTPS | | `tokens *` with an RPC set | + Solana RPC (`getMultipleAccounts` for the bonding curves) | JSON-RPC | -| `info` | Solana RPC (`getAccountInfo` for bonding curve PDA) | JSON-RPC | -| `buy` | Solana RPC (`getAccountInfo`, `getLatestBlockhash`, `sendTransaction`) | JSON-RPC | -| `sell` | Solana RPC (`getAccountInfo`, `getTokenAccountBalance`, `getLatestBlockhash`, `sendTransaction`) | JSON-RPC | +| `info` | Solana RPC (`getAccountInfo` for bonding curve PDA; LaunchLab: `getMultipleAccounts`, `getProgramAccounts`) | JSON-RPC | +| `buy` | Solana RPC (`getAccountInfo`, `getLatestBlockhash`, `sendTransaction`; LaunchLab adds `getMultipleAccounts`, `getProgramAccounts`) | JSON-RPC | +| `sell` | Solana RPC (`getAccountInfo`, `getTokenAccountBalance`, `getLatestBlockhash`, `sendTransaction`; LaunchLab as `buy`) | JSON-RPC | | `launch` | Solana RPC (`sendTransaction`); `pump.fun/api/ipfs` only without `--uri` | JSON-RPC (+ HTTPS) | | `tx-status` | Solana RPC (`getTransaction`) | JSON-RPC | | `migrate` | Solana RPC (`sendTransaction`) | JSON-RPC | diff --git a/idl/raydium_launchlab_idl.json b/idl/raydium_launchlab_idl.json new file mode 100644 index 0000000..6f2d807 --- /dev/null +++ b/idl/raydium_launchlab_idl.json @@ -0,0 +1,6360 @@ +{ + "address": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "metadata": { + "name": "raydium_launchpad", + "version": "0.2.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "buy_exact_in", + "docs": [ + "Use the given amount of quote tokens to purchase base tokens.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `amount_in` - Amount of quote token to purchase", + "* `minimum_amount_out` - Minimum amount of base token to receive (slippage protection)", + "* `share_fee_rate` - Fee rate for the share", + "" + ], + "discriminator": [ + 250, + 234, + 13, + 123, + 213, + 156, + 19, + 236 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The user performing the swap operation", + "Must sign the transaction and pay for fees" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Used to read protocol fee rates and curve type" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform-wide settings", + "Used to read platform fee rate" + ] + }, + { + "name": "pool_state", + "docs": [ + "The pool state account where the swap will be performed", + "Contains current pool parameters and balances" + ], + "writable": true + }, + { + "name": "user_base_token", + "docs": [ + "The user's token account for base tokens (tokens being bought)", + "Will receive the output tokens after the swap" + ], + "writable": true + }, + { + "name": "user_quote_token", + "docs": [ + "The user's token account for quote tokens (tokens being sold)", + "Will be debited for the input amount" + ], + "writable": true + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be debited to send tokens to the user" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will receive the input tokens from the user" + ], + "writable": true + }, + { + "name": "base_token_mint", + "docs": [ + "The mint of the base token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "quote_token_mint", + "docs": [ + "The mint of the quote token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "base_token_program", + "docs": [ + "Token program owning the base token: either SPL Token or Token-2022" + ] + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "amount_in", + "type": "u64" + }, + { + "name": "minimum_amount_out", + "type": "u64" + }, + { + "name": "share_fee_rate", + "type": "u64" + } + ] + }, + { + "name": "buy_exact_out", + "docs": [ + "Use quote tokens to purchase the given amount of base tokens.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `amount_out` - Amount of base token to receive", + "* `maximum_amount_in` - Maximum amount of quote token to purchase (slippage protection)", + "* `share_fee_rate` - Fee rate for the share" + ], + "discriminator": [ + 24, + 211, + 116, + 40, + 105, + 3, + 153, + 56 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The user performing the swap operation", + "Must sign the transaction and pay for fees" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Used to read protocol fee rates and curve type" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform-wide settings", + "Used to read platform fee rate" + ] + }, + { + "name": "pool_state", + "docs": [ + "The pool state account where the swap will be performed", + "Contains current pool parameters and balances" + ], + "writable": true + }, + { + "name": "user_base_token", + "docs": [ + "The user's token account for base tokens (tokens being bought)", + "Will receive the output tokens after the swap" + ], + "writable": true + }, + { + "name": "user_quote_token", + "docs": [ + "The user's token account for quote tokens (tokens being sold)", + "Will be debited for the input amount" + ], + "writable": true + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be debited to send tokens to the user" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will receive the input tokens from the user" + ], + "writable": true + }, + { + "name": "base_token_mint", + "docs": [ + "The mint of the base token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "quote_token_mint", + "docs": [ + "The mint of the quote token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "base_token_program", + "docs": [ + "Token program owning the base token: either SPL Token or Token-2022" + ] + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "amount_out", + "type": "u64" + }, + { + "name": "maximum_amount_in", + "type": "u64" + }, + { + "name": "share_fee_rate", + "type": "u64" + } + ] + }, + { + "name": "claim_creator_fee", + "docs": [ + "Claim the fee from the exclusive creator fee vault.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 26, + 97, + 138, + 203, + 132, + 171, + 141, + 252 + ], + "accounts": [ + { + "name": "creator", + "docs": [ + "The pool creator" + ], + "writable": true, + "signer": true + }, + { + "name": "fee_vault_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 99, + 114, + 101, + 97, + 116, + 111, + 114, + 95, + 102, + 101, + 101, + 95, + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "creator_fee_vault", + "docs": [ + "The creator fee vault" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "creator" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "recipient_token_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "creator" + }, + { + "kind": "account", + "path": "token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token" + ] + }, + { + "name": "token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "associated_token_program", + "docs": [ + "Required for associated token program" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + } + ], + "args": [] + }, + { + "name": "claim_platform_fee", + "docs": [ + "Claim platform fee", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 156, + 39, + 208, + 135, + 76, + 237, + 61, + 72 + ], + "accounts": [ + { + "name": "platform_fee_wallet", + "docs": [ + "Only the wallet stored in platform_config can collect platform fees" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault and mint operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config account" + ] + }, + { + "name": "quote_vault", + "writable": true + }, + { + "name": "recipient_token_account", + "docs": [ + "The address that receives the collected quote token fees" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "platform_fee_wallet" + }, + { + "kind": "account", + "path": "token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "quote_mint", + "docs": [ + "The mint of quote token vault" + ] + }, + { + "name": "token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "associated_token_program", + "docs": [ + "Required for associated token program" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + } + ], + "args": [] + }, + { + "name": "claim_platform_fee_from_vault", + "docs": [ + "Claim the fee from the exclusive platform fee vault.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 117, + 241, + 198, + 168, + 248, + 218, + 80, + 29 + ], + "accounts": [ + { + "name": "platform_fee_wallet", + "docs": [ + "Only the wallet stored in platform_config can collect platform fees" + ], + "writable": true, + "signer": true + }, + { + "name": "fee_vault_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 102, + 101, + 101, + 95, + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "platform_config", + "docs": [ + "The platform config account" + ] + }, + { + "name": "platform_fee_vault", + "docs": [ + "The platform fee vault" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "recipient_token_account", + "docs": [ + "The address that receives the collected quote token fees" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "platform_fee_wallet" + }, + { + "kind": "account", + "path": "token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "quote_mint", + "docs": [ + "The mint of quote token vault" + ] + }, + { + "name": "token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "associated_token_program", + "docs": [ + "Required for associated token program" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + } + ], + "args": [] + }, + { + "name": "claim_vested_token", + "docs": [ + "Claim vested token", + "# Arguments" + ], + "discriminator": [ + 49, + 33, + 104, + 30, + 189, + 157, + 79, + 35 + ], + "accounts": [ + { + "name": "beneficiary", + "docs": [ + "The beneficiary of the vesting account" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault and mint operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true + }, + { + "name": "vesting_record", + "docs": [ + "The vesting record account" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 101, + 115, + 116, + 105, + 110, + 103 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "beneficiary" + } + ] + } + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be debited to send tokens to the user" + ], + "writable": true + }, + { + "name": "user_base_token", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "beneficiary" + }, + { + "kind": "account", + "path": "base_token_program" + }, + { + "kind": "account", + "path": "base_token_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "base_token_mint", + "docs": [ + "The mint for the base token (token being sold)", + "Created in this instruction with specified decimals" + ] + }, + { + "name": "base_token_program", + "docs": [ + "SPL Token program or Token-2022 program for the base token", + "Bound to the base mint's owner via `base_token_mint.mint::token_program`" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "associated_token_program", + "docs": [ + "Required for associated token program" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + } + ], + "args": [] + }, + { + "name": "close_platform_allow_config", + "docs": [ + "Stop allowing the global config to be used to create pools with the platform config", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 82, + 205, + 36, + 216, + 16, + 6, + 240, + 215 + ], + "accounts": [ + { + "name": "platform_admin", + "docs": [ + "The owner of the platform config, it receives the rent of the closed account" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the global config was allowed for" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_admin" + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "The global config the platform no longer allows" + ] + }, + { + "name": "platform_allow_config", + "docs": [ + "The account that records the allowed global config" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 97, + 108, + 108, + 111, + 119, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + } + ], + "args": [] + }, + { + "name": "close_platform_curve_rule", + "docs": [ + "Close the launch param rule of the global config, the platform then no longer", + "restricts the launch params of the global config", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 194, + 114, + 82, + 45, + 136, + 88, + 169, + 159 + ], + "accounts": [ + { + "name": "curve_rule_authority", + "docs": [ + "The curve rule manager of the platform config or the platform admin itself,", + "it receives the rent of the closed account, see PlatformConfig::curve_rule_manager" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the rule belongs to, it can only be managed by the dedicated", + "curve rule manager wallet or by the platform admin" + ] + }, + { + "name": "global_config", + "docs": [ + "The global config the rule applies to" + ] + }, + { + "name": "platform_curve_rule", + "docs": [ + "The launch param rule to be closed" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 117, + 114, + 118, + 101, + 95, + 114, + 117, + 108, + 101 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + } + ], + "args": [] + }, + { + "name": "collect_excess_lamports", + "docs": [ + "Collect excess lamports, including the token accounts and mints owned by the", + "program authorities and the program pda accounts.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 28, + 189, + 19, + 40, + 164, + 176, + 118, + 17 + ], + "accounts": [ + { + "name": "collect_lamports_wallet", + "docs": [ + "Only admin or collect_lamports can collect lamports" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "authority, the platform fee vault authority or the creator fee vault authority" + ] + }, + { + "name": "token_program", + "docs": [ + "The SPL program to perform token transfers" + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "token_program_2022", + "docs": [ + "The SPL program 2022 to perform token transfers" + ], + "address": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + } + ], + "args": [] + }, + { + "name": "collect_fee", + "docs": [ + "Collects accumulated fees from the pool", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 60, + 173, + 247, + 103, + 4, + 93, + 130, + 48 + ], + "accounts": [ + { + "name": "owner", + "docs": [ + "Only protocol_fee_owner saved in global_config can collect protocol fee now" + ], + "signer": true + }, + { + "name": "authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Pool state stores accumulated protocol fee amount" + ], + "writable": true + }, + { + "name": "global_config", + "docs": [ + "Global config account stores owner" + ] + }, + { + "name": "quote_vault", + "docs": [ + "The address that holds pool tokens for quote token" + ], + "writable": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint of quote token vault" + ] + }, + { + "name": "recipient_token_account", + "docs": [ + "The address that receives the collected quote token fees" + ], + "writable": true + }, + { + "name": "token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + } + ], + "args": [] + }, + { + "name": "collect_migrate_fee", + "docs": [ + "Collects migrate fees from the pool", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 255, + 186, + 150, + 223, + 235, + 118, + 201, + 186 + ], + "accounts": [ + { + "name": "owner", + "docs": [ + "Only migrate_fee_owner saved in global_config can collect migrate fee now" + ], + "signer": true + }, + { + "name": "authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Pool state stores accumulated protocol fee amount" + ], + "writable": true + }, + { + "name": "global_config", + "docs": [ + "Global config account stores owner" + ] + }, + { + "name": "quote_vault", + "docs": [ + "The address that holds pool tokens for quote token" + ], + "writable": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint of quote token vault" + ] + }, + { + "name": "recipient_token_account", + "docs": [ + "The address that receives the collected quote token fees" + ], + "writable": true + }, + { + "name": "token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + } + ], + "args": [] + }, + { + "name": "create_config", + "docs": [ + "Creates a new configuration", + "# Arguments", + "", + "* `ctx` - The accounts needed by instruction", + "* `curve_type` - The type of bonding curve (0: ConstantProduct)", + "* `index` - The index of config, there may be multiple config with the same curve type.", + "* `trade_fee_rate` - Trade fee rate, must be less than RATE_DENOMINATOR_VALUE", + "" + ], + "discriminator": [ + 201, + 207, + 243, + 114, + 75, + 111, + 47, + 189 + ], + "accounts": [ + { + "name": "owner", + "docs": [ + "The protocol owner/admin account", + "Must match the predefined admin address or the create config authority", + "Has authority to create and modify protocol configurations" + ], + "writable": true, + "signer": true + }, + { + "name": "global_config", + "docs": [ + "Global configuration account that stores protocol-wide settings", + "PDA generated using GLOBAL_CONFIG_SEED, quote token mint, and curve type", + "Stores fee rates and protocol parameters" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "quote_token_mint" + }, + { + "kind": "arg", + "path": "curve_type" + }, + { + "kind": "arg", + "path": "index" + } + ] + } + }, + { + "name": "quote_token_mint", + "docs": [ + "The mint address of the quote token (token used for buying)", + "This will be the standard token used for all pools with this config", + "May be owned by either the SPL Token program or the Token-2022 program" + ] + }, + { + "name": "protocol_fee_owner", + "docs": [ + "Account that will receive protocol fees" + ] + }, + { + "name": "migrate_fee_owner", + "docs": [ + "Account that will receive migrate fees" + ] + }, + { + "name": "migrate_to_amm_wallet", + "docs": [ + "The control wallet address for migrating to amm" + ] + }, + { + "name": "migrate_to_cpswap_wallet", + "docs": [ + "The control wallet address for migrating to cpswap" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "curve_type", + "type": "u8" + }, + { + "name": "index", + "type": "u16" + }, + { + "name": "migrate_fee", + "type": "u64" + }, + { + "name": "trade_fee_rate", + "type": "u64" + } + ] + }, + { + "name": "create_platform_allow_config", + "docs": [ + "Allow the global config to be used to create pools with the platform config,", + "it takes effect only when platform_config.restrict_global_config is 1", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 69, + 71, + 168, + 7, + 214, + 250, + 107, + 102 + ], + "accounts": [ + { + "name": "platform_admin", + "docs": [ + "The owner of the platform config, it pays for the initialization costs" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the global config is allowed for" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_admin" + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "The global config the platform allows to create pools with" + ] + }, + { + "name": "platform_allow_config", + "docs": [ + "The account that records the allowed global config" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 97, + 108, + 108, + 111, + 119, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "create_platform_config", + "docs": [ + "Create platform config account", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "# Fields", + "* `fee_rate` - Fee rate of the platform", + "* `name` - Name of the platform", + "* `web` - Website of the platform", + "* `img` - Image link of the platform", + "" + ], + "discriminator": [ + 176, + 90, + 196, + 175, + 253, + 113, + 220, + 20 + ], + "accounts": [ + { + "name": "platform_admin", + "docs": [ + "The account paying for the initialization costs" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_fee_wallet" + }, + { + "name": "platform_nft_wallet" + }, + { + "name": "platform_config", + "docs": [ + "The platform config account" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_admin" + } + ] + } + }, + { + "name": "cpswap_config" + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "transfer_fee_extension_authority" + }, + { + "name": "platform_vesting_wallet" + } + ], + "args": [ + { + "name": "platform_params", + "type": { + "defined": { + "name": "PlatformParams" + } + } + } + ] + }, + { + "name": "create_platform_curve_rule", + "docs": [ + "Create an empty launch param rule for the global config, the check groups are", + "added with the `update_platform_curve_rule` instruction. It is signed by the", + "curve rule manager wallet or by the platform admin.", + "It takes effect only when platform_config.restrict_curve_param is 1", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 148, + 71, + 180, + 136, + 122, + 144, + 59, + 21 + ], + "accounts": [ + { + "name": "curve_rule_authority", + "docs": [ + "The curve rule manager of the platform config or the platform admin itself,", + "it pays for the initialization costs, see PlatformConfig::curve_rule_manager" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the rule belongs to, it can only be managed by the dedicated", + "curve rule manager wallet or by the platform admin" + ] + }, + { + "name": "global_config", + "docs": [ + "The global config the rule applies to" + ] + }, + { + "name": "platform_curve_rule", + "docs": [ + "The account that records the launch param check groups of the global config" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 117, + 114, + 118, + 101, + 95, + 114, + 117, + 108, + 101 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "create_platform_vesting_account", + "docs": [ + "Create vesting account", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `share` - The share amount of base token to be vested", + "" + ], + "discriminator": [ + 146, + 71, + 173, + 69, + 98, + 19, + 15, + 106 + ], + "accounts": [ + { + "name": "platform_vesting_wallet", + "docs": [ + "The account paying for the initialization costs", + "This can be any account with sufficient SOL to cover the transaction" + ], + "writable": true, + "signer": true + }, + { + "name": "beneficiary", + "docs": [ + "The beneficiary is used to receive the allocated linear release of tokens.", + "Once this account is set, it cannot be modified, so please ensure the validity of this account,", + "otherwise, the unlocked tokens will not be claimable." + ], + "writable": true + }, + { + "name": "platform_config", + "docs": [ + "Platform config account to be changed" + ] + }, + { + "name": "pool_state", + "docs": [ + "The pool state account" + ], + "writable": true + }, + { + "name": "platform_vesting_record", + "docs": [ + "The vesting record account" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 101, + 115, + 116, + 105, + 110, + 103 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "beneficiary" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "create_vesting_account", + "docs": [ + "Create vesting account", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `share` - The share amount of base token to be vested", + "" + ], + "discriminator": [ + 129, + 178, + 2, + 13, + 217, + 172, + 230, + 218 + ], + "accounts": [ + { + "name": "creator", + "docs": [ + "The account paying for the initialization costs", + "This can be any account with sufficient SOL to cover the transaction" + ], + "writable": true, + "signer": true + }, + { + "name": "beneficiary", + "docs": [ + "The beneficiary is used to receive the allocated linear release of tokens.", + "Once this account is set, it cannot be modified, so please ensure the validity of this account,", + "otherwise, the unlocked tokens will not be claimable." + ], + "writable": true + }, + { + "name": "pool_state", + "docs": [ + "The pool state account" + ], + "writable": true + }, + { + "name": "vesting_record", + "docs": [ + "The vesting record account" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 101, + 115, + 116, + 105, + 110, + 103 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "beneficiary" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "share_amount", + "type": "u64" + } + ] + }, + { + "name": "initialize", + "docs": [ + "Deprecated, it always fails with `NotApproved`, use `initialize_v2`", + "# Arguments", + "", + "* `ctx` - The context of accounts containing pool and token information", + "" + ], + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The account paying for the initialization costs", + "This can be any account with sufficient SOL to cover the transaction" + ], + "writable": true, + "signer": true + }, + { + "name": "creator" + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Includes settings like quote token mint and fee parameters" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform info", + "Includes settings like the fee_rate, name, web, img of the platform" + ] + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault and mint operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108 + ] + }, + { + "kind": "account", + "path": "base_mint" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "base_mint", + "docs": [ + "The mint for the base token (token being sold)", + "Created in this instruction with specified decimals" + ], + "writable": true, + "signer": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token (token used to buy)", + "Must match the quote_mint specified in global config" + ] + }, + { + "name": "base_vault", + "docs": [ + "Token account that holds the pool's base tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "base_mint" + } + ] + } + }, + { + "name": "quote_vault", + "docs": [ + "Token account that holds the pool's quote tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "metadata_account", + "docs": [ + "Account to store the base token's metadata", + "Created using Metaplex metadata program" + ], + "writable": true + }, + { + "name": "base_token_program", + "docs": [ + "SPL Token program for the base token", + "Must be the standard Token program" + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "quote_token_program", + "docs": [ + "SPL Token program for the quote token" + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "metadata_program", + "docs": [ + "Metaplex Token Metadata program", + "Used to create metadata for the base token" + ], + "address": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "rent_program", + "docs": [ + "Required for rent exempt calculations" + ], + "address": "SysvarRent111111111111111111111111111111111" + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "_base_mint_param", + "type": { + "defined": { + "name": "MintParams" + } + } + }, + { + "name": "_curve_param", + "type": { + "defined": { + "name": "CurveParams" + } + } + }, + { + "name": "_vesting_param", + "type": { + "defined": { + "name": "VestingParams" + } + } + } + ] + }, + { + "name": "initialize_v2", + "docs": [ + "Initializes a new trading pool,", + "pool created by this instruction must be migrated to cpswap after fundraising ends, i.e., curve_param.migrate_type = 1", + "# Arguments", + "", + "* `ctx` - The context of accounts containing pool and token information", + "" + ], + "discriminator": [ + 67, + 153, + 175, + 39, + 218, + 16, + 38, + 32 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The account paying for the initialization costs", + "This can be any account with sufficient SOL to cover the transaction" + ], + "writable": true, + "signer": true + }, + { + "name": "creator" + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Includes settings like quote token mint and fee parameters" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform info", + "Includes settings like the fee_rate, name, web, img of the platform" + ] + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault and mint operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108 + ] + }, + { + "kind": "account", + "path": "base_mint" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "base_mint", + "docs": [ + "The mint for the base token (token being sold)", + "Created in this instruction with specified decimals" + ], + "writable": true, + "signer": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token (token used to buy)", + "Must match the quote_mint specified in global config" + ] + }, + { + "name": "base_vault", + "docs": [ + "Token account that holds the pool's base tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "base_mint" + } + ] + } + }, + { + "name": "quote_vault", + "docs": [ + "Token account that holds the pool's quote tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "metadata_account", + "docs": [ + "Account to store the base token's metadata", + "Created using Metaplex metadata program" + ], + "writable": true + }, + { + "name": "base_token_program", + "docs": [ + "SPL Token program for the base token", + "Must be the standard Token program" + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "metadata_program", + "docs": [ + "Metaplex Token Metadata program", + "Used to create metadata for the base token" + ], + "address": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "rent_program", + "docs": [ + "Required for rent exempt calculations" + ], + "address": "SysvarRent111111111111111111111111111111111" + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "base_mint_param", + "type": { + "defined": { + "name": "MintParams" + } + } + }, + { + "name": "curve_param", + "type": { + "defined": { + "name": "CurveParams" + } + } + }, + { + "name": "vesting_param", + "type": { + "defined": { + "name": "VestingParams" + } + } + }, + { + "name": "amm_fee_on", + "type": { + "defined": { + "name": "AmmCreatorFeeOn" + } + } + } + ] + }, + { + "name": "initialize_with_token_2022", + "docs": [ + "Initializes a new trading pool with base token belongs to spl-token-2022,", + "pool created by this instruction must be migrated to cpswap after fundraising ends, i.e., curve_param.migrate_type = 1", + "# Arguments", + "", + "* `ctx` - The context of accounts containing pool and token information", + "" + ], + "discriminator": [ + 37, + 190, + 126, + 222, + 44, + 154, + 171, + 17 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The account paying for the initialization costs", + "This can be any account with sufficient SOL to cover the transaction" + ], + "writable": true, + "signer": true + }, + { + "name": "creator" + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Includes settings like quote token mint and fee parameters" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform info", + "Includes settings like the fee_rate, name, web, img of the platform" + ] + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault and mint operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108 + ] + }, + { + "kind": "account", + "path": "base_mint" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "base_mint", + "docs": [ + "The mint for the base token (token being sold)", + "Created in this instruction with specified decimals" + ], + "writable": true, + "signer": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token (token used to buy)", + "Must match the quote_mint specified in global config" + ] + }, + { + "name": "base_vault", + "docs": [ + "Token account that holds the pool's base tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "base_mint" + } + ] + } + }, + { + "name": "quote_vault", + "docs": [ + "Token account that holds the pool's quote tokens", + "PDA generated using POOL_VAULT_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool_state" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "base_token_program", + "docs": [ + "SPL Token program for the base token" + ], + "address": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "base_mint_param", + "type": { + "defined": { + "name": "MintParams" + } + } + }, + { + "name": "curve_param", + "type": { + "defined": { + "name": "CurveParams" + } + } + }, + { + "name": "vesting_param", + "type": { + "defined": { + "name": "VestingParams" + } + } + }, + { + "name": "amm_fee_on", + "type": { + "defined": { + "name": "AmmCreatorFeeOn" + } + } + }, + { + "name": "transfer_fee_extension_param", + "type": { + "option": { + "defined": { + "name": "TransferFeeExtensionParams" + } + } + } + } + ] + }, + { + "name": "migrate_to_amm", + "docs": [ + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 207, + 82, + 192, + 145, + 254, + 207, + 145, + 223 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "Only migrate_to_amm_wallet can migrate to cpswap pool", + "This signer must match the migrate_to_amm_wallet saved in global_config" + ], + "writable": true, + "signer": true + }, + { + "name": "base_mint", + "docs": [ + "The mint for the base token (token being sold)" + ] + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token (token used to buy)" + ] + }, + { + "name": "market", + "docs": [ + "Account created and asigned to openbook_program but not been initialized" + ], + "writable": true + }, + { + "name": "amm_program", + "address": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" + }, + { + "name": "amm_pool", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "amm_program" + }, + { + "kind": "account", + "path": "market" + }, + { + "kind": "const", + "value": [ + 97, + 109, + 109, + 95, + 97, + 115, + 115, + 111, + 99, + 105, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 97, + 109, + 109, + 32, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_lp_mint", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "amm_program" + }, + { + "kind": "account", + "path": "market" + }, + { + "kind": "const", + "value": [ + 108, + 112, + 95, + 109, + 105, + 110, + 116, + 95, + 97, + 115, + 115, + 111, + 99, + 105, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_base_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "amm_program" + }, + { + "kind": "account", + "path": "market" + }, + { + "kind": "const", + "value": [ + 99, + 111, + 105, + 110, + 95, + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 115, + 115, + 111, + 99, + 105, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_quote_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "amm_program" + }, + { + "kind": "account", + "path": "market" + }, + { + "kind": "const", + "value": [ + 112, + 99, + 95, + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 115, + 115, + 111, + 99, + 105, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_target_orders", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "amm_program" + }, + { + "kind": "account", + "path": "market" + }, + { + "kind": "const", + "value": [ + 116, + 97, + 114, + 103, + 101, + 116, + 95, + 97, + 115, + 115, + 111, + 99, + 105, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 97, + 109, + 109, + 95, + 99, + 111, + 110, + 102, + 105, + 103, + 95, + 97, + 99, + 99, + 111, + 117, + 110, + 116, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "amm_program" + } + } + }, + { + "name": "amm_create_fee_destination", + "writable": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108 + ] + }, + { + "kind": "account", + "path": "base_mint" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global config account stores owner" + ] + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be fully drained during migration" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will be fully drained during migration" + ], + "writable": true + }, + { + "name": "pool_lp_token", + "writable": true + }, + { + "name": "spl_token_program", + "docs": [ + "SPL Token program for the base token", + "Must be the standard Token program" + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "associated_token_program", + "docs": [ + "Program to create an ATA for receiving fee NFT" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "rent_program", + "docs": [ + "Required for rent exempt calculations" + ], + "address": "SysvarRent111111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "migrate_to_cpswap", + "docs": [ + "# Arguments", + "", + "* `ctx` - The context of accounts", + "" + ], + "discriminator": [ + 136, + 92, + 200, + 103, + 28, + 218, + 144, + 140 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "Only migrate_to_cpswap_wallet can migrate to cpswap pool", + "This signer must match the migrate_to_cpswap_wallet saved in global_config" + ], + "writable": true, + "signer": true + }, + { + "name": "base_mint", + "docs": [ + "The mint for the base token (token being sold)", + "May be owned by either token program, the instruction picks the matching one" + ], + "writable": true + }, + { + "name": "quote_mint", + "docs": [ + "The mint for the quote token (token used to buy)", + "May be owned by either token program, the instruction picks the matching one" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform-wide settings", + "Used to read platform fee rate" + ] + }, + { + "name": "cpswap_program", + "address": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C" + }, + { + "name": "cpswap_pool", + "docs": [ + "PDA account:", + "seeds = [", + "b\"pool\",", + "cpswap_config.key().as_ref(),", + "token_0_mint.key().as_ref(),", + "token_1_mint.key().as_ref(),", + "],", + "seeds::program = cpswap_program,", + "", + "Or random account: must be signed by cli" + ], + "writable": true + }, + { + "name": "cpswap_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 110, + 100, + 95, + 108, + 112, + 95, + 109, + 105, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "cpswap_program" + } + } + }, + { + "name": "cpswap_lp_mint", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 108, + 112, + 95, + 109, + 105, + 110, + 116 + ] + }, + { + "kind": "account", + "path": "cpswap_pool" + } + ], + "program": { + "kind": "account", + "path": "cpswap_program" + } + } + }, + { + "name": "cpswap_base_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "cpswap_pool" + }, + { + "kind": "account", + "path": "base_mint" + } + ], + "program": { + "kind": "account", + "path": "cpswap_program" + } + } + }, + { + "name": "cpswap_quote_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "cpswap_pool" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "account", + "path": "cpswap_program" + } + } + }, + { + "name": "cpswap_config" + }, + { + "name": "cpswap_create_pool_fee", + "writable": true + }, + { + "name": "cpswap_observation", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 111, + 98, + 115, + 101, + 114, + 118, + 97, + 116, + 105, + 111, + 110 + ] + }, + { + "kind": "account", + "path": "cpswap_pool" + } + ], + "program": { + "kind": "account", + "path": "cpswap_program" + } + } + }, + { + "name": "lock_program", + "address": "LockrWmn6K5twhz3y9w1dQERbmgSaRkfnTeTKbpofwE" + }, + { + "name": "lock_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 108, + 111, + 99, + 107, + 95, + 99, + 112, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121, + 95, + 115, + 101, + 101, + 100 + ] + } + ], + "program": { + "kind": "account", + "path": "lock_program" + } + } + }, + { + "name": "lock_lp_vault", + "writable": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "pool_state", + "docs": [ + "Account that stores the pool's state and parameters", + "PDA generated using POOL_SEED and both token mints" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 111, + 111, + 108 + ] + }, + { + "kind": "account", + "path": "base_mint" + }, + { + "kind": "account", + "path": "quote_mint" + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global config account stores owner" + ] + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be fully drained during migration" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will be fully drained during migration" + ], + "writable": true + }, + { + "name": "pool_lp_token", + "writable": true + }, + { + "name": "token_program", + "docs": [ + "The legacy SPL Token program.", + "Always required: the cpswap lp mint and the locked liquidity fee NFT live on it, and it", + "serves the base or quote token whenever that mint is a legacy mint." + ], + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "token_program_2022", + "docs": [ + "The Token-2022 program, serving the base or quote token when that mint is a", + "Token-2022 mint. Always required, even when neither mint uses it." + ], + "address": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + }, + { + "name": "associated_token_program", + "docs": [ + "Program to create an ATA for receiving fee NFT" + ], + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + }, + { + "name": "system_program", + "docs": [ + "Required for account creation" + ], + "address": "11111111111111111111111111111111" + }, + { + "name": "rent_program", + "docs": [ + "Required for rent exempt calculations" + ], + "address": "SysvarRent111111111111111111111111111111111" + }, + { + "name": "metadata_program", + "docs": [ + "Program to create NFT metadata accunt" + ], + "address": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" + } + ], + "args": [] + }, + { + "name": "remove_platform_curve_rule", + "docs": [ + "Remove one check group of the launch param rule", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `group_id` - The id of the group to remove", + "" + ], + "discriminator": [ + 236, + 77, + 154, + 138, + 32, + 145, + 127, + 219 + ], + "accounts": [ + { + "name": "curve_rule_authority", + "docs": [ + "The curve rule manager of the platform config or the platform admin itself,", + "it receives the rent the account shrinks by,", + "see PlatformConfig::curve_rule_manager" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the rule belongs to, it can only be managed by the dedicated", + "curve rule manager wallet or by the platform admin" + ] + }, + { + "name": "global_config", + "docs": [ + "The global config the rule applies to" + ] + }, + { + "name": "platform_curve_rule", + "docs": [ + "The launch param rule to be changed" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 117, + 114, + 118, + 101, + 95, + 114, + 117, + 108, + 101 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required to settle the rent of the resized account" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "group_id", + "type": "u16" + } + ] + }, + { + "name": "sell_exact_in", + "docs": [ + "Use the given amount of base tokens to sell for quote tokens.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `amount_in` - Amount of base token to sell", + "* `minimum_amount_out` - Minimum amount of quote token to receive (slippage protection)", + "* `share_fee_rate` - Fee rate for the share", + "" + ], + "discriminator": [ + 149, + 39, + 222, + 155, + 211, + 124, + 152, + 26 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The user performing the swap operation", + "Must sign the transaction and pay for fees" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Used to read protocol fee rates and curve type" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform-wide settings", + "Used to read platform fee rate" + ] + }, + { + "name": "pool_state", + "docs": [ + "The pool state account where the swap will be performed", + "Contains current pool parameters and balances" + ], + "writable": true + }, + { + "name": "user_base_token", + "docs": [ + "The user's token account for base tokens (tokens being bought)", + "Will receive the output tokens after the swap" + ], + "writable": true + }, + { + "name": "user_quote_token", + "docs": [ + "The user's token account for quote tokens (tokens being sold)", + "Will be debited for the input amount" + ], + "writable": true + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be debited to send tokens to the user" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will receive the input tokens from the user" + ], + "writable": true + }, + { + "name": "base_token_mint", + "docs": [ + "The mint of the base token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "quote_token_mint", + "docs": [ + "The mint of the quote token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "base_token_program", + "docs": [ + "Token program owning the base token: either SPL Token or Token-2022" + ] + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "amount_in", + "type": "u64" + }, + { + "name": "minimum_amount_out", + "type": "u64" + }, + { + "name": "share_fee_rate", + "type": "u64" + } + ] + }, + { + "name": "sell_exact_out", + "docs": [ + "Sell base tokens for the given amount of quote tokens.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `amount_out` - Amount of quote token to receive", + "* `maximum_amount_in` - Maximum amount of base token to purchase (slippage protection)", + "* `share_fee_rate` - Fee rate for the share", + "" + ], + "discriminator": [ + 95, + 200, + 71, + 34, + 8, + 9, + 11, + 166 + ], + "accounts": [ + { + "name": "payer", + "docs": [ + "The user performing the swap operation", + "Must sign the transaction and pay for fees" + ], + "writable": true, + "signer": true + }, + { + "name": "authority", + "docs": [ + "PDA that acts as the authority for pool vault operations", + "Generated using AUTH_SEED" + ], + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 97, + 117, + 108, + 116, + 95, + 97, + 117, + 116, + 104, + 95, + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "global_config", + "docs": [ + "Global configuration account containing protocol-wide settings", + "Used to read protocol fee rates and curve type" + ] + }, + { + "name": "platform_config", + "docs": [ + "Platform configuration account containing platform-wide settings", + "Used to read platform fee rate" + ] + }, + { + "name": "pool_state", + "docs": [ + "The pool state account where the swap will be performed", + "Contains current pool parameters and balances" + ], + "writable": true + }, + { + "name": "user_base_token", + "docs": [ + "The user's token account for base tokens (tokens being bought)", + "Will receive the output tokens after the swap" + ], + "writable": true + }, + { + "name": "user_quote_token", + "docs": [ + "The user's token account for quote tokens (tokens being sold)", + "Will be debited for the input amount" + ], + "writable": true + }, + { + "name": "base_vault", + "docs": [ + "The pool's vault for base tokens", + "Will be debited to send tokens to the user" + ], + "writable": true + }, + { + "name": "quote_vault", + "docs": [ + "The pool's vault for quote tokens", + "Will receive the input tokens from the user" + ], + "writable": true + }, + { + "name": "base_token_mint", + "docs": [ + "The mint of the base token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "quote_token_mint", + "docs": [ + "The mint of the quote token", + "Used for transfer fee calculations if applicable" + ] + }, + { + "name": "base_token_program", + "docs": [ + "Token program owning the base token: either SPL Token or Token-2022" + ] + }, + { + "name": "quote_token_program", + "docs": [ + "Token program owning the quote token: either SPL Token or Token-2022" + ] + }, + { + "name": "event_authority" + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "amount_out", + "type": "u64" + }, + { + "name": "maximum_amount_in", + "type": "u64" + }, + { + "name": "share_fee_rate", + "type": "u64" + } + ] + }, + { + "name": "update_config", + "docs": [ + "Updates configuration parameters", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `param` - Parameter to update:", + "- 0: Update trade_fee_rate", + "- 1: Update fee owner", + "* `value` - New value for the selected parameter", + "" + ], + "discriminator": [ + 29, + 158, + 252, + 191, + 10, + 83, + 219, + 99 + ], + "accounts": [ + { + "name": "owner", + "docs": [ + "The global config owner or admin" + ], + "signer": true + }, + { + "name": "global_config", + "docs": [ + "Global config account to be changed" + ], + "writable": true + } + ], + "args": [ + { + "name": "param", + "type": "u8" + }, + { + "name": "value", + "type": "u64" + } + ] + }, + { + "name": "update_platform_config", + "docs": [ + "Update platform config", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `param` - Parameter to update", + "" + ], + "discriminator": [ + 195, + 60, + 76, + 129, + 146, + 45, + 67, + 143 + ], + "accounts": [ + { + "name": "platform_admin", + "docs": [ + "The account paying for the initialization costs" + ], + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "Platform config account to be changed" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + }, + { + "kind": "account", + "path": "platform_admin" + } + ] + } + } + ], + "args": [ + { + "name": "param", + "type": { + "defined": { + "name": "PlatformConfigParam" + } + } + } + ] + }, + { + "name": "update_platform_curve_rule", + "docs": [ + "Add or replace one check group of the launch param rule.", + "The constraints of a group must all hold together, a launch is allowed when it", + "satisfies any group, a group with no constraint allows everything.", + "# Arguments", + "", + "* `ctx` - The context of accounts", + "* `group_id` - The stable id of the group, an existing group with the id is replaced", + "* `constraints` - The constraints of the group, see `param_field` and `param_op`", + "" + ], + "discriminator": [ + 90, + 100, + 206, + 196, + 54, + 117, + 120, + 139 + ], + "accounts": [ + { + "name": "curve_rule_authority", + "docs": [ + "The curve rule manager of the platform config or the platform admin itself,", + "it pays the rent the account grows by, and receives the rent it shrinks by,", + "see PlatformConfig::curve_rule_manager" + ], + "writable": true, + "signer": true + }, + { + "name": "platform_config", + "docs": [ + "The platform config the rule belongs to, it can only be managed by the dedicated", + "curve rule manager wallet or by the platform admin" + ] + }, + { + "name": "global_config", + "docs": [ + "The global config the rule applies to" + ] + }, + { + "name": "platform_curve_rule", + "docs": [ + "The launch param rule to be changed" + ], + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 116, + 102, + 111, + 114, + 109, + 95, + 99, + 117, + 114, + 118, + 101, + 95, + 114, + 117, + 108, + 101 + ] + }, + { + "kind": "account", + "path": "platform_config" + }, + { + "kind": "account", + "path": "global_config" + } + ] + } + }, + { + "name": "system_program", + "docs": [ + "Required to settle the rent of the resized account" + ], + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "group_id", + "type": "u16" + }, + { + "name": "constraints", + "type": { + "vec": { + "defined": { + "name": "ParamConstraint" + } + } + } + } + ] + } + ], + "accounts": [ + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "PlatformAllowConfig", + "discriminator": [ + 248, + 57, + 34, + 138, + 222, + 238, + 186, + 75 + ] + }, + { + "name": "PlatformConfig", + "discriminator": [ + 160, + 78, + 128, + 0, + 248, + 83, + 230, + 160 + ] + }, + { + "name": "PlatformCurveRule", + "discriminator": [ + 12, + 20, + 122, + 169, + 247, + 155, + 104, + 234 + ] + }, + { + "name": "PoolState", + "discriminator": [ + 247, + 237, + 227, + 245, + 215, + 195, + 222, + 70 + ] + }, + { + "name": "VestingRecord", + "discriminator": [ + 106, + 243, + 221, + 205, + 230, + 126, + 85, + 83 + ] + } + ], + "events": [ + { + "name": "ClaimVestedEvent", + "discriminator": [ + 21, + 194, + 114, + 87, + 120, + 211, + 226, + 32 + ] + }, + { + "name": "CreateVestingEvent", + "discriminator": [ + 150, + 152, + 11, + 179, + 52, + 210, + 191, + 125 + ] + }, + { + "name": "PoolCreateEvent", + "discriminator": [ + 151, + 215, + 226, + 9, + 118, + 161, + 115, + 174 + ] + }, + { + "name": "TradeEvent", + "discriminator": [ + 189, + 219, + 127, + 211, + 78, + 230, + 97, + 238 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "NotApproved", + "msg": "Not approved" + }, + { + "code": 6001, + "name": "InvalidOwner", + "msg": "Input account owner is not the program address" + }, + { + "code": 6002, + "name": "InvalidInput", + "msg": "InvalidInput" + }, + { + "code": 6003, + "name": "InputNotMatchCurveConfig", + "msg": "The input params are not match with curve type in config" + }, + { + "code": 6004, + "name": "ExceededSlippage", + "msg": "Exceeds desired slippage limit" + }, + { + "code": 6005, + "name": "PoolFunding", + "msg": "Pool funding" + }, + { + "code": 6006, + "name": "PoolMigrated", + "msg": "Pool migrated" + }, + { + "code": 6007, + "name": "MigrateTypeNotMatch", + "msg": "Migrate type not match" + }, + { + "code": 6008, + "name": "MathOverflow", + "msg": "Math overflow" + }, + { + "code": 6009, + "name": "NoAssetsToCollect", + "msg": "No assets to collect" + }, + { + "code": 6010, + "name": "VestingRatioTooHigh", + "msg": "Vesting ratio too high" + }, + { + "code": 6011, + "name": "VestingSettingEnded", + "msg": "Vesting setting ended" + }, + { + "code": 6012, + "name": "VestingNotStarted", + "msg": "Vesting not started" + }, + { + "code": 6013, + "name": "NoVestingSchedule", + "msg": "No vesting schedule" + }, + { + "code": 6014, + "name": "InvalidPlatformInfo", + "msg": "The platform info input is invalid" + }, + { + "code": 6015, + "name": "PoolNotMigrated", + "msg": "Pool not migrated" + }, + { + "code": 6016, + "name": "InvalidCpSwapConfig", + "msg": "The input cp swap config account is invalid" + }, + { + "code": 6017, + "name": "NoSupportExtension", + "msg": "No support extension" + }, + { + "code": 6018, + "name": "NotEnoughRemainingAccounts", + "msg": "Not enough remaining accounts" + }, + { + "code": 6019, + "name": "TransferFeeCalculateNotMatch", + "msg": "TransferFee calculate not match" + }, + { + "code": 6020, + "name": "CurveParamIsNotExist", + "msg": "Curve param is not exist" + }, + { + "code": 6021, + "name": "InvalidTotalLockedAmount", + "msg": "Total locked amount must great or equal to the platform vesting share amount" + }, + { + "code": 6022, + "name": "InvalidPlatformAllowConfig", + "msg": "Invalid platform allow config account" + }, + { + "code": 6023, + "name": "CalculateOverflow", + "msg": "Calculation overflowed" + }, + { + "code": 6024, + "name": "InvalidPlatformCurveRule", + "msg": "Invalid platform curve rule account" + }, + { + "code": 6025, + "name": "CurveParamNotMatchPlatformRule", + "msg": "The curve param does not match any check group of the platform curve rule" + }, + { + "code": 6026, + "name": "CurveRuleGroupNotExist", + "msg": "The curve rule group is not exist" + }, + { + "code": 6027, + "name": "CurveRuleGroupsExceeded", + "msg": "The number of curve rule groups exceeds the limit" + }, + { + "code": 6028, + "name": "InvalidCurveRuleConstraint", + "msg": "The curve rule constraint is invalid" + }, + { + "code": 6029, + "name": "InvalidCurveRuleAuthority", + "msg": "The signer is neither the curve rule manager nor the platform admin" + }, + { + "code": 6030, + "name": "CurveRuleFieldNotSupportedByCurve", + "msg": "The curve rule field is not supported by the curve of the global config" + }, + { + "code": 6031, + "name": "LamportsCalculateError", + "msg": "Lamports calculate error" + } + ], + "types": [ + { + "name": "AmmCreatorFeeOn", + "docs": [ + "migrate to cpmm, creator fee on quote token or both token" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "QuoteToken" + }, + { + "name": "BothToken" + } + ] + } + }, + { + "name": "ClaimVestedEvent", + "docs": [ + "Emitted when vesting token claimed by beneficiary" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool_state", + "type": "pubkey" + }, + { + "name": "beneficiary", + "type": "pubkey" + }, + { + "name": "claim_amount", + "type": "u64" + } + ] + } + }, + { + "name": "ConstantCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "supply", + "type": "u64" + }, + { + "name": "total_base_sell", + "type": "u64" + }, + { + "name": "total_quote_fund_raising", + "type": "u64" + }, + { + "name": "migrate_type", + "type": "u8" + } + ] + } + }, + { + "name": "CreateVestingEvent", + "docs": [ + "Emitted when vest_account created" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool_state", + "type": "pubkey" + }, + { + "name": "beneficiary", + "type": "pubkey" + }, + { + "name": "share_amount", + "type": "u64" + } + ] + } + }, + { + "name": "CurveParams", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Constant", + "fields": [ + { + "name": "data", + "type": { + "defined": { + "name": "ConstantCurve" + } + } + } + ] + }, + { + "name": "Fixed", + "fields": [ + { + "name": "data", + "type": { + "defined": { + "name": "FixedCurve" + } + } + } + ] + }, + { + "name": "Linear", + "fields": [ + { + "name": "data", + "type": { + "defined": { + "name": "LinearCurve" + } + } + } + ] + } + ] + } + }, + { + "name": "CurveRuleGroup", + "docs": [ + "A set of constraints that must all hold together, e.g.", + "`supply = 50W && total_base_sell >= 20W`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "group_id", + "docs": [ + "The stable id of the group, used to update or remove it" + ], + "type": "u16" + }, + { + "name": "epoch", + "docs": [ + "The epoch the group was written" + ], + "type": "u64" + }, + { + "name": "constraints", + "docs": [ + "The constraints of the group, an empty list allows everything" + ], + "type": { + "vec": { + "defined": { + "name": "ParamConstraint" + } + } + } + } + ] + } + }, + { + "name": "FixedCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "supply", + "type": "u64" + }, + { + "name": "total_quote_fund_raising", + "type": "u64" + }, + { + "name": "migrate_type", + "type": "u8" + } + ] + } + }, + { + "name": "GlobalConfig", + "docs": [ + "Holds the current owner of the factory" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "epoch", + "docs": [ + "Account update epoch" + ], + "type": "u64" + }, + { + "name": "curve_type", + "docs": [ + "0: Constant Product Curve", + "1: Fixed Price Curve", + "2: Linear Price Curve" + ], + "type": "u8" + }, + { + "name": "index", + "docs": [ + "Config index" + ], + "type": "u16" + }, + { + "name": "migrate_fee", + "docs": [ + "The fee of migrate to amm" + ], + "type": "u64" + }, + { + "name": "trade_fee_rate", + "docs": [ + "The trade fee rate, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "max_share_fee_rate", + "docs": [ + "The maximum share fee rate, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "min_base_supply", + "docs": [ + "The minimum base supply, the value without decimals" + ], + "type": "u64" + }, + { + "name": "max_lock_rate", + "docs": [ + "The maximum lock rate, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "min_base_sell_rate", + "docs": [ + "The minimum base sell rate, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "min_base_migrate_rate", + "docs": [ + "The minimum base migrate rate, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "min_quote_fund_raising", + "docs": [ + "The minimum quote fund raising, the value with decimals" + ], + "type": "u64" + }, + { + "name": "quote_mint", + "docs": [ + "Mint information for quote token" + ], + "type": "pubkey" + }, + { + "name": "protocol_fee_owner", + "docs": [ + "Protocol Fee owner" + ], + "type": "pubkey" + }, + { + "name": "migrate_fee_owner", + "docs": [ + "Migrate Fee owner" + ], + "type": "pubkey" + }, + { + "name": "migrate_to_amm_wallet", + "docs": [ + "Migrate to amm control wallet" + ], + "type": "pubkey" + }, + { + "name": "migrate_to_cpswap_wallet", + "docs": [ + "Migrate to cpswap wallet" + ], + "type": "pubkey" + }, + { + "name": "padding_alignment", + "docs": [ + "padding alignment" + ], + "type": { + "array": [ + "u8", + 8 + ] + } + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "LinearCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "supply", + "type": "u64" + }, + { + "name": "total_quote_fund_raising", + "type": "u64" + }, + { + "name": "migrate_type", + "type": "u8" + } + ] + } + }, + { + "name": "MigrateNftInfo", + "docs": [ + "Represents the parameters for initializing a platform config account(Only support MigrateType::CPSWAP)", + "# Fields", + "* `platform_scale` - Scale of the platform liquidity quantity rights will be converted into NFT", + "* `creator_scale` - Scale of the token creator liquidity quantity rights will be converted into NFT", + "* `burn_scale` - Scale of liquidity directly to burn", + "", + "* platform_scale + creator_scale + burn_scale = RATE_DENOMINATOR_VALUE" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "platform_scale", + "type": "u64" + }, + { + "name": "creator_scale", + "type": "u64" + }, + { + "name": "burn_scale", + "type": "u64" + } + ] + } + }, + { + "name": "MintParams", + "docs": [ + "Represents the parameters for initializing a new token mint", + "# Fields", + "* `decimals` - Number of decimal places for the token", + "* `name` - Name of the token", + "* `symbol` - Symbol/ticker of the token", + "* `uri` - URI pointing to token metadata" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "decimals", + "type": "u8" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "symbol", + "type": "string" + }, + { + "name": "uri", + "type": "string" + } + ] + } + }, + { + "name": "ParamConstraint", + "docs": [ + "A single check on one launch parameter, e.g. `(SUPPLY, GTE, 1_000_000)`.", + "A range check is expressed with two constraints(`GTE` + `LTE`) in the same group." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "field", + "docs": [ + "The parameter this constraint applies to, see `param_field`" + ], + "type": "u8" + }, + { + "name": "op", + "docs": [ + "The comparison operator, see `param_op`" + ], + "type": "u8" + }, + { + "name": "value", + "docs": [ + "The value to compare the actual launch parameter with" + ], + "type": "u128" + } + ] + } + }, + { + "name": "PlatformAllowConfig", + "docs": [ + "The existence of this account means the platform allows the global config to be used", + "with its platform config, it is only checked when the platform restricts the global", + "config, see PlatformConfig::restrict_global_config" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "platform_config", + "type": "pubkey" + }, + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "PlatformConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "epoch", + "docs": [ + "The epoch for update interval" + ], + "type": "u64" + }, + { + "name": "platform_fee_wallet", + "docs": [ + "The platform fee wallet" + ], + "type": "pubkey" + }, + { + "name": "platform_nft_wallet", + "docs": [ + "The platform nft wallet to receive the platform NFT after migration if platform_scale is not 0(Only support MigrateType::CPSWAP)" + ], + "type": "pubkey" + }, + { + "name": "platform_scale", + "docs": [ + "Scale of the platform liquidity quantity rights will be converted into NFT(Only support MigrateType::CPSWAP)" + ], + "type": "u64" + }, + { + "name": "creator_scale", + "docs": [ + "Scale of the token creator liquidity quantity rights will be converted into NFT(Only support MigrateType::CPSWAP)" + ], + "type": "u64" + }, + { + "name": "burn_scale", + "docs": [ + "Scale of liquidity directly to burn" + ], + "type": "u64" + }, + { + "name": "fee_rate", + "docs": [ + "The platform fee rate" + ], + "type": "u64" + }, + { + "name": "name", + "docs": [ + "The platform name" + ], + "type": { + "array": [ + "u8", + 64 + ] + } + }, + { + "name": "web", + "docs": [ + "The platform website" + ], + "type": { + "array": [ + "u8", + 256 + ] + } + }, + { + "name": "img", + "docs": [ + "The platform img link" + ], + "type": { + "array": [ + "u8", + 256 + ] + } + }, + { + "name": "cpswap_config", + "docs": [ + "The platform specifies the trade fee rate after migration to cp swap" + ], + "type": "pubkey" + }, + { + "name": "creator_fee_rate", + "docs": [ + "Creator fee rate" + ], + "type": "u64" + }, + { + "name": "transfer_fee_extension_auth", + "docs": [ + "If the base token belongs to token2022, then you can choose to support the transferfeeConfig extension, which includes permissions such as `transfer_fee_config_authority`` and `withdraw_withheld_authority`.", + "When initializing mint, `withdraw_withheld_authority` and `transfer_fee_config_authority` both belongs to the contract.", + "Once the token is migrated to AMM, the authorities will be reset to this value" + ], + "type": "pubkey" + }, + { + "name": "platform_vesting_wallet", + "type": "pubkey" + }, + { + "name": "platform_vesting_scale", + "type": "u64" + }, + { + "name": "platform_cp_creator", + "docs": [ + "If a valid platform_cp_creator is configured for the platform,", + "it will be used as the creator for the AMM pool during migration to cpswap pool." + ], + "type": "pubkey" + }, + { + "name": "restrict_global_config", + "docs": [ + "Whether the platform restricts the global config used to create pools with this", + "platform config, 0: no restriction, 1: only the allowed global config can be used," + ], + "type": "u8" + }, + { + "name": "restrict_curve_param", + "docs": [ + "Whether the platform restricts the launch params of the pools created with this", + "platform config, 0: no restriction, 1: the launch params must satisfy the", + "`PlatformCurveRule` account of the used global config" + ], + "type": "u8" + }, + { + "name": "curve_rule_manager", + "docs": [ + "The wallet allowed to manage the `PlatformCurveRule` accounts of this platform", + "config, the platform admin is often a multisig wallet which is inconvenient for", + "the day to day rule changes. The platform admin can always manage the rules too,", + "and it is the only one that can set this wallet." + ], + "type": "pubkey" + }, + { + "name": "padding", + "docs": [ + "padding for future updates. The last 4 bytes were the length prefix of the removed", + "`curve_params` vec, the migration left them zeroed, so the layout keeps exactly the", + "byte count every platform config already has." + ], + "type": { + "array": [ + "u8", + 78 + ] + } + } + ] + } + }, + { + "name": "PlatformConfigInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "fee_wallet", + "type": "pubkey" + }, + { + "name": "nft_wallet", + "type": "pubkey" + }, + { + "name": "migrate_nft_info", + "type": { + "defined": { + "name": "MigrateNftInfo" + } + } + }, + { + "name": "fee_rate", + "type": "u64" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "web", + "type": "string" + }, + { + "name": "img", + "type": "string" + }, + { + "name": "transfer_fee_extension_auth", + "type": "pubkey" + }, + { + "name": "creator_fee_rate", + "type": "u64" + }, + { + "name": "platform_vesting_scale", + "type": "u64" + }, + { + "name": "vesting_wallet", + "type": "pubkey" + } + ] + } + }, + { + "name": "PlatformConfigParam", + "type": { + "kind": "enum", + "variants": [ + { + "name": "FeeWallet", + "fields": [ + "pubkey" + ] + }, + { + "name": "NFTWallet", + "fields": [ + "pubkey" + ] + }, + { + "name": "MigrateNftInfo", + "fields": [ + { + "defined": { + "name": "MigrateNftInfo" + } + } + ] + }, + { + "name": "FeeRate", + "fields": [ + "u64" + ] + }, + { + "name": "Name", + "fields": [ + "string" + ] + }, + { + "name": "Web", + "fields": [ + "string" + ] + }, + { + "name": "Img", + "fields": [ + "string" + ] + }, + { + "name": "CpSwapConfig" + }, + { + "name": "AllInfo", + "fields": [ + { + "defined": { + "name": "PlatformConfigInfo" + } + } + ] + }, + { + "name": "VestingWallet", + "fields": [ + "pubkey" + ] + }, + { + "name": "PlatformVestingScale", + "fields": [ + "u64" + ] + }, + { + "name": "PlatformCPCreator", + "fields": [ + "pubkey" + ] + }, + { + "name": "RestrictGlobalConfig", + "fields": [ + "u64" + ] + }, + { + "name": "RestrictCurveParam", + "fields": [ + "u64" + ] + }, + { + "name": "CurveRuleManager", + "fields": [ + "pubkey" + ] + } + ] + } + }, + { + "name": "PlatformCurveRule", + "docs": [ + "The platform launch parameter rule for one global config.", + "", + "The existence of this account is only checked when the platform restricts the launch", + "parameters, see `PlatformConfig::restrict_curve_param`. It narrows the launch parameters", + "down from what the global config allows, it can never loosen them." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "version", + "docs": [ + "The layout version of the account" + ], + "type": "u8" + }, + { + "name": "platform_config", + "docs": [ + "The platform config the rule belongs to" + ], + "type": "pubkey" + }, + { + "name": "global_config", + "docs": [ + "The global config the rule applies to" + ], + "type": "pubkey" + }, + { + "name": "epoch", + "docs": [ + "The epoch of the last update" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u64", + 8 + ] + } + }, + { + "name": "groups", + "docs": [ + "The check groups, a launch is allowed when it satisfies any of them.", + "An empty list means the platform does not restrict the launch parameters of", + "this global config." + ], + "type": { + "vec": { + "defined": { + "name": "CurveRuleGroup" + } + } + } + } + ] + } + }, + { + "name": "PlatformParams", + "docs": [ + "Represents the parameters for initializing a platform config account", + "# Fields", + "* `migrate_nft_info` - The platform configures liquidity info during migration(Only support MigrateType::CPSWAP)", + "* `fee_rate` - Fee rate of the platform", + "* `name` - Name of the platform", + "* `web` - Website of the platform", + "* `img` - Image link of the platform", + "/// * `creator_fee_rate` - The fee rate charged by the creator for each transaction." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "migrate_nft_info", + "type": { + "defined": { + "name": "MigrateNftInfo" + } + } + }, + { + "name": "fee_rate", + "type": "u64" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "web", + "type": "string" + }, + { + "name": "img", + "type": "string" + }, + { + "name": "creator_fee_rate", + "type": "u64" + }, + { + "name": "platform_vesting_scale", + "type": "u64" + } + ] + } + }, + { + "name": "PoolCreateEvent", + "docs": [ + "Emitted when pool created" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool_state", + "type": "pubkey" + }, + { + "name": "creator", + "type": "pubkey" + }, + { + "name": "config", + "type": "pubkey" + }, + { + "name": "base_mint_param", + "type": { + "defined": { + "name": "MintParams" + } + } + }, + { + "name": "curve_param", + "type": { + "defined": { + "name": "CurveParams" + } + } + }, + { + "name": "vesting_param", + "type": { + "defined": { + "name": "VestingParams" + } + } + }, + { + "name": "amm_fee_on", + "type": { + "defined": { + "name": "AmmCreatorFeeOn" + } + } + } + ] + } + }, + { + "name": "PoolState", + "docs": [ + "Represents the state of a trading pool in the protocol", + "Stores all essential information about pool balances, fees, and configuration" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "epoch", + "docs": [ + "Account update epoch" + ], + "type": "u64" + }, + { + "name": "auth_bump", + "docs": [ + "Bump seed used for PDA address derivation" + ], + "type": "u8" + }, + { + "name": "status", + "docs": [ + "Current status of the pool", + "* 0: Pool is funding", + "* 1: Pool funding is end, waiting for migration", + "* 2: Pool migration is done" + ], + "type": "u8" + }, + { + "name": "base_decimals", + "docs": [ + "Decimals of the pool base token" + ], + "type": "u8" + }, + { + "name": "quote_decimals", + "docs": [ + "Decimals of the pool quote token" + ], + "type": "u8" + }, + { + "name": "migrate_type", + "docs": [ + "Migrate to AMM or CpSwap, 0: amm\uff0c 1: cpswap" + ], + "type": "u8" + }, + { + "name": "supply", + "docs": [ + "Supply of the pool base token" + ], + "type": "u64" + }, + { + "name": "total_base_sell", + "docs": [ + "Total sell amount of the base token" + ], + "type": "u64" + }, + { + "name": "virtual_base", + "docs": [ + "For different curves, virtual_base and virtual_quote have different meanings", + "For constant product curve, virtual_base and virtual_quote are virtual liquidity, virtual_quote/virtual_base is the initial price", + "For linear price curve, virtual_base is the price slope parameter a, virtual_quote has no effect", + "For fixed price curve, virtual_quote/virtual_base is the initial price" + ], + "type": "u64" + }, + { + "name": "virtual_quote", + "type": "u64" + }, + { + "name": "real_base", + "docs": [ + "Actual base token amount in the pool", + "Represents the real tokens available for trading" + ], + "type": "u64" + }, + { + "name": "real_quote", + "docs": [ + "Actual quote token amount in the pool", + "Represents the real tokens available for trading" + ], + "type": "u64" + }, + { + "name": "total_quote_fund_raising", + "docs": [ + "The total quote fund raising of the pool" + ], + "type": "u64" + }, + { + "name": "quote_protocol_fee", + "docs": [ + "Accumulated trading fees in quote tokens", + "Can be collected by the protocol fee owner" + ], + "type": "u64" + }, + { + "name": "platform_fee", + "docs": [ + "Accumulated platform fees in quote tokens", + "Can be collected by the platform wallet stored in platform config" + ], + "type": "u64" + }, + { + "name": "migrate_fee", + "docs": [ + "The fee of migrate to amm" + ], + "type": "u64" + }, + { + "name": "vesting_schedule", + "docs": [ + "Vesting schedule for the base token" + ], + "type": { + "defined": { + "name": "VestingSchedule" + } + } + }, + { + "name": "global_config", + "docs": [ + "Public key of the global configuration account", + "Contains protocol-wide settings this pool adheres to" + ], + "type": "pubkey" + }, + { + "name": "platform_config", + "docs": [ + "Public key of the platform configuration account", + "Contains platform-wide settings this pool adheres to" + ], + "type": "pubkey" + }, + { + "name": "base_mint", + "docs": [ + "Public key of the base mint address" + ], + "type": "pubkey" + }, + { + "name": "quote_mint", + "docs": [ + "Public key of the quote mint address" + ], + "type": "pubkey" + }, + { + "name": "base_vault", + "docs": [ + "Public key of the base token vault", + "Holds the actual base tokens owned by the pool" + ], + "type": "pubkey" + }, + { + "name": "quote_vault", + "docs": [ + "Public key of the quote token vault", + "Holds the actual quote tokens owned by the pool" + ], + "type": "pubkey" + }, + { + "name": "creator", + "docs": [ + "The creator of base token" + ], + "type": "pubkey" + }, + { + "name": "token_program_flag", + "docs": [ + "token program bits", + "bit0: base token program flag", + "0: spl_token_program", + "1: token_program_2022", + "", + "bit1: quote token program flag", + "0: spl_token_program", + "1: token_program_2022" + ], + "type": "u8" + }, + { + "name": "amm_creator_fee_on", + "docs": [ + "migrate to cpmm, creator fee on quote token or both token" + ], + "type": { + "defined": { + "name": "AmmCreatorFeeOn" + } + } + }, + { + "name": "platform_vesting_share", + "docs": [ + "platform vesting token amount" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u8", + 54 + ] + } + } + ] + } + }, + { + "name": "PoolStatus", + "docs": [ + "Represents the different states a pool can be in", + "* Fund - Initial state where pool is accepting funds", + "* Migrate - Pool funding has ended and waiting for migration", + "* Trade - Pool migration is complete and amm trading is enabled" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fund" + }, + { + "name": "Migrate" + }, + { + "name": "Trade" + } + ] + } + }, + { + "name": "TradeDirection", + "docs": [ + "Specifies the direction of a trade in the bonding curve", + "This is important because curves can treat tokens differently through weights or offsets" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Buy" + }, + { + "name": "Sell" + } + ] + } + }, + { + "name": "TradeEvent", + "docs": [ + "Emitted when trade process" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "pool_state", + "type": "pubkey" + }, + { + "name": "total_base_sell", + "type": "u64" + }, + { + "name": "virtual_base", + "type": "u64" + }, + { + "name": "virtual_quote", + "type": "u64" + }, + { + "name": "real_base_before", + "type": "u64" + }, + { + "name": "real_quote_before", + "type": "u64" + }, + { + "name": "real_base_after", + "type": "u64" + }, + { + "name": "real_quote_after", + "type": "u64" + }, + { + "name": "amount_in", + "type": "u64" + }, + { + "name": "amount_out", + "type": "u64" + }, + { + "name": "protocol_fee", + "type": "u64" + }, + { + "name": "platform_fee", + "type": "u64" + }, + { + "name": "creator_fee", + "type": "u64" + }, + { + "name": "share_fee", + "type": "u64" + }, + { + "name": "trade_direction", + "type": { + "defined": { + "name": "TradeDirection" + } + } + }, + { + "name": "pool_status", + "type": { + "defined": { + "name": "PoolStatus" + } + } + }, + { + "name": "exact_in", + "type": "bool" + } + ] + } + }, + { + "name": "TransferFeeExtensionParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "transfer_fee_basis_points", + "docs": [ + "denominator is 10000, currently, this value cannot exceed 5%, which is 500." + ], + "type": "u16" + }, + { + "name": "maximum_fee", + "docs": [ + "Maximum fee on each transfers, the value must exceed supply * transfer_fee_basis_points / 10000" + ], + "type": "u64" + } + ] + } + }, + { + "name": "VestingParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "total_locked_amount", + "type": "u64" + }, + { + "name": "cliff_period", + "type": "u64" + }, + { + "name": "unlock_period", + "type": "u64" + } + ] + } + }, + { + "name": "VestingRecord", + "type": { + "kind": "struct", + "fields": [ + { + "name": "epoch", + "docs": [ + "Account update epoch" + ], + "type": "u64" + }, + { + "name": "pool", + "docs": [ + "The pool state account" + ], + "type": "pubkey" + }, + { + "name": "beneficiary", + "docs": [ + "The beneficiary of the vesting account" + ], + "type": "pubkey" + }, + { + "name": "claimed_amount", + "docs": [ + "The amount of tokens claimed" + ], + "type": "u64" + }, + { + "name": "token_share_amount", + "docs": [ + "The share amount of the token to be vested" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "VestingSchedule", + "type": { + "kind": "struct", + "fields": [ + { + "name": "total_locked_amount", + "type": "u64" + }, + { + "name": "cliff_period", + "type": "u64" + }, + { + "name": "unlock_period", + "type": "u64" + }, + { + "name": "start_time", + "type": "u64" + }, + { + "name": "allocated_share_amount", + "docs": [ + "Total allocated share amount of the base token, not greater than total_locked_amount" + ], + "type": "u64" + } + ] + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index f7c05db..9196903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pumpfun-cli" -version = "0.2.0" -description = "CLI for pump.fun on Solana — trade, launch, and browse tokens" +version = "0.3.0" +description = "CLI for pump.fun and Raydium LaunchLab on Solana — trade, launch, and browse tokens" readme = "README.md" license = {text = "Apache-2.0"} requires-python = ">=3.12" diff --git a/src/pumpfun_cli/__init__.py b/src/pumpfun_cli/__init__.py index 04fae4b..cc61a9f 100644 --- a/src/pumpfun_cli/__init__.py +++ b/src/pumpfun_cli/__init__.py @@ -1,3 +1,3 @@ """pumpfun-cli — CLI for pump.fun on Solana.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/pumpfun_cli/commands/info.py b/src/pumpfun_cli/commands/info.py index 1ed2f7d..f3a031b 100644 --- a/src/pumpfun_cli/commands/info.py +++ b/src/pumpfun_cli/commands/info.py @@ -31,24 +31,37 @@ def info( error(result["message"]) if not render(result, state.json_mode): typer.echo(f"Mint: {result['mint']}") + if result.get("venue") in ("launchlab", "cpmm"): + typer.echo( + f"Launchpad: {result['platform'] or 'LaunchLab'} (Raydium LaunchLab)" + ) symbol = result["quote_symbol"] typer.echo(f"Price: {result['price']:.10f} {symbol}") typer.echo(f"Bonding progress: {result['bonding_progress']}%") typer.echo(f"Graduated: {result['graduated']}") - if result["graduated"] and result.get("pool_address"): + if result["graduated"] and result.get("base_reserves") is not None: typer.echo(f"Pool: {result['pool_address']}") typer.echo(f"Base reserves: {result['base_reserves']:.2f}") typer.echo(f"Quote reserves: {result['quote_reserves']:.4f} {symbol}") else: typer.echo(f"Quote reserves: {result['real_quote_reserves']:.4f} {symbol}") - if result.get("pumpswap_warning"): - typer.echo(f"Warning: {result['pumpswap_warning']}") + if result.get("pumpswap_warning") or result.get("pool_warning"): + warning = result.get("pumpswap_warning") or result["pool_warning"] + typer.echo(f"Warning: {warning}") typer.echo(f"Creator: {result['creator'] or '\u2014'}") - if result["is_mayhem_mode"]: + if result.get("is_mayhem_mode"): typer.echo("Mayhem mode: Yes") - if result["is_cashback_coin"]: + if result.get("is_cashback_coin"): typer.echo("Cashback: Yes") if result.get("is_holder_reward"): typer.echo("Holder rewards: Yes") if result.get("creator_fee_bps"): typer.echo(f"Creator fee: {result['creator_fee_bps']} bps") + if result.get("curve_fee_pct") is not None: + typer.echo(f"Curve fee: {result['curve_fee_pct']:g}%") + if result.get("transfer_fee_bps"): + typer.echo(f"Transfer fee: {result['transfer_fee_bps']} bps") + if result.get("migrate_type"): + typer.echo(f"Migrated to: {result['migrate_type']}") + if result.get("pool_fee_pct") is not None: + typer.echo(f"Pool fee: {result['pool_fee_pct']:g}%") diff --git a/src/pumpfun_cli/commands/trade.py b/src/pumpfun_cli/commands/trade.py index 000297f..960d2c0 100644 --- a/src/pumpfun_cli/commands/trade.py +++ b/src/pumpfun_cli/commands/trade.py @@ -4,6 +4,8 @@ import typer +from pumpfun_cli.core.cpmm import buy_cpmm, sell_cpmm +from pumpfun_cli.core.launchlab import buy_launchlab, sell_launchlab from pumpfun_cli.core.pumpswap import buy_pumpswap, sell_pumpswap from pumpfun_cli.core.trade import ( buy_token, @@ -22,6 +24,35 @@ from pumpfun_cli.output import error, render +def _route(curve, amm, launchlab, cpmm, force_amm: bool, mint: str, call) -> dict: + """Trade where the coin lives. + + pump.fun's curve, then PumpSwap once it has graduated; a LaunchLab curve + when pump.fun has no such coin, then Raydium CPMM once that has graduated. + `--force-amm` goes straight to the pool: PumpSwap, or CPMM for a LaunchLab + coin. `call(fn)` runs one venue's core function with the command's arguments. + """ + if force_amm: + result = call(amm) + if result.get("pool_missing"): + # No PumpSwap pool: a LaunchLab coin trades on CPMM. Unless CPMM + # recognises the coin, PumpSwap's own error stands. + pool_result = call(cpmm) + if pool_result.get("error") not in ("not_found", "cpmm_error"): + return pool_result + return result + result = call(curve) + if result.get("error") == "graduated": + return call(amm) + if result.get("error") == "not_found": + result = call(launchlab) + if result.get("error") == "graduated": + return call(cpmm) + if result.get("error") == "not_found": + result["message"] = f"No pump.fun or LaunchLab coin found for {mint}." + return result + + def _get_overrides(ctx: typer.Context) -> dict: """Extract priority_fee and compute_units from global state.""" state = ctx.obj @@ -75,7 +106,9 @@ def buy( 15, "--slippage", help="Slippage tolerance percent (default: 15)" ), force_amm: bool = typer.Option( - False, "--force-amm", help="Skip bonding curve, buy directly on PumpSwap AMM" + False, + "--force-amm", + help="Skip the bonding curve, buy in the pool (PumpSwap, or CPMM for LaunchLab coins)", ), confirm: bool = typer.Option(False, "--confirm", help="Wait for transaction confirmation"), dry_run: bool = typer.Option( @@ -90,9 +123,15 @@ def buy( rpc, keyfile, password = _require_rpc_and_wallet(ctx) overrides = _get_overrides(ctx) try: - if force_amm: - result = asyncio.run( - buy_pumpswap( + result = _route( + buy_token, + buy_pumpswap, + buy_launchlab, + buy_cpmm, + force_amm, + mint, + lambda fn: asyncio.run( + fn( rpc, keyfile, password, @@ -103,35 +142,8 @@ def buy( dry_run=dry_run, **overrides, ) - ) - else: - result = asyncio.run( - buy_token( - rpc, - keyfile, - password, - mint, - amount, - slippage, - confirm=confirm, - dry_run=dry_run, - **overrides, - ) - ) - if result.get("error") == "graduated": - result = asyncio.run( - buy_pumpswap( - rpc, - keyfile, - password, - mint, - amount, - slippage, - confirm=confirm, - dry_run=dry_run, - **overrides, - ) - ) + ), + ) except (ValueError, RuntimeError) as exc: error(str(exc)) if result.get("dry_run"): @@ -170,7 +182,9 @@ def sell( 15, "--slippage", help="Slippage tolerance percent (default: 15)" ), force_amm: bool = typer.Option( - False, "--force-amm", help="Skip bonding curve, sell directly on PumpSwap AMM" + False, + "--force-amm", + help="Skip the bonding curve, sell in the pool (PumpSwap, or CPMM for LaunchLab coins)", ), confirm: bool = typer.Option(False, "--confirm", help="Wait for transaction confirmation"), dry_run: bool = typer.Option( @@ -183,9 +197,15 @@ def sell( rpc, keyfile, password = _require_rpc_and_wallet(ctx) overrides = _get_overrides(ctx) try: - if force_amm: - result = asyncio.run( - sell_pumpswap( + result = _route( + sell_token, + sell_pumpswap, + sell_launchlab, + sell_cpmm, + force_amm, + mint, + lambda fn: asyncio.run( + fn( rpc, keyfile, password, @@ -196,35 +216,8 @@ def sell( dry_run=dry_run, **overrides, ) - ) - else: - result = asyncio.run( - sell_token( - rpc, - keyfile, - password, - mint, - amount, - slippage, - confirm=confirm, - dry_run=dry_run, - **overrides, - ) - ) - if result.get("error") == "graduated": - result = asyncio.run( - sell_pumpswap( - rpc, - keyfile, - password, - mint, - amount, - slippage, - confirm=confirm, - dry_run=dry_run, - **overrides, - ) - ) + ), + ) except (ValueError, RuntimeError) as exc: error(str(exc)) if result.get("dry_run"): @@ -243,6 +236,8 @@ def sell( f" Min out: {result['min_quote_out']:.6f} {symbol}" f" ({result['slippage_pct']}% slippage)" ) + if "tokens_kept" in result: + typer.echo(f" Kept: {result['tokens_kept']:.6f} (curve can't pay)") return if result.get("error"): code = 3 if result["error"] in ("graduated", "slippage", "no_tokens") else 1 @@ -253,6 +248,11 @@ def sell( f"Sold {result['tokens_sold']:.2f} tokens" f" for {result['quote_received']:.6f} {result['quote_symbol']}" ) + if "tokens_kept" in result: + typer.echo( + f"Kept {result['tokens_kept']:.2f} tokens: the bonding curve's real" + " reserves can't pay for them now." + ) typer.echo(f"TX: {result['explorer']}") diff --git a/src/pumpfun_cli/core/cpmm.py b/src/pumpfun_cli/core/cpmm.py new file mode 100644 index 0000000..8d20176 --- /dev/null +++ b/src/pumpfun_cli/core/cpmm.py @@ -0,0 +1,406 @@ +"""Raydium CPMM trades and info for LaunchLab coins that have graduated. + +The pool is found from the coin's LaunchLab pool: its platform config names +the CPMM AMM config, and the pool is derived from that and the two mints. +Amounts are in the pool's quote asset, the one the curve was priced in. +""" + +from pathlib import Path + +from solders.pubkey import Pubkey +from spl.token.instructions import create_idempotent_associated_token_account + +from pumpfun_cli.core.amounts import ( + amount_fields, + confirmed_token_delta, + received_fields, + to_raw, +) +from pumpfun_cli.core.launchlab import buy_shortfall, find_launchlab_pool +from pumpfun_cli.core.trade import DEFAULT_PRIORITY_FEE, _handle_tx_error +from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey +from pumpfun_cli.crypto import decrypt_keypair +from pumpfun_cli.protocol.address import find_ata +from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError +from pumpfun_cli.protocol.cpmm import ( + CPMM_SLIPPAGE_ERROR_CODES, + FEE_RATE_DENOMINATOR, + build_cpmm_swap_instruction, + cpmm_pool_address, + parse_cpmm_fee_rate, + parse_cpmm_pool, + swap_amount_out, +) +from pumpfun_cli.protocol.instructions import quote_settlement_account +from pumpfun_cli.protocol.launchlab import ( + MIGRATE_TO_CPMM, + PLATFORM_CPSWAP_CONFIG_OFFSET, + STATUS_TRADING, + mint_transfer_fee, + platform_name, + withheld_fee, +) +from pumpfun_cli.protocol.quote import quote_symbol + +CPMM_COMPUTE_UNITS = 150_000 +_TOKEN_AMOUNT_OFFSET = 64 + + +class _NoPool(Exception): + """The coin has no CPMM pool to trade in; carries the result to return.""" + + def __init__(self, result: dict): + super().__init__(result["message"]) + self.result = result + + +async def _load_pool(client: RpcClient, mint: Pubkey, mint_str: str) -> dict: + """The coin's CPMM pool with its reserves, fee and transfer fee. + + Raises: + _NoPool: If the coin isn't a LaunchLab coin migrated to CPMM + RuntimeError: If an account can't be read + """ + found = await find_launchlab_pool(client, mint) + if found is None: + raise _NoPool({"error": "not_found", "message": f"No LaunchLab coin {mint_str}"}) + _, launch = found + if launch["status"] == STATUS_TRADING: + raise _NoPool( + { + "error": "not_graduated", + "message": "Token is still on its LaunchLab bonding curve.", + } + ) + if launch["migrate_type"] != MIGRATE_TO_CPMM: + raise _NoPool( + { + "error": "unsupported_venue", + "message": "Token graduated to Raydium AMM v4, which this CLI does not trade.", + } + ) + platform = await client.get_account_info(launch["platform_config"]) + if not platform.value: + raise RuntimeError(f"LaunchLab platform config {launch['platform_config']} unreadable") + platform_data = bytes(platform.value.data) + offset = PLATFORM_CPSWAP_CONFIG_OFFSET + amm_config = Pubkey.from_bytes(platform_data[offset : offset + 32]) + address = cpmm_pool_address(amm_config, mint, launch["quote_mint"]) + pool_account = await client.get_account_info(address) + if not pool_account.value: + raise _NoPool( + { + "error": "migrating", + "message": "Token's curve is complete but its CPMM pool is not open yet.", + } + ) + pool = parse_cpmm_pool(bytes(pool_account.value.data), mint) + config, base_vault, quote_vault, base_mint = await client.get_multiple_accounts( + [pool["amm_config"], pool["base_vault"], pool["quote_vault"], mint] + ) + if None in (config, base_vault, quote_vault, base_mint): + raise RuntimeError(f"CPMM pool {address}: config, vault or mint unreadable") + + def vault_amount(account) -> int: + return int.from_bytes( + bytes(account.data)[_TOKEN_AMOUNT_OFFSET : _TOKEN_AMOUNT_OFFSET + 8], "little" + ) + + return { + **pool, + "address": address, + "platform": platform_name(platform_data), + "fee_rate": parse_cpmm_fee_rate(bytes(config.data), pool["creator_fee_enabled"]), + "transfer_fee": mint_transfer_fee(bytes(base_mint.data)), + # The vaults also hold fees owed to the protocol, fund and creator. + "base_reserve": vault_amount(base_vault) - pool["base_fees"], + "quote_reserve": vault_amount(quote_vault) - pool["quote_fees"], + } + + +def _price(pool: dict) -> float: + if pool["base_reserve"] <= 0: + return 0.0 + return (pool["quote_reserve"] / 10 ** pool["quote_decimals"]) / ( + pool["base_reserve"] / 10 ** pool["base_decimals"] + ) + + +def _pool_fields(pool: dict) -> dict: + return { + "venue": "cpmm", + "platform": pool["platform"], + "quote_mint": str(pool["quote_mint"]), + "quote_symbol": quote_symbol(pool["quote_mint"]), + } + + +def _impact(effective: float, spot: float) -> float: + return round((effective - spot) / spot * 100, 4) if spot > 0 else 0 + + +async def buy_cpmm( + rpc_url: str, + keystore_path: str, + password: str, + mint_str: str, + amount: float, + slippage: int = 15, + confirm: bool = False, + priority_fee: int | None = None, + compute_units: int | None = None, + dry_run: bool = False, +) -> dict: + """Spend exactly `amount` of the pool's quote asset on Raydium CPMM (swap_base_input).""" + keypair = decrypt_keypair(Path(keystore_path), password) + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url) + try: + pool = await _load_pool(client, mint, mint_str) + if pool["base_reserve"] <= 0 or pool["quote_reserve"] <= 0: + return {"error": "no_liquidity", "message": "Pool has no liquidity."} + quote_mint = pool["quote_mint"] + base_decimals, quote_decimals = pool["base_decimals"], pool["quote_decimals"] + user = keypair.pubkey() + + amount_raw = to_raw(amount, quote_decimals) + gross = swap_amount_out( + pool["quote_reserve"], pool["base_reserve"], amount_raw, pool["fee_rate"] + ) + tokens_out = gross - withheld_fee(gross, pool["transfer_fee"]) + min_tokens_out = max(1, tokens_out * (100 - slippage) // 100) + + eff_priority_fee = priority_fee if priority_fee is not None else DEFAULT_PRIORITY_FEE + eff_compute_units = compute_units if compute_units is not None else CPMM_COMPUTE_UNITS + shortfall = await buy_shortfall( + client, + user, + quote_mint, + pool["quote_token_program"], + quote_decimals, + amount_raw, + eff_priority_fee * eff_compute_units // 1_000_000, + ) + + if dry_run: + spot = _price(pool) + expected = tokens_out / 10**base_decimals + effective = amount / expected if expected > 0 else 0 + result = { + "dry_run": True, + "action": "buy", + "mint": mint_str, + **_pool_fields(pool), + **amount_fields("quote_in", amount_raw, quote_decimals), + **amount_fields("expected_tokens", tokens_out, base_decimals), + "effective_price": effective, + "spot_price": spot, + "price_impact_pct": _impact(effective, spot), + **amount_fields("min_tokens_out", min_tokens_out, base_decimals), + "slippage_pct": slippage, + } + if shortfall: + result["balance_warning"] = shortfall["message"] + return result + + if shortfall: + return { + **shortfall, + "error": "insufficient_balance", + "message": f"Insufficient balance. {shortfall['message']}.", + } + + base_program = pool["base_token_program"] + quote_account, open_ixs, close_ixs = quote_settlement_account( + user, quote_mint, pool["quote_token_program"], funding=amount_raw + ) + ixs = [ + *open_ixs, + create_idempotent_associated_token_account(user, user, mint, base_program), + build_cpmm_swap_instruction( + buy=True, + pool_address=pool["address"], + pool=pool, + user=user, + user_base_token=find_ata(user, mint, base_program), + user_quote_token=quote_account, + amount_in=amount_raw, + minimum_amount_out=min_tokens_out, + ), + *close_ixs, + ] + try: + sig = await client.send_tx( + ixs, + [keypair], + confirm=confirm, + priority_fee=eff_priority_fee, + compute_units=eff_compute_units, + ) + except TransactionFailedError as exc: + return _handle_tx_error(exc, CPMM_SLIPPAGE_ERROR_CODES) + received = await confirmed_token_delta(client, sig, user, mint) if confirm else None + result = { + "action": "buy", + "mint": mint_str, + **_pool_fields(pool), + **amount_fields("quote_spent", amount_raw, quote_decimals), + **received_fields("tokens_received", received, tokens_out, base_decimals), + "signature": sig, + "explorer": f"https://solscan.io/tx/{sig}", + } + if confirm: + result["confirmed"] = True + return result + except _NoPool as exc: + return exc.result + except (RuntimeError, ValueError) as exc: + return {"error": "cpmm_error", "message": str(exc)} + finally: + await client.close() + + +async def sell_cpmm( + rpc_url: str, + keystore_path: str, + password: str, + mint_str: str, + amount_str: str, + slippage: int = 15, + confirm: bool = False, + priority_fee: int | None = None, + compute_units: int | None = None, + dry_run: bool = False, +) -> dict: + """Sell tokens on Raydium CPMM (swap_base_input) for the pool's quote asset.""" + keypair = decrypt_keypair(Path(keystore_path), password) + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url) + try: + pool = await _load_pool(client, mint, mint_str) + quote_mint = pool["quote_mint"] + base_decimals, quote_decimals = pool["base_decimals"], pool["quote_decimals"] + base_program = pool["base_token_program"] + user = keypair.pubkey() + user_ata = find_ata(user, mint, base_program) + + held = await client.get_token_account_balance(user_ata) + if amount_str.lower() == "all": + token_amount = held + else: + token_amount = to_raw(amount_str, base_decimals) + if held < token_amount: + return { + "error": "insufficient_balance", + "message": ( + f"Insufficient token balance. Have {held / 10**base_decimals:.6f}" + f" tokens, need {token_amount / 10**base_decimals:.6f} tokens." + ), + "available_tokens": held / 10**base_decimals, + "required_tokens": token_amount / 10**base_decimals, + } + if token_amount <= 0: + return {"error": "no_tokens", "message": "No tokens to sell."} + if pool["base_reserve"] <= 0 or pool["quote_reserve"] <= 0: + return {"error": "no_liquidity", "message": "Pool has no liquidity."} + + arrived = token_amount - withheld_fee(token_amount, pool["transfer_fee"]) + quote_out = swap_amount_out( + pool["base_reserve"], pool["quote_reserve"], arrived, pool["fee_rate"] + ) + if quote_out <= 0: + return { + "error": "dust", + "message": "Too few tokens: the pool would pay nothing for them.", + } + min_quote_out = max(1, quote_out * (100 - slippage) // 100) + + if dry_run: + spot = _price(pool) + effective = quote_out / 10**quote_decimals / (token_amount / 10**base_decimals) + return { + "dry_run": True, + "action": "sell", + "mint": mint_str, + **_pool_fields(pool), + **amount_fields("tokens_in", token_amount, base_decimals), + **amount_fields("expected_quote", quote_out, quote_decimals), + "effective_price": effective, + "spot_price": spot, + "price_impact_pct": _impact(effective, spot), + **amount_fields("min_quote_out", min_quote_out, quote_decimals), + "slippage_pct": slippage, + } + + quote_account, open_ixs, close_ixs = quote_settlement_account( + user, quote_mint, pool["quote_token_program"] + ) + ixs = [ + *open_ixs, + build_cpmm_swap_instruction( + buy=False, + pool_address=pool["address"], + pool=pool, + user=user, + user_base_token=user_ata, + user_quote_token=quote_account, + amount_in=token_amount, + minimum_amount_out=min_quote_out, + ), + *close_ixs, + ] + try: + sig = await client.send_tx( + ixs, + [keypair], + confirm=confirm, + priority_fee=priority_fee if priority_fee is not None else DEFAULT_PRIORITY_FEE, + compute_units=compute_units if compute_units is not None else CPMM_COMPUTE_UNITS, + ) + except TransactionFailedError as exc: + return _handle_tx_error(exc, CPMM_SLIPPAGE_ERROR_CODES) + result = { + "action": "sell", + "mint": mint_str, + **_pool_fields(pool), + **amount_fields("tokens_sold", token_amount, base_decimals), + "quote_received": quote_out / 10**quote_decimals, + "signature": sig, + "explorer": f"https://solscan.io/tx/{sig}", + } + if confirm: + result["confirmed"] = True + return result + except _NoPool as exc: + return exc.result + except (RuntimeError, ValueError) as exc: + return {"error": "cpmm_error", "message": str(exc)} + finally: + await client.close() + + +async def get_cpmm_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) -> dict: + """The CPMM pool of a graduated LaunchLab coin: price, reserves, fee.""" + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url, timeout=rpc_timeout) + try: + pool = await _load_pool(client, mint, mint_str) + return { + "pool_address": str(pool["address"]), + "price": _price(pool), + "base_reserves": pool["base_reserve"] / 10 ** pool["base_decimals"], + "quote_reserves": pool["quote_reserve"] / 10 ** pool["quote_decimals"], + "pool_fee_pct": pool["fee_rate"] * 100 / FEE_RATE_DENOMINATOR, + } + except _NoPool as exc: + return exc.result + except (RuntimeError, ValueError) as exc: + return {"error": "cpmm_error", "message": str(exc)} + finally: + await client.close() diff --git a/src/pumpfun_cli/core/info.py b/src/pumpfun_cli/core/info.py index 4f6103e..74ed159 100644 --- a/src/pumpfun_cli/core/info.py +++ b/src/pumpfun_cli/core/info.py @@ -1,5 +1,7 @@ """Fetch on-chain token info.""" +from pumpfun_cli.core.cpmm import get_cpmm_info +from pumpfun_cli.core.launchlab import get_launchlab_info from pumpfun_cli.core.pumpswap import get_pumpswap_info from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.protocol.address import derive_bonding_curve @@ -22,7 +24,16 @@ async def get_token_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) try: resp = await client.get_account_info(bonding_curve) if not resp.value: - return {"error": "not_found", "message": f"No bonding curve found for {mint_str}"} + result = await get_launchlab_info(rpc_url, mint_str, rpc_timeout=rpc_timeout) + if result.get("error") == "not_found": + result["message"] = f"No pump.fun or LaunchLab coin found for {mint_str}." + elif result.get("migrate_type") == "cpmm": + pool = await get_cpmm_info(rpc_url, mint_str, rpc_timeout=rpc_timeout) + if pool.get("error"): + result["pool_warning"] = f"CPMM pool lookup failed: {pool['message']}" + else: + result.update(pool, venue="cpmm") + return result state = parse_bonding_curve(bytes(resp.value.data)) quote_mint = state["quote_mint"] _, quote_decimals = await get_mint_info(client, quote_mint) @@ -32,6 +43,7 @@ async def get_token_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) result = { "mint": mint_str, + "venue": "pumpswap" if graduated else "bonding_curve", "bonding_curve": str(bonding_curve), "quote_mint": str(quote_mint), "quote_symbol": quote_symbol(quote_mint), diff --git a/src/pumpfun_cli/core/launchlab.py b/src/pumpfun_cli/core/launchlab.py new file mode 100644 index 0000000..890d444 --- /dev/null +++ b/src/pumpfun_cli/core/launchlab.py @@ -0,0 +1,465 @@ +"""Raydium LaunchLab bonding-curve trades and info, for any launchpad on it. + +A coin's pool is keyed by its base and quote mints. The SOL pool is derived +first; a coin priced in anything else is found by searching LaunchLab's pools +for its mint. Amounts are in the pool's quote asset. +""" + +from pathlib import Path + +from solana.rpc.models import MemcmpOpts +from solders.pubkey import Pubkey +from spl.token.instructions import create_idempotent_associated_token_account + +from pumpfun_cli.core.amounts import ( + amount_fields, + confirmed_token_delta, + received_fields, + to_raw, +) +from pumpfun_cli.core.trade import DEFAULT_PRIORITY_FEE, _handle_tx_error +from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey +from pumpfun_cli.crypto import decrypt_keypair +from pumpfun_cli.protocol.address import find_ata +from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError +from pumpfun_cli.protocol.contracts import ( + ATA_RENT_LAMPORTS, + LAMPORTS_PER_SOL, + SOL_RENT_EXEMPT_MIN, + WSOL_MINT, +) +from pumpfun_cli.protocol.instructions import quote_settlement_account +from pumpfun_cli.protocol.launchlab import ( + CONSTANT_PRODUCT_CURVE, + FEE_RATE_DENOMINATOR, + LAUNCHLAB_PROGRAM, + LAUNCHLAB_SLIPPAGE_ERROR_CODES, + MIGRATE_TO_CPMM, + POOL_BASE_MINT_OFFSET, + POOL_STATE_SIZE, + STATUS_TRADING, + build_launchlab_swap_instruction, + buy_amount_out, + curve_type, + derive_launchlab_pool, + mint_transfer_fee, + parse_fee_rate, + parse_pool_state, + platform_name, + sell_amount_out, + spot_price, + transfer_fee_bps, +) +from pumpfun_cli.protocol.quote import is_sol, quote_symbol + +LAUNCHLAB_COMPUTE_UNITS = 150_000 + + +async def find_launchlab_pool(client: RpcClient, mint: Pubkey) -> tuple[Pubkey, dict] | None: + """The coin's LaunchLab pool and its decoded state, or None if it has none. + + Raises: + RuntimeError: If the pool search fails, e.g. on an RPC that refuses + getProgramAccounts + """ + address = derive_launchlab_pool(mint, WSOL_MINT) + resp = await client.get_account_info(address) + if resp.value and resp.value.owner == LAUNCHLAB_PROGRAM: + return address, parse_pool_state(bytes(resp.value.data)) + try: + found = await client.get_program_accounts( + LAUNCHLAB_PROGRAM, + [POOL_STATE_SIZE, MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(mint))], + ) + except Exception as exc: # noqa: BLE001 - any RPC refusal means the same + raise RuntimeError( + f"No SOL-priced LaunchLab pool for {mint}, and pools in other quote assets" + f" could not be searched ({type(exc).__name__}): this RPC may not serve" + " getProgramAccounts" + ) from None + for item in found: + return item.pubkey, parse_pool_state(bytes(item.account.data)) + return None + + +async def _load_market(client: RpcClient, mint: Pubkey) -> dict | None: + """The pool with everything a trade or quote needs, or None without a pool. + + Adds `address`, `fee_rate` (parts per million), `transfer_fee` + ((bps, max) of the coin), `platform`, and both token programs. + """ + found = await find_launchlab_pool(client, mint) + if found is None: + return None + address, pool = found + global_config, platform_config, base_mint, quote_mint = await client.get_multiple_accounts( + [pool["global_config"], pool["platform_config"], mint, pool["quote_mint"]] + ) + if None in (global_config, platform_config, base_mint, quote_mint): + raise RuntimeError(f"LaunchLab pool {address}: config or mint accounts unreadable") + if curve_type(bytes(global_config.data)) != CONSTANT_PRODUCT_CURVE: + raise RuntimeError(f"LaunchLab pool {address} uses a curve type this CLI does not price") + return { + **pool, + "address": address, + "fee_rate": parse_fee_rate(bytes(global_config.data), bytes(platform_config.data)), + "transfer_fee": mint_transfer_fee(bytes(base_mint.data)), + "platform": platform_name(bytes(platform_config.data)), + "base_token_program": base_mint.owner, + "quote_token_program": quote_mint.owner, + } + + +async def buy_shortfall( + client: RpcClient, + user: Pubkey, + quote_mint: Pubkey, + quote_token_program: Pubkey, + quote_decimals: int, + amount_raw: int, + fee_lamports: int, +) -> dict | None: + """What a buy spending `amount_raw` of the quote is short of, or None. + + SOL covers the fee, the coin's token account and, on a SOL pool, the spend + plus the throwaway WSOL account until it closes. A non-SOL quote must + already be in the wallet's account for it. Returns `message` plus the + available and required amounts of whichever asset is short. + """ + sol = is_sol(quote_mint) + required = ( + (amount_raw if sol else 0) + fee_lamports + 2 * ATA_RENT_LAMPORTS + SOL_RENT_EXEMPT_MIN + ) + balance = await client.get_balance(user) + if balance < required: + return { + "message": f"Wallet has {balance / LAMPORTS_PER_SOL:.6f} SOL" + f" but trade requires ~{required / LAMPORTS_PER_SOL:.6f} SOL", + "available_sol": balance / LAMPORTS_PER_SOL, + "required_sol": required / LAMPORTS_PER_SOL, + } + if sol: + return None + held = await client.get_token_account_balance(find_ata(user, quote_mint, quote_token_program)) + if held >= amount_raw: + return None + symbol = quote_symbol(quote_mint) + return { + "message": f"Wallet holds {held / 10**quote_decimals} {symbol}" + f" but the buy spends {amount_raw / 10**quote_decimals} {symbol}", + **amount_fields("available_quote", held, quote_decimals), + **amount_fields("required_quote", amount_raw, quote_decimals), + } + + +def _not_found(mint_str: str) -> dict: + return {"error": "not_found", "message": f"No LaunchLab pool for {mint_str}"} + + +def _graduated(market: dict) -> dict: + where = "Raydium CPMM" if market["migrate_type"] == MIGRATE_TO_CPMM else "Raydium AMM v4" + return { + "error": "graduated", + "message": f"Token has graduated from its LaunchLab curve to {where}.", + "migrate_type": "cpmm" if market["migrate_type"] == MIGRATE_TO_CPMM else "amm_v4", + } + + +def _market_fields(market: dict) -> dict: + return { + "venue": "launchlab", + "platform": market["platform"], + "quote_mint": str(market["quote_mint"]), + "quote_symbol": quote_symbol(market["quote_mint"]), + } + + +async def buy_launchlab( + rpc_url: str, + keystore_path: str, + password: str, + mint_str: str, + amount: float, + slippage: int = 15, + confirm: bool = False, + priority_fee: int | None = None, + compute_units: int | None = None, + dry_run: bool = False, +) -> dict: + """Spend exactly `amount` of the pool's quote asset on a LaunchLab curve (buy_exact_in).""" + keypair = decrypt_keypair(Path(keystore_path), password) + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url) + try: + market = await _load_market(client, mint) + if market is None: + return _not_found(mint_str) + if market["status"] != STATUS_TRADING: + return _graduated(market) + + quote_mint = market["quote_mint"] + base_decimals, quote_decimals = market["base_decimals"], market["quote_decimals"] + user = keypair.pubkey() + + amount_raw = to_raw(amount, quote_decimals) + tokens_out = buy_amount_out(market, amount_raw, market["fee_rate"], market["transfer_fee"]) + min_tokens_out = max(1, tokens_out * (100 - slippage) // 100) + + eff_priority_fee = priority_fee if priority_fee is not None else DEFAULT_PRIORITY_FEE + eff_compute_units = compute_units if compute_units is not None else LAUNCHLAB_COMPUTE_UNITS + shortfall = await buy_shortfall( + client, + user, + quote_mint, + market["quote_token_program"], + quote_decimals, + amount_raw, + eff_priority_fee * eff_compute_units // 1_000_000, + ) + + if dry_run: + spot = spot_price(market) + expected = tokens_out / 10**base_decimals + effective_price = amount / expected if expected > 0 else 0 + result = { + "dry_run": True, + "action": "buy", + "mint": mint_str, + **_market_fields(market), + **amount_fields("quote_in", amount_raw, quote_decimals), + **amount_fields("expected_tokens", tokens_out, base_decimals), + "effective_price": effective_price, + "spot_price": spot, + "price_impact_pct": round((effective_price - spot) / spot * 100, 4) + if spot > 0 + else 0, + **amount_fields("min_tokens_out", min_tokens_out, base_decimals), + "slippage_pct": slippage, + } + if shortfall: + result["balance_warning"] = shortfall["message"] + return result + + if shortfall: + return { + **shortfall, + "error": "insufficient_balance", + "message": f"Insufficient balance. {shortfall['message']}.", + } + + base_program = market["base_token_program"] + quote_account, open_ixs, close_ixs = quote_settlement_account( + user, quote_mint, market["quote_token_program"], funding=amount_raw + ) + ixs = [ + create_idempotent_associated_token_account(user, user, mint, base_program), + *open_ixs, + build_launchlab_swap_instruction( + buy=True, + pool_address=market["address"], + pool=market, + user=user, + user_base_token=find_ata(user, mint, base_program), + user_quote_token=quote_account, + base_token_program=base_program, + quote_token_program=market["quote_token_program"], + amount_in=amount_raw, + minimum_amount_out=min_tokens_out, + ), + *close_ixs, + ] + try: + sig = await client.send_tx( + ixs, + [keypair], + confirm=confirm, + priority_fee=eff_priority_fee, + compute_units=eff_compute_units, + ) + except TransactionFailedError as exc: + return _handle_tx_error(exc, LAUNCHLAB_SLIPPAGE_ERROR_CODES) + received = await confirmed_token_delta(client, sig, user, mint) if confirm else None + result = { + "action": "buy", + "mint": mint_str, + **_market_fields(market), + **amount_fields("quote_spent", amount_raw, quote_decimals), + **received_fields("tokens_received", received, tokens_out, base_decimals), + "signature": sig, + "explorer": f"https://solscan.io/tx/{sig}", + } + if confirm: + result["confirmed"] = True + return result + except (RuntimeError, ValueError) as exc: + return {"error": "launchlab_error", "message": str(exc)} + finally: + await client.close() + + +async def sell_launchlab( + rpc_url: str, + keystore_path: str, + password: str, + mint_str: str, + amount_str: str, + slippage: int = 15, + confirm: bool = False, + priority_fee: int | None = None, + compute_units: int | None = None, + dry_run: bool = False, +) -> dict: + """Sell tokens into a LaunchLab curve (sell_exact_in), paid in its quote asset.""" + keypair = decrypt_keypair(Path(keystore_path), password) + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url) + try: + market = await _load_market(client, mint) + if market is None: + return _not_found(mint_str) + if market["status"] != STATUS_TRADING: + return _graduated(market) + + quote_mint = market["quote_mint"] + base_decimals, quote_decimals = market["base_decimals"], market["quote_decimals"] + base_program = market["base_token_program"] + user = keypair.pubkey() + user_ata = find_ata(user, mint, base_program) + + held = await client.get_token_account_balance(user_ata) + if amount_str.lower() == "all": + token_amount = held + else: + token_amount = to_raw(amount_str, base_decimals) + if held < token_amount: + return { + "error": "insufficient_balance", + "message": ( + f"Insufficient token balance. Have {held / 10**base_decimals:.6f}" + f" tokens, need {token_amount / 10**base_decimals:.6f} tokens." + ), + "available_tokens": held / 10**base_decimals, + "required_tokens": token_amount / 10**base_decimals, + } + if token_amount <= 0: + return {"error": "no_tokens", "message": "No tokens to sell."} + + quote_out = sell_amount_out( + market, token_amount, market["fee_rate"], market["transfer_fee"] + ) + if quote_out <= 0: + # The program refuses a sell that pays nothing. + return { + "error": "dust", + "message": "Too few tokens: the curve would pay nothing for them.", + } + min_quote_out = max(1, quote_out * (100 - slippage) // 100) + + if dry_run: + spot = spot_price(market) + tokens_human = token_amount / 10**base_decimals + effective_price = quote_out / 10**quote_decimals / tokens_human + return { + "dry_run": True, + "action": "sell", + "mint": mint_str, + **_market_fields(market), + **amount_fields("tokens_in", token_amount, base_decimals), + **amount_fields("expected_quote", quote_out, quote_decimals), + "effective_price": effective_price, + "spot_price": spot, + "price_impact_pct": round((effective_price - spot) / spot * 100, 4) + if spot > 0 + else 0, + **amount_fields("min_quote_out", min_quote_out, quote_decimals), + "slippage_pct": slippage, + } + + quote_account, open_ixs, close_ixs = quote_settlement_account( + user, quote_mint, market["quote_token_program"] + ) + ixs = [ + *open_ixs, + build_launchlab_swap_instruction( + buy=False, + pool_address=market["address"], + pool=market, + user=user, + user_base_token=user_ata, + user_quote_token=quote_account, + base_token_program=base_program, + quote_token_program=market["quote_token_program"], + amount_in=token_amount, + minimum_amount_out=min_quote_out, + ), + *close_ixs, + ] + try: + sig = await client.send_tx( + ixs, + [keypair], + confirm=confirm, + priority_fee=priority_fee if priority_fee is not None else DEFAULT_PRIORITY_FEE, + compute_units=compute_units + if compute_units is not None + else LAUNCHLAB_COMPUTE_UNITS, + ) + except TransactionFailedError as exc: + return _handle_tx_error(exc, LAUNCHLAB_SLIPPAGE_ERROR_CODES) + result = { + "action": "sell", + "mint": mint_str, + **_market_fields(market), + **amount_fields("tokens_sold", token_amount, base_decimals), + "quote_received": quote_out / 10**quote_decimals, + "signature": sig, + "explorer": f"https://solscan.io/tx/{sig}", + } + if confirm: + result["confirmed"] = True + return result + except (RuntimeError, ValueError) as exc: + return {"error": "launchlab_error", "message": str(exc)} + finally: + await client.close() + + +async def get_launchlab_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) -> dict: + """On-chain info for a LaunchLab coin: price, progress, reserves, fees.""" + mint = parse_pubkey(mint_str, "mint address") + if mint is None: + return invalid_pubkey_error(mint_str, "mint address") + client = RpcClient(rpc_url, timeout=rpc_timeout) + try: + market = await _load_market(client, mint) + if market is None: + return _not_found(mint_str) + quote_decimals = market["quote_decimals"] + progress = ( + market["real_base"] / market["total_base_sell"] if market["total_base_sell"] else 0.0 + ) + graduated = market["status"] != STATUS_TRADING + result = { + "mint": mint_str, + **_market_fields(market), + "platform_config": str(market["platform_config"]), + "launchlab_pool": str(market["address"]), + "price": spot_price(market), + "bonding_progress": round(min(progress, 1.0) * 100, 1), + "graduated": graduated, + "real_quote_reserves": market["real_quote"] / 10**quote_decimals, + "creator": str(market["creator"]), + "curve_fee_pct": market["fee_rate"] * 100 / FEE_RATE_DENOMINATOR, + "transfer_fee_bps": transfer_fee_bps(market["transfer_fee"]), + } + if graduated: + result["migrate_type"] = _graduated(market)["migrate_type"] + return result + except (RuntimeError, ValueError) as exc: + return {"error": "launchlab_error", "message": str(exc)} + finally: + await client.close() diff --git a/src/pumpfun_cli/core/pumpswap.py b/src/pumpfun_cli/core/pumpswap.py index 4657207..de1e0e9 100644 --- a/src/pumpfun_cli/core/pumpswap.py +++ b/src/pumpfun_cli/core/pumpswap.py @@ -39,6 +39,7 @@ build_pumpswap_sell_instructions, ) from pumpfun_cli.protocol.pumpswap import ( + PoolNotFoundError, get_global_config, get_pool_balances, get_pool_by_mint, @@ -235,6 +236,9 @@ async def buy_pumpswap( if confirm: result["confirmed"] = True return result + except PoolNotFoundError as exc: + # Same error as before, marked so a caller can tell "no pool" apart. + return {"error": "pumpswap_error", "message": str(exc), "pool_missing": True} except (RuntimeError, ValueError) as exc: return {"error": "pumpswap_error", "message": str(exc)} finally: @@ -359,6 +363,9 @@ async def sell_pumpswap( if confirm: result["confirmed"] = True return result + except PoolNotFoundError as exc: + # Same error as before, marked so a caller can tell "no pool" apart. + return {"error": "pumpswap_error", "message": str(exc), "pool_missing": True} except (RuntimeError, ValueError) as exc: return {"error": "pumpswap_error", "message": str(exc)} finally: @@ -387,6 +394,9 @@ async def get_pumpswap_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30 "mayhem_mode": pool["is_mayhem_mode"], "cashback": pool["is_cashback_coin"], } + except PoolNotFoundError as exc: + # Same error as before, marked so a caller can tell "no pool" apart. + return {"error": "pumpswap_error", "message": str(exc), "pool_missing": True} except (RuntimeError, ValueError) as exc: return {"error": "pumpswap_error", "message": str(exc)} finally: diff --git a/src/pumpfun_cli/core/trade.py b/src/pumpfun_cli/core/trade.py index a0b789a..6405a9c 100644 --- a/src/pumpfun_cli/core/trade.py +++ b/src/pumpfun_cli/core/trade.py @@ -41,6 +41,7 @@ calculate_buy_tokens_out, calculate_sell_quote_out, get_token_price, + max_sell_within_real_reserves, parse_bonding_curve, ) from pumpfun_cli.protocol.instructions import ( @@ -62,6 +63,8 @@ ) DEFAULT_PRIORITY_FEE = 200_000 +# pump.fun NotEnoughTokensToBuy: the buy asks for more tokens than the curve has left. +_NOT_ENOUGH_TOKENS_TO_BUY = 6021 def _estimate_buy_required_lamports( @@ -250,6 +253,19 @@ async def buy_token( compute_units=eff_compute_units, ) except TransactionFailedError as exc: + if exc.error_code == _NOT_ENOUGH_TOKENS_TO_BUY: + left = state["real_token_reserves"] / 10**TOKEN_DECIMALS + return { + "error": "exceeds_curve", + "message": ( + f"The buy asks for more than the {left:.6f} tokens left on the" + f" bonding curve. Spend less {symbol}." + ), + "error_code": exc.error_code, + **amount_fields( + "curve_tokens_left", state["real_token_reserves"], TOKEN_DECIMALS + ), + } return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES) received = await confirmed_token_delta(client, sig, user, mint) if confirm else None return _tx_result( @@ -324,6 +340,20 @@ async def sell_token( if token_amount <= 0: return {"error": "no_tokens", "message": "No tokens to sell."} + # The curve pays a sell out of its real reserves; sell what they cover. + sellable = max_sell_within_real_reserves(state, token_amount) + if sellable == 0: + return { + "error": "curve_short", + "message": ( + "The bonding curve's real reserves can't pay anything for these" + " tokens now. Nothing was sent." + ), + } + kept = token_amount - sellable + token_amount = sellable + partial = amount_fields("tokens_kept", kept, TOKEN_DECIMALS) if kept else {} + quote_out = calculate_sell_quote_out(state, token_amount) min_quote_out = max(1, int(quote_out * (1 - slippage / 100))) @@ -349,6 +379,7 @@ async def sell_token( "price_impact_pct": round(price_impact, 4), **amount_fields("min_quote_out", min_quote_out, quote_decimals), "slippage_pct": slippage, + **partial, } ixs = [] @@ -397,6 +428,7 @@ async def sell_token( quote_symbol=symbol, **amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS), quote_received=quote_out / 10**quote_decimals, + **partial, ) finally: await client.close() diff --git a/src/pumpfun_cli/protocol/address.py b/src/pumpfun_cli/protocol/address.py index 5532cf2..b905c38 100644 --- a/src/pumpfun_cli/protocol/address.py +++ b/src/pumpfun_cli/protocol/address.py @@ -149,6 +149,12 @@ def derive_amm_pool(mint: Pubkey, quote_mint: Pubkey = WSOL_MINT) -> Pubkey: return addr +def derive_amm_boost_vault_authority(pool: Pubkey) -> Pubkey: + """Derive a PumpSwap pool's boost vault authority PDA.""" + addr, _ = Pubkey.find_program_address([b"boost_vault", bytes(pool)], PUMP_AMM_PROGRAM) + return addr + + def derive_amm_lp_mint(pool: Pubkey) -> Pubkey: """Derive a PumpSwap pool's LP mint.""" addr, _ = Pubkey.find_program_address([b"pool_lp_mint", bytes(pool)], PUMP_AMM_PROGRAM) diff --git a/src/pumpfun_cli/protocol/cpmm.py b/src/pumpfun_cli/protocol/cpmm.py new file mode 100644 index 0000000..34539c1 --- /dev/null +++ b/src/pumpfun_cli/protocol/cpmm.py @@ -0,0 +1,162 @@ +"""Raydium CPMM, where a LaunchLab coin trades once its curve graduates. + +The pool is derived, not searched for: CPMM keys it by an AMM config and the +two mints, and a LaunchLab platform names its AMM config in `cpswap_config`. +Both trade directions are `swap_base_input`, so a buy spends exactly its input. +""" + +import hashlib +import struct + +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey + +CPMM_PROGRAM = Pubkey.from_string("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C") +CPMM_AUTHORITY = Pubkey.find_program_address([b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM)[0] +SWAP_BASE_INPUT_DISCRIMINATOR = hashlib.sha256(b"global:swap_base_input").digest()[:8] +POOL_STATE_DISCRIMINATOR = hashlib.sha256(b"account:PoolState").digest()[:8] + +# ExceededSlippage. +CPMM_SLIPPAGE_ERROR_CODES: set[int] = {6005} + +FEE_RATE_DENOMINATOR = 1_000_000 + +# PoolState: discriminator, ten pubkeys, five u8 (auth_bump, status, +# lp_mint_decimals, mint_0_decimals, mint_1_decimals), seven u64 (lp_supply, +# protocol_fees_token_0/1, fund_fees_token_0/1, open_time, recent_epoch), two u8 +# (creator_fee_on, enable_creator_fee), six bytes of padding, then the creator +# fees owed in each token. +_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, +# protocol, fund and create-pool fees, 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 + + +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 parse_cpmm_pool(data: bytes, base_mint: Pubkey) -> dict: + """Decode a PoolState, oriented so `base_*` is the coin and `quote_*` the other side. + + `base_fees` / `quote_fees` are what the vaults hold that is not liquidity: + protocol, fund and creator fees not yet claimed. + + Raises: + ValueError: If the data is not a PoolState or the pool doesn't trade `base_mint` + """ + if len(data) < _POOL_CREATOR_FEES_OFFSET + 16 or data[:8] != POOL_STATE_DISCRIMINATOR: + raise ValueError("Not a Raydium CPMM pool account") # noqa: TRY003 + 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 does not trade {base_mint}") # noqa: TRY003 + base = 0 if keys["token_0_mint"] == base_mint else 1 + quote = 1 - base + p0, p1, f0, f1 = struct.unpack_from("<4Q", data, _POOL_FEES_OFFSET) + c0, c1 = struct.unpack_from("<2Q", data, _POOL_CREATOR_FEES_OFFSET) + fees = (p0 + f0 + c0, p1 + f1 + c1) + decimals = data[_POOL_DECIMALS_OFFSET : _POOL_DECIMALS_OFFSET + 2] + return { + "amm_config": keys["amm_config"], + "observation": keys["observation_key"], + "base_mint": base_mint, + "quote_mint": keys[f"token_{quote}_mint"], + "base_vault": keys[f"token_{base}_vault"], + "quote_vault": keys[f"token_{quote}_vault"], + "base_token_program": keys[f"token_{base}_program"], + "quote_token_program": keys[f"token_{quote}_program"], + "base_decimals": decimals[base], + "quote_decimals": decimals[quote], + "base_fees": fees[base], + "quote_fees": fees[quote], + "creator_fee_enabled": bool(data[_POOL_CREATOR_FEE_FLAG_OFFSET]), + } + + +def parse_cpmm_fee_rate(amm_config: bytes, creator_fee_enabled: bool) -> int: + """Total swap fee in parts per million: the trade fee, plus the creator fee if on.""" + trade = struct.unpack_from(" int: + """Constant-product output for `amount_in`, with the fee taken off the input. + + An estimate for sizing the slippage floor: the program may take the + creator fee from either side. + """ + net_in = amount_in - -(-amount_in * fee_rate // FEE_RATE_DENOMINATOR) + return reserve_out * net_in // (reserve_in + net_in) + + +def build_cpmm_swap_instruction( + *, + buy: bool, + pool_address: Pubkey, + pool: dict, + user: Pubkey, + user_base_token: Pubkey, + user_quote_token: Pubkey, + amount_in: int, + minimum_amount_out: int, +) -> Instruction: + """swap_base_input of (amount_in, minimum_amount_out), input side first. + + The program reads the input token's account, vault, token program and mint + before the output token's: a buy inputs the quote, a sell the coin. + """ + base = (user_base_token, pool["base_vault"], pool["base_token_program"], pool["base_mint"]) + quote = ( + user_quote_token, + pool["quote_vault"], + pool["quote_token_program"], + pool["quote_mint"], + ) + (in_user, in_vault, in_program, in_mint), (out_user, out_vault, out_program, out_mint) = ( + (quote, base) if buy else (base, quote) + ) + + def meta(pubkey: Pubkey, writable: bool = False, signer: bool = False) -> AccountMeta: + return AccountMeta(pubkey, is_signer=signer, is_writable=writable) + + accounts = [ + meta(user, writable=True, signer=True), + meta(CPMM_AUTHORITY), + meta(pool["amm_config"]), + meta(pool_address, writable=True), + meta(in_user, writable=True), + meta(out_user, writable=True), + meta(in_vault, writable=True), + meta(out_vault, writable=True), + meta(in_program), + meta(out_program), + meta(in_mint), + meta(out_mint), + meta(pool["observation"], writable=True), + ] + data = SWAP_BASE_INPUT_DISCRIMINATOR + struct.pack(" int: ) +def max_sell_within_real_reserves(state: dict, token_amount: int) -> int: + """The largest part of `token_amount` the curve has the quote to pay for. + + The program prices a sell on the virtual reserves but pays it, fees + included, out of `real_quote_reserves`, and a payout above that reverts. + On a mayhem-mode coin the two drift apart, so the curve can hold far less + than its price promises. Returns `token_amount` when the curve can pay for + all of it, else the most raw tokens whose payout still fits, and 0 when + the payout would be nothing either way. + """ + virtual_tokens = state["virtual_token_reserves"] + virtual_quote = state["virtual_quote_reserves"] + real_quote = state["real_quote_reserves"] + payout = calculate_sell_quote_out(state, token_amount) + if payout <= real_quote: + # A sell paid nothing reverts on its floor of at least one unit. + return token_amount if payout > 0 else 0 + # The payout rounds down, so it fits while + # amount * virtual_quote < (real_quote + 1) * (virtual_tokens + amount). + # virtual_quote > real_quote + 1 here, or the full sell would have fit. + bound = (real_quote + 1) * virtual_tokens - 1 + sellable = min(token_amount, bound // (virtual_quote - real_quote - 1)) + return sellable if calculate_sell_quote_out(state, sellable) > 0 else 0 + + def get_token_price(state: dict, quote_decimals: int) -> float: """Current price in whole quote units per whole token.""" if state["virtual_token_reserves"] == 0: diff --git a/src/pumpfun_cli/protocol/instructions.py b/src/pumpfun_cli/protocol/instructions.py index 668d6c9..f50df67 100644 --- a/src/pumpfun_cli/protocol/instructions.py +++ b/src/pumpfun_cli/protocol/instructions.py @@ -20,6 +20,7 @@ from spl.token.models import CloseAccountParams, InitializeAccountParams from pumpfun_cli.protocol.address import ( + derive_amm_boost_vault_authority, derive_amm_creator_vault, derive_amm_fee_config, derive_amm_global_volume_accumulator, @@ -654,12 +655,15 @@ def build_migrate_v2_instruction( ) -> Instruction: """Move a complete curve's liquidity into its canonical PumpSwap pool. - `withdraw_authority` is read from pump.fun's Global account. + `withdraw_authority` is read from pump.fun's Global account. After the IDL's + 27 accounts come the new pool's boost vault authority and its quote token + account: PumpSwap creates the boost vault during the migration. """ bonding_curve = derive_bonding_curve(mint) pool_authority = derive_pool_authority(mint) pool = derive_amm_pool(mint, quote_mint) lp_mint = derive_amm_lp_mint(pool) + boost_vault_authority = derive_amm_boost_vault_authority(pool) return Instruction( PUMP_PROGRAM, MIGRATE_V2_DISCRIMINATOR, @@ -691,5 +695,10 @@ def build_migrate_v2_instruction( _meta(RENT), _meta(PUMP_EVENT_AUTHORITY), _meta(PUMP_PROGRAM), + _meta(boost_vault_authority), + _meta( + find_ata(boost_vault_authority, quote_mint, quote_token_program), + writable=True, + ), ], ) diff --git a/src/pumpfun_cli/protocol/launchlab.py b/src/pumpfun_cli/protocol/launchlab.py new file mode 100644 index 0000000..1291527 --- /dev/null +++ b/src/pumpfun_cli/protocol/launchlab.py @@ -0,0 +1,296 @@ +"""Raydium LaunchLab: addresses, account layouts, curve math and swap instructions. + +LaunchLab is one program serving many launchpads (letsbonk.fun, StonkFun and +others); a launchpad is a set of platform configs its pools are created under. +Nothing here depends on which: every account a trade needs comes from the pool. + +All amounts are raw integer units with the program's own rounding: fees round +up, curve outputs round down. +""" + +import struct + +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey + +from pumpfun_cli.protocol.contracts import SYSTEM_PROGRAM + +LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj") + +BUY_EXACT_IN_DISCRIMINATOR = bytes([250, 234, 13, 123, 213, 156, 19, 236]) +SELL_EXACT_IN_DISCRIMINATOR = bytes([149, 39, 222, 155, 211, 124, 152, 26]) +POOL_STATE_DISCRIMINATOR = bytes([247, 237, 227, 245, 215, 195, 222, 70]) + +# ExceededSlippage. +LAUNCHLAB_SLIPPAGE_ERROR_CODES: set[int] = {6004} + +# A trade sends no front-end share fee. +SHARE_FEE_RATE = 0 + +# Fee rates on GlobalConfig and PlatformConfig are parts per million. +FEE_RATE_DENOMINATOR = 1_000_000 +_BASIS_POINTS = 10_000 + +# PoolState: discriminator, epoch, five u8 (auth_bump, status, base_decimals, +# quote_decimals, migrate_type), ten u64 amounts, a five-u64 vesting schedule, +# then seven pubkeys from global_config on. +POOL_STATE_SIZE = 429 +_POOL_U8_OFFSET = 16 +_POOL_AMOUNTS_OFFSET = 21 +_POOL_KEYS_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 +_POOL_KEYS = ( + "global_config", + "platform_config", + "base_mint", + "quote_mint", + "base_vault", + "quote_vault", + "creator", +) +POOL_BASE_MINT_OFFSET = _POOL_KEYS_OFFSET + 2 * 32 + +# GlobalConfig: discriminator, epoch u64, curve_type u8, index u16, migrate_fee u64. +_GLOBAL_CURVE_TYPE_OFFSET = 16 +_GLOBAL_TRADE_FEE_RATE_OFFSET = 8 + 8 + 1 + 2 + 8 +# The curve math here is for constant-product curves; LaunchLab also defines +# fixed-price (1) and linear (2) ones. +CONSTANT_PRODUCT_CURVE = 0 +# PlatformConfig: discriminator, epoch, two wallets, three u64 scales, fee_rate, +# name[64], web[256], img[256], cpswap_config, creator_fee_rate. +_PLATFORM_FEE_RATE_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 +_PLATFORM_NAME_OFFSET = _PLATFORM_FEE_RATE_OFFSET + 8 +PLATFORM_CPSWAP_CONFIG_OFFSET = _PLATFORM_NAME_OFFSET + 64 + 256 + 256 +_PLATFORM_CREATOR_FEE_RATE_OFFSET = PLATFORM_CPSWAP_CONFIG_OFFSET + 32 + +# Token-2022 mint: 82-byte base mint, padding to 165, one account-type byte, +# then type-length-value extensions. TransferFeeConfig holds two authorities +# and a withheld amount (72 bytes), then the older and newer fees, each +# (epoch u64, maximum_fee u64, basis_points u16). +_EXTENSIONS_OFFSET = 166 +_TRANSFER_FEE_CONFIG = 1 +_OLDER_FEE_OFFSET = 72 +_NEWER_FEE_OFFSET = 90 + +# Pool status: 0 trades on the curve, 1 waits for migration, 2 has migrated. +STATUS_TRADING = 0 +# PoolState.migrate_type: 0 migrates to Raydium AMM v4, 1 to CPMM. +MIGRATE_TO_CPMM = 1 + + +def _pda(*seeds: bytes) -> Pubkey: + return Pubkey.find_program_address(list(seeds), LAUNCHLAB_PROGRAM)[0] + + +def derive_launchlab_pool(base_mint: Pubkey, quote_mint: Pubkey) -> Pubkey: + """The pool for a base/quote pair.""" + return _pda(b"pool", bytes(base_mint), bytes(quote_mint)) + + +def derive_launchlab_authority() -> Pubkey: + """The PDA that signs for every pool vault.""" + return _pda(b"vault_auth_seed") + + +def derive_launchlab_event_authority() -> Pubkey: + return _pda(b"__event_authority") + + +def derive_platform_fee_vault(platform_config: Pubkey, quote_mint: Pubkey) -> Pubkey: + """Where a platform's share of curve fees accrues, per quote mint.""" + return _pda(bytes(platform_config), bytes(quote_mint)) + + +def derive_creator_fee_vault(creator: Pubkey, quote_mint: Pubkey) -> Pubkey: + """Where a creator's share of curve fees accrues, per quote mint.""" + return _pda(bytes(creator), bytes(quote_mint)) + + +def parse_pool_state(data: bytes) -> dict: + """Decode a PoolState account. + + Raises: + ValueError: If the data is not a PoolState + """ + if len(data) < POOL_STATE_SIZE or data[:8] != POOL_STATE_DISCRIMINATOR: + raise ValueError("Not a LaunchLab pool account") # noqa: TRY003 + status, base_decimals, quote_decimals, migrate_type = data[ + _POOL_U8_OFFSET + 1 : _POOL_U8_OFFSET + 5 + ] + ( + supply, + total_base_sell, + virtual_base, + virtual_quote, + real_base, + real_quote, + total_quote_fund_raising, + ) = struct.unpack_from("<7Q", data, _POOL_AMOUNTS_OFFSET) + pool = { + "status": status, + "base_decimals": base_decimals, + "quote_decimals": quote_decimals, + "migrate_type": migrate_type, + "supply": supply, + "total_base_sell": total_base_sell, + "virtual_base": virtual_base, + "virtual_quote": virtual_quote, + "real_base": real_base, + "real_quote": real_quote, + "total_quote_fund_raising": total_quote_fund_raising, + } + for i, name in enumerate(_POOL_KEYS): + start = _POOL_KEYS_OFFSET + 32 * i + pool[name] = Pubkey.from_bytes(data[start : start + 32]) + return pool + + +def curve_type(global_config: bytes) -> int: + """GlobalConfig.curve_type: 0 constant product, 1 fixed price, 2 linear.""" + return global_config[_GLOBAL_CURVE_TYPE_OFFSET] + + +def parse_fee_rate(global_config: bytes, platform_config: bytes) -> int: + """Total curve fee in parts per million: trade, platform and creator fees.""" + return ( + struct.unpack_from(" str: + """The launchpad's name as its PlatformConfig stores it.""" + raw = platform_config[_PLATFORM_NAME_OFFSET : _PLATFORM_NAME_OFFSET + 64] + return raw.split(b"\0", 1)[0].decode("utf-8", errors="replace").strip() + + +def mint_transfer_fee(mint_data: bytes) -> tuple[tuple[int, int], ...]: + """A Token-2022 mint's scheduled transfer fees, each (basis points, maximum fee raw). + + Both the older and the newer fee: a change only applies two epochs after it + is set, and `withheld_fee` charges whichever withholds more, so an amount is + never overestimated without reading the epoch. Empty for a mint without + the extension. + """ + offset = _EXTENSIONS_OFFSET + while offset + 4 <= len(mint_data): + kind, length = struct.unpack_from(" int: + """The highest scheduled rate, for display.""" + return max((bps for bps, _ in fees), default=0) + + +def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def withheld_fee(amount: int, fees: tuple[tuple[int, int], ...]) -> int: + """What Token-2022 withholds from a transfer of `amount`: rounded up, capped, + and the larger of the scheduled fees.""" + return max( + (min(_ceil_div(amount * bps, _BASIS_POINTS), cap) for bps, cap in fees if bps), + default=0, + ) + + +def _reserves(pool: dict) -> tuple[int, int]: + """The (base, quote) reserves the curve trades against. + + The virtual reserves are fixed at launch; the real ones record every trade. + """ + return pool["virtual_base"] - pool["real_base"], pool["virtual_quote"] + pool["real_quote"] + + +def spot_price(pool: dict) -> float: + """Whole quote units per whole token at the current point on the curve.""" + base, quote = _reserves(pool) + if base <= 0 or quote <= 0: + return 0.0 + return (quote / 10 ** pool["quote_decimals"]) / (base / 10 ** pool["base_decimals"]) + + +def buy_amount_out( + pool: dict, amount_in: int, fee_rate: int, transfer_fee: tuple[tuple[int, int], ...] = () +) -> int: + """Raw tokens that reach the buyer for exactly `amount_in` raw quote. + + The curve fee comes off the input, the rest trades on the curve, and the + coin's transfer fee is withheld on delivery. + """ + net_in = amount_in - _ceil_div(amount_in * fee_rate, FEE_RATE_DENOMINATOR) + base, quote = _reserves(pool) + out = base * net_in // (quote + net_in) + return out - withheld_fee(out, transfer_fee) + + +def sell_amount_out( + pool: dict, amount_in: int, fee_rate: int, transfer_fee: tuple[tuple[int, int], ...] = () +) -> int: + """Raw quote paid for sending `amount_in` raw tokens. + + The transfer fee is withheld on the way into the pool, the curve trades on + what arrives, and the curve fee comes off the quote. + """ + arrived = amount_in - withheld_fee(amount_in, transfer_fee) + base, quote = _reserves(pool) + gross = quote * arrived // (base + arrived) + return gross - _ceil_div(gross * fee_rate, FEE_RATE_DENOMINATOR) + + +def build_launchlab_swap_instruction( + *, + buy: bool, + pool_address: Pubkey, + pool: dict, + user: Pubkey, + user_base_token: Pubkey, + user_quote_token: Pubkey, + base_token_program: Pubkey, + quote_token_program: Pubkey, + amount_in: int, + minimum_amount_out: int, +) -> Instruction: + """buy_exact_in (spend exactly `amount_in` quote) or sell_exact_in. + + The IDL's 15 accounts, then three the program reads positionally: the + system program and the platform and creator fee vaults. + """ + quote_mint = pool["quote_mint"] + + def meta(pubkey: Pubkey, writable: bool = False, signer: bool = False) -> AccountMeta: + return AccountMeta(pubkey, is_signer=signer, is_writable=writable) + + accounts = [ + meta(user, writable=True, signer=True), + meta(derive_launchlab_authority()), + meta(pool["global_config"]), + meta(pool["platform_config"]), + meta(pool_address, writable=True), + meta(user_base_token, writable=True), + meta(user_quote_token, writable=True), + meta(pool["base_vault"], writable=True), + meta(pool["quote_vault"], writable=True), + meta(pool["base_mint"]), + meta(quote_mint), + meta(base_token_program), + meta(quote_token_program), + meta(derive_launchlab_event_authority()), + meta(LAUNCHLAB_PROGRAM), + meta(SYSTEM_PROGRAM), + meta(derive_platform_fee_vault(pool["platform_config"], quote_mint), writable=True), + meta(derive_creator_fee_vault(pool["creator"], quote_mint), writable=True), + ] + discriminator = BUY_EXACT_IN_DISCRIMINATOR if buy else SELL_EXACT_IN_DISCRIMINATOR + data = discriminator + struct.pack(" tuple[Pubkey, bytes]: @@ -35,7 +39,8 @@ async def get_pool_by_mint( with a single getAccountInfo. Raises: - RuntimeError: If the pool does not exist or cannot be read + PoolNotFoundError: If the pool does not exist + RuntimeError: If the pool cannot be read """ pool_address = derive_amm_pool(mint, quote_mint) try: @@ -43,7 +48,7 @@ async def get_pool_by_mint( except Exception as exc: raise RuntimeError(f"Failed to fetch PumpSwap pool {pool_address}: {exc}") from exc if not resp.value: - raise RuntimeError(f"No PumpSwap pool found for {mint}") + raise PoolNotFoundError(f"No PumpSwap pool found for {mint}") return pool_address, bytes(resp.value.data) diff --git a/tests/fixtures/cpmm_sol_pool.json b/tests/fixtures/cpmm_sol_pool.json new file mode 100644 index 0000000..34bc513 --- /dev/null +++ b/tests/fixtures/cpmm_sol_pool.json @@ -0,0 +1,47 @@ +{ + "launchlab_pool": { + "address": "CwdAVEVv5uLq1SBH4kt2Gv4wy8xMg1VND5Yz3fMVEHtL", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "9+3j9dfD3kY0BAAAAAAAAPoCBgkBAIDGpH6NAwAAeMX7UdECAN50Dj7pzwMA168w/AYAAAAAeMX7UdECAFISZcoTAAAAABJlyhMAAABZX+gaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVxqOAcjfeCD51ms8c2W40eSvqBt4VMwu91zvWL0Ihn4v76UoyW8/kSO/XTQhGtcPshtjskAzEmFgU9mvfUhN3P+RBvaNKmDC7HFK//ho+L4uvNSUpTFcdIdYU+dTt+G/BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAGkgTCguOUywBZAeiYLy9qNehaJ3nuqt6oglDFz6BAM9yjUCbT9AdGTbK6V5MHYBBsyp2j01hEFqjO3pdQ44Wk2YVe5tizaNIJ7jLljrocmbAag1dlm/sadB6G0UIsEtK0BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "global_config": { + "address": "6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "lQicyqD8sNnBAwAAAAAAAAAAAAAAAAAAAAAAxAkAAAAAAAAQJwAAAAAAAICWmAAAAAAAADUMAAAAAABADQMAAAAAAPBJAgAAAAAAAPCClgUAAAAGm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQyz4e5t0cIQzcDSX4MHTIDK0v9FiO4+tNA3FD3gRgUiDLPevnctauoG6DOOMPSKGFjctO/P9tXw/LdYKgFdwIUGML1SYnBjHI8sW0M/s3jLoiH369IfbHsD8odEFvzLFAYwvGw1LgPHgfQBOLmbBdQ7GAZ6dG85H/e4gZH21Q8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "platform_config": { + "address": "4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "oE6AAPhT5qAEBAAAAAAAAD5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5Pk+EKn0LDJ7I4k6gOjHJ+HmjXcMEdYkaqu5dnrAIL/lAQg8AAAAAAAAAAAAAAAAAAAAAAAAAAAAQJwAAAAAAAFN0b25rRnVuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABodHRwczovL3d3dy5zdG9ua2Z1bi54eXoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaHR0cHM6Ly93d3cuc3RvbmtmdW4ueHl6L3N0b25rZnVuLWxvZ28ucG5nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKmyHxd+m5dujuEp9BrvN4SqxRSUkJWwAuYRfVnQNpaPAAAAAAAAAABALVyvepfFNJdljlYbJiPQ449TQPIIrdUKTvRUCwrLLj5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5AAAAAAAAAAA+T4QqfQsMnsjiTqA6Mcn4eaNdwwR1iRqq7l2esAgv+QABGv1mAdFtt7hdam3csaoALEDujVMj8YuY7QspWaQA7JUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "base_mint": { + "address": "JCdE6KcCEUcgmhiCQfXTL11524wSgqJCnGtYDA757q4a", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgb0LSH6NAwAGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP+RBvaNKmDC7HFK//ho+L4uvNSUpTFcdIdYU+dTt+G/EwClAAeDqBUmIQ+UoOjoTSCHADs6QmDSnOedrP4hpOp3H6Yo/5EG9o0qYMLscUr/+Gj4vi681JSlMVx0h1hT51O34b8IAAAATUlEVFVSRFMIAAAATUlEVFVSRFNFAAAAaHR0cHM6Ly9nYXRld2F5LmlyeXMueHl6LzdOVGdqZVRyUEp4WmllQ1VKclk4TEx1SlVidnhMZW55ODZrblEyanVSek5xAAAAAA==" + }, + "quote_mint": { + "address": "So11111111111111111111111111111111111111112", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "cpmm_pool": { + "address": "DHpFy3CM4cxQmoR14cMA3wA2V5SB9yHfVPiLZCYrfakt", + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "9+3j9dfD3kapsh8XfpuXbo7hKfQa7zeEqsUUlJCVsALmEX1Z0DaWjz5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/58nMJbJDiI0ZVdQ6YCg/R51j+amvvtFxk8s0sRNpLLdZaitd9lf31DZa3Iv0W11RIcLIPDs4BJ/4LXwenHX05JJZ6ZG24qBCzqY9OSBtL9OGH2fX/Swrv8cZApl7sHZ/ZBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAH/kQb2jSpgwuxxSv/4aPi+LrzUlKUxXHSHWFPnU7fhvwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBt324e51j94YQl285GzN2rYa/E2DuQ0n/r35KNihi/zd2mIczhOiLXJzD10SlCRVhm0r/W/YfskedwefW7MLUf0ACQkGJ1eYZ9ADAAAdBQAAAAAAAIo91gEAAAAAuhLVAwAAAAD9eEK5KQAAAHL46WoAAAAAOQQAAAAAAAABAQAAAAAAAMkVLQIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "amm_config": { + "address": "CRRS5ieQmBrZjWhcj99JuGrT5tyuWDaGAXLXLFjbAtjQ", + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "2vQhaMvLK2/+AAsAxAkAAAAAAADA1AEAAAAAAECcAAAAAAAAgNHwCAAAAAAF2xU6D/SH9pzeiQAf7ExGlbbUIV6iRLh71Otyh/hXqNcGSjj20XsRPl9m3PtjuHN//OJkSr8KALmLNDVtY4WDECcAAAAAAABQwwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "token_0_vault": { + "address": "HKRQKKmDJE84Jw7BrPaMMgcwPJTvVV95PyW5Pb681bTT", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAHrANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ0/9sM4EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAAAA4thYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "token_1_vault": { + "address": "76SV94YrMzKc4pN58sZkcetNjREckbEjRnSzKHYz2b6b", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "/5EG9o0qYMLscUr/+Gj4vi681JSlMVx0h1hT51O34b/rANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ7bpIjefHwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} \ No newline at end of file diff --git a/tests/fixtures/cpmm_transfer_fee_pool.json b/tests/fixtures/cpmm_transfer_fee_pool.json new file mode 100644 index 0000000..1dbae41 --- /dev/null +++ b/tests/fixtures/cpmm_transfer_fee_pool.json @@ -0,0 +1,47 @@ +{ + "launchlab_pool": { + "address": "6NSSzrWsj8zGuh3yUhy9ZoJecewkerdGwCFV9CvxBZvZ", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "9+3j9dfD3kYzBAAAAAAAAPoCBggBAIDGpH6NAwAAeMX7UdECABESDz7pzwMA6hhHDgAAAAAAeMX7UdECAN/RcygAAAAAjdBzKAAAAACudqoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZUuBMnaSvpe6ShxBVHBJ9QW7k9894v74AVhP+/AiVNNF5467uId8T+hDkEHLNibXaPT5YpV3S7SudH4Btb6sUvHAmJSaFvgQW8J7uKhNAM+8xvCDXuvflFRKme93cxEh2nTr1mD30RsTr8lhACv2hJdccE/EsYUfPvKMTye8hNLir5LGz0catS7vZqMebw07OYqP/9HghgUYAEyMcArE2ly/n4fj6aximodORqp2k84/gMj5ciCvhAGDZOC+4/W2RVH4AvFOwJMIx4QXdiv7dsuriNy/6k8dQhW7eCMQToBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "global_config": { + "address": "E2fd24QycuwszEK8WyU8CgtCfp5bUkTMenUthzgShJxA", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "lQicyqD8sNkABAAAAAAAAAAAAAAAAAAAAAAAxAkAAAAAAAAAAAAAAAAAAICWmAAAAAAAP0IPAAAAAAABAAAAAAAAAPBJAgAAAAAAAQAAAAAAAACHadOvWYPfRGxOvyWEAK/aEl1xwT8SxhR8+8oxPJ7yEwyz4e5t0cIQzcDSX4MHTIDK0v9FiO4+tNA3FD3gRgUiDLPevnctauoG6DOOMPSKGFjctO/P9tXw/LdYKgFdwIUGML1SYnBjHI8sW0M/s3jLoiH369IfbHsD8odEFvzLFAYwvGw1LgPHgfQBOLmbBdQ7GAZ6dG85H/e4gZH21Q8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "platform_config": { + "address": "6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "oE6AAPhT5qAXBAAAAAAAAD5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5Pk+EKn0LDJ7I4k6gOjHJ+HmjXcMEdYkaqu5dnrAIL/lAQg8AAAAAAAAAAAAAAAAAAAAAAAAAAAAQJwAAAAAAAFN0b25rRnVuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABodHRwczovL3d3dy5zdG9ua2Z1bi54eXoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaHR0cHM6Ly93d3cuc3RvbmtmdW4ueHl6L3N0b25rZnVuLWxvZ28ucG5nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKmyHxd+m5dujuEp9BrvN4SqxRSUkJWwAuYRfVnQNpaPAAAAAAAAAABALVyvepfFNJdljlYbJiPQ449TQPIIrdUKTvRUCwrLLj5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5AAAAAAAAAAA+T4QqfQsMnsjiTqA6Mcn4eaNdwwR1iRqq7l2esAgv+QABGv1mAdFtt7hdam3csaoALEDujVMj8YuY7QspWaQA7JUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "base_mint": { + "address": "66oamSkYoFAqEx5fv4vre1YXumFFkD2qSHLM89vjuuxX", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3/GpH6NAwAGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEvHAmJSaFvgQW8J7uKhNAM+8xvCDXuvflFRKme93cxEAQBsAEAtXK96l8U0l2WOVhsmI9Djj1NA8git1QpO9FQLCssuQC1cr3qXxTSXZY5WGyYj0OOPU0DyCK3VCk70VAsKyy59dbR9DQIAADMEAAAAAAAAAIDGpH6NAwBkADMEAAAAAAAAAIDGpH6NAwBkABMApAAHg6gVJiEPlKDo6E0ghwA7OkJg0pznnaz+IaTqdx+mKEvHAmJSaFvgQW8J7uKhNAM+8xvCDXuvflFRKme93cxEDAAAAOGRk+CsueqaoeCkiAwAAADhkZPgrLnqmqHgpIg8AAAAaHR0cHM6Ly9tZXRhZGF0YS5qN3RyYWNrZXIuaW8vbWV0YWRhdGEvN2RjMGI3OGI0NDg0NGFiZi5qc29uAAAAAA==" + }, + "quote_mint": { + "address": "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "AQAAAN22T4c56SXz3m1wLL1wawhxUxlvdxhZc9ZCpLvjIOG+XL/g1sQJAAAIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "cpmm_pool": { + "address": "gRiZY3skcDmVHgfJkg1vi2TDFV4JaqCboccSxdGmZyo", + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "9+3j9dfD3kapsh8XfpuXbo7hKfQa7zeEqsUUlJCVsALmEX1Z0DaWjz5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5CjrXGb9p3nphIZd/aUho52wNzdGjlxh8y+6ojahprEqE5fnons+aroiJ52PpD36maNwzQ8prdJXzLA6Qc6OwFC7wwy8JufP7iIjCMtH7W+Qjuod4ltQdVMIs+RLMRSI9S8cCYlJoW+BBbwnu4qE0Az7zG8INe69+UVEqZ73dzESHadOvWYPfRGxOvyWEAK/aEl1xwT8SxhR8+8oxPJ7yEwbd9uHudY/eGEJdvORszdq2GvxNg7kNJ/69+SjYoYv8Bt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKna+ZwLVbP+LEbqjGJYKiio4ZyKcWUEeN8bqkKKMaWI7/0ACQYIKNVhz1YAAACeB17SPAAAAGjjAwAAAAAAWMIMjTEAAABQiAEAAAAAAAsU6WoAAAAAOAQAAAAAAAACAQAAAAAAAAAAAAAAAAAA5AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "amm_config": { + "address": "CRRS5ieQmBrZjWhcj99JuGrT5tyuWDaGAXLXLFjbAtjQ", + "owner": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "data": "2vQhaMvLK2/+AAsAxAkAAAAAAADA1AEAAAAAAECcAAAAAAAAgNHwCAAAAAAF2xU6D/SH9pzeiQAf7ExGlbbUIV6iRLh71Otyh/hXqNcGSjj20XsRPl9m3PtjuHN//OJkSr8KALmLNDVtY4WDECcAAAAAAABQwwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "token_0_vault": { + "address": "gw7hpGrYwfDyKFwjfFHx9AkNnVyqQEW1rktJrrRHRaH", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "S8cCYlJoW+BBbwnu4qE0Az7zG8INe69+UVEqZ73dzETrANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ1Lz+pRLWwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgIACAAAAAAAAAAAAA==" + }, + "token_1_vault": { + "address": "9wnD5UXX2wuboghStTGLkguoVpknaM7UuhmNFaTTP1xF", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "h2nTr1mD30RsTr8lhACv2hJdccE/EsYUfPvKMTye8hPrANn1spK0IUrH0De01vBkULlkYA3zcwUrtehPL46aZ2+p7ggAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} \ No newline at end of file diff --git a/tests/fixtures/launchlab_sol_pool.json b/tests/fixtures/launchlab_sol_pool.json new file mode 100644 index 0000000..20ab6e6 --- /dev/null +++ b/tests/fixtures/launchlab_sol_pool.json @@ -0,0 +1,27 @@ +{ + "pool": { + "address": "XP3LGzJYkrpiUBjAZiAH11QJtEjL7Vt9uWtXoV2XEG3", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "9+3j9dfD3kaYAwAAAAAAAPoABgkBAIDGpH6NAwAAeMX7UdECAN50Dj7pzwMA168w/AYAAAA8qzUAAAAAAGQAAAAAAAAAABJlyhMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVxqOAcjfeCD51ms8c2W40eSvqBt4VMwu91zvWL0Ihn5IrYtx3pi/5aCibIBuTeneT/L9WQq7rh6o8vgnpyCvCQTYzotQZDh2cL2wP5NjB7hT6slnX+RdwT9t77KM6MKrBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAFK0FP1Zy4LhBrRbHebGLB+xMEA+nF22zrPeLXzW80wPi6I8VZslO0WC+iQVa5zww1+eMyX6xwOtxeY/HbemEmZAQbzImcAmCuqolIbmFNvoIJTG5XnlvkMat+/iuGhvBsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "global_config": { + "address": "6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "lQicyqD8sNnBAwAAAAAAAAAAAAAAAAAAAAAAxAkAAAAAAAAQJwAAAAAAAICWmAAAAAAAADUMAAAAAABADQMAAAAAAPBJAgAAAAAAAPCClgUAAAAGm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAQyz4e5t0cIQzcDSX4MHTIDK0v9FiO4+tNA3FD3gRgUiDLPevnctauoG6DOOMPSKGFjctO/P9tXw/LdYKgFdwIUGML1SYnBjHI8sW0M/s3jLoiH369IfbHsD8odEFvzLFAYwvGw1LgPHgfQBOLmbBdQ7GAZ6dG85H/e4gZH21Q8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "platform_config": { + "address": "5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "oE6AAPhT5qBjAwAAAAAAAAEG8yJnAJgrqqJSG5hTb6CCUxuV55b5DGrfv4rhobwbAQbzImcAmCuqolIbmFNvoIJTG5XnlvkMat+/iuGhvBtAQg8AAAAAAAAAAAAAAAAAAAAAAAAAAAAQJwAAAAAAAFNwb3RzLmZ1bgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABodHRwczovL3Nwb3RzLmZ1bi8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaHR0cHM6Ly9pcGZzLmlvL2lwZnMvUW1STTlHUUxZNVhybTd0aVNCbzJoZkF5NWs3ZFlXV3o1NW9KcGZNMW9ycUtHeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAODxkGrDXXr/nr20x291VPvcTU/b+b+o2kC9G0kcXeTliBMAAAAAAAABBvMiZwCYK6qiUhuYU2+gglMbleeW+Qxq37+K4aG8GwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "base_mint": { + "address": "KvNoK516th8oBKfYP9gQQhaUaPH77bnWjtSsUXsspot", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "AAAAAAeDqBUmIQ+UoOjoTSCHADs6QmDSnOedrP4hpOp3H6YoIwSRpH6NAwAGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "quote_mint": { + "address": "So11111111111111111111111111111111111111112", + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + } +} \ No newline at end of file diff --git a/tests/fixtures/launchlab_token2022_quote_pool.json b/tests/fixtures/launchlab_token2022_quote_pool.json new file mode 100644 index 0000000..da1810d --- /dev/null +++ b/tests/fixtures/launchlab_token2022_quote_pool.json @@ -0,0 +1,27 @@ +{ + "pool": { + "address": "9rEMpYv4KCBTzCRad82WgFRkJACwno4Hrw6CWzTeMuUg", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "9+3j9dfD3kY5BAAAAAAAAPoABggBAIDGpH6NAwAAeMX7UdECAM8wDz7pzwMARHhsWQAAAABWELyjHwABAHTh0R8AAAAAWCdc/QAAAADta48DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFHg74bXj0RNAHXQkbT4m9JKn3O3AZNwnG3zyaEmgj8lNF5467uId8T+hDkEHLNibXaPT5YpV3S7SudH4Btb6sVNeOt4FHpWIjABhwAsUdQJfb18aAgdnnxKlxzpzW8rbB+ilDhQP2leR9FZqlX/Trj+HPmo0Zv/BPXkRnfqatQrhsHJhP4+VYoPRq3isUIdkjCutRSaji7p7pFlD5KIKFNeTkKWe58wTv0L6Mtat2QuECSYo0aT9xIJ53rqlmZgUIKOnTj9kzL3mEILjSVc3QWbwvf/AqDZAEPerdpfmiqoDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "global_config": { + "address": "2NuVPU5ViAyQsZamVfJ1vU6Cp77WSzret4LtNTMiWFQ4", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "lQicyqD8sNn9AwAAAAAAAAAAAAAAAAAAAAAAxAkAAAAAAAAAAAAAAAAAAICWmAAAAAAAP0IPAAAAAAABAAAAAAAAAPBJAgAAAAAAAQAAAAAAAAAH6KUOFA/aV5H0VmqVf9OuP4c+ajRm/8E9eRGd+pq1Cgyz4e5t0cIQzcDSX4MHTIDK0v9FiO4+tNA3FD3gRgUiDLPevnctauoG6DOOMPSKGFjctO/P9tXw/LdYKgFdwIUGML1SYnBjHI8sW0M/s3jLoiH369IfbHsD8odEFvzLFAYwvGw1LgPHgfQBOLmbBdQ7GAZ6dG85H/e4gZH21Q8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "platform_config": { + "address": "6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt", + "owner": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj", + "data": "oE6AAPhT5qAXBAAAAAAAAD5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5Pk+EKn0LDJ7I4k6gOjHJ+HmjXcMEdYkaqu5dnrAIL/lAQg8AAAAAAAAAAAAAAAAAAAAAAAAAAAAQJwAAAAAAAFN0b25rRnVuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABodHRwczovL3d3dy5zdG9ua2Z1bi54eXoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaHR0cHM6Ly93d3cuc3RvbmtmdW4ueHl6L3N0b25rZnVuLWxvZ28ucG5nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKmyHxd+m5dujuEp9BrvN4SqxRSUkJWwAuYRfVnQNpaPAAAAAAAAAABALVyvepfFNJdljlYbJiPQ449TQPIIrdUKTvRUCwrLLj5PhCp9CwyeyOJOoDoxyfh5o13DBHWJGqruXZ6wCC/5AAAAAAAAAAA+T4QqfQsMnsjiTqA6Mcn4eaNdwwR1iRqq7l2esAgv+QABGv1mAdFtt7hdam3csaoALEDujVMj8YuY7QspWaQA7JUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "base_mint": { + "address": "6cSBEGvrLNDQvsoUZor1Lrh1G1LV1hSR2eiTt57Xv1VU", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAXa/YbH6NAwAGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFNeOt4FHpWIjABhwAsUdQJfb18aAgdnnxKlxzpzW8rbAQBsAAeDqBUmIQ+UoOjoTSCHADs6QmDSnOedrP4hpOp3H6YoQC1cr3qXxTSXZY5WGyYj0OOPU0DyCK3VCk70VAsKyy4k1WhszQAAABoEAAAAAAAAAIDGpH6NAwBkABoEAAAAAAAAAIDGpH6NAwBkABMAmgAHg6gVJiEPlKDo6E0ghwA7OkJg0pznnaz+IaTqdx+mKFNeOt4FHpWIjABhwAsUdQJfb18aAgdnnxKlxzpzW8rbCQAAAFB1c3N5Q29pbgUAAABQVVNTWTwAAABodHRwczovL21ldGFkYXRhLmo3dHJhY2tlci5pby9tZXRhZGF0YS9jNTczOWM2Y2NjNzA0YWM5Lmpzb24AAAAA" + }, + "quote_mint": { + "address": "Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh", + "owner": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "data": "AQAAAGVqQkIv6okUBqQZ0dHeCPQqhHlBtaGulevOYZrDFyk0bf0l3TcdAAAIAQEAAAD/3+wbzSzTg5PITaoIyRzA041nf/jQq3tdAz8A9zLMMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARIAQABD+fHuLje4B+pFy3TmAJcivsAJKWAm5ORVi0NeJKXpxgfopQ4UD9pXkfRWapV/064/hz5qNGb/wT15EZ36mrUKDAAgAEP58e4uN7gH6kXLdOYAlyK+wAkpYCbk5FWLQ14kpenGBgABAAEZADgABm9ZIlHMR3R4JaWa0UIupDVz9SjaXe4q94ErMU+ZReM/9BuswgPwPwj6oWoAAAAAqf6J1fcG8D8aACEA/9/sG80s04OTyE2qCMkcwNONZ3/40Kt7XQM/APcyzDAABABBAEP58e4uN7gH6kXLdOYAlyK+wAkpYCbk5FWLQ14kpenGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgBAAEP58e4uN7gH6kXLdOYAlyK+wAkpYCbk5FWLQ14kpenGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATAKYAQ/nx7i43uAfqRct05gCXIr7ACSlgJuTkVYtDXiSl6cYH6KUOFA/aV5H0VmqVf9OuP4c+ajRm/8E9eRGd+pq1Cg0AAABOVklESUEgeFN0b2NrBQAAAE5WREF4RAAAAGh0dHBzOi8veHN0b2Nrcy1tZXRhZGF0YS5iYWNrZWQuZmkvdG9rZW5zL1NvbGFuYS9OVkRBeC9tZXRhZGF0YS5qc29uAAAAAA==" + } +} \ No newline at end of file diff --git a/tests/test_commands/test_human_output.py b/tests/test_commands/test_human_output.py index e4fa205..d3999e7 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -52,6 +52,23 @@ def test_buy_and_sell_name_the_quote_asset(wallet): assert sold.exit_code == 0 and "4.500000 USDC" in sold.output +def test_a_capped_sell_says_what_it_kept(wallet): + with ( + _human("trade"), + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as sell, + ): + sell.return_value = { + "action": "sell", + "quote_symbol": "SOL", + "quote_received": 0.001, + "tokens_sold": 6.0, + "tokens_kept": 4.0, + "explorer": "x", + } + sold = runner.invoke(app, ["--rpc", "http://rpc", "sell", MINT, "all"]) + assert sold.exit_code == 0 and "Kept 4.00 tokens" in sold.output + + def test_dry_runs(wallet): common = { "dry_run": True, @@ -285,3 +302,34 @@ def test_tokens_listing_survives_an_rpc_failure(monkeypatch): assert result.exit_code == 0 assert table.called assert "Could not read prices from chain" in result.output + + +@pytest.mark.parametrize("graduated", [False, True]) +def test_info_for_a_launchlab_coin(wallet, graduated): + info = { + "mint": MINT, + "venue": "launchlab", + "platform": "StonkFun", + "quote_mint": MINT, + "quote_symbol": "USDC", + "launchlab_pool": "pool", + "price": 0.001, + "bonding_progress": 40.0, + "graduated": graduated, + "real_quote_reserves": 12.5, + "creator": "c", + "curve_fee_pct": 1.25, + "transfer_fee_bps": 100, + } + if graduated: + # A pool address without its reserves, as when the pool lookup failed. + info.update(migrate_type="cpmm", pool_address="cpmm-pool") + with ( + _human("info"), + patch("pumpfun_cli.commands.info.get_token_info", new_callable=AsyncMock) as get, + ): + get.return_value = info + shown = runner.invoke(app, ["--rpc", "http://rpc", "info", MINT]) + assert shown.exit_code == 0, shown.output + assert "StonkFun (Raydium LaunchLab)" in shown.output + assert "Transfer fee: 100 bps" in shown.output diff --git a/tests/test_commands/test_trade_cmd.py b/tests/test_commands/test_trade_cmd.py index c47eaf6..ec1e64d 100644 --- a/tests/test_commands/test_trade_cmd.py +++ b/tests/test_commands/test_trade_cmd.py @@ -3,6 +3,7 @@ import json from unittest.mock import AsyncMock, patch +import pytest from typer.testing import CliRunner from pumpfun_cli.cli import app @@ -1084,3 +1085,147 @@ def test_sell_graduated_fallback_sell_all(tmp_path, monkeypatch): # Verify "all" was passed through call_args = mock_pumpswap.call_args assert call_args[0][4] == "all" + + +@pytest.mark.parametrize( + "command,curve,launchlab,amount", + [ + ("buy", "buy_token", "buy_launchlab", "0.01"), + ("sell", "sell_token", "sell_launchlab", "all"), + ], +) +def test_a_coin_pump_fun_lacks_trades_on_launchlab( + tmp_path, monkeypatch, command, curve, launchlab, amount +): + """No pump.fun curve: the trade goes to the coin's LaunchLab curve, same arguments.""" + _setup_wallet(tmp_path, monkeypatch) + with ( + patch(f"pumpfun_cli.commands.trade.{curve}", new_callable=AsyncMock) as mock_curve, + patch(f"pumpfun_cli.commands.trade.{launchlab}", new_callable=AsyncMock) as mock_ll, + ): + mock_curve.return_value = {"error": "not_found", "message": "No bonding curve"} + mock_ll.return_value = {"action": command, "venue": "launchlab", "signature": "s"} + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", command, "--slippage", "7", _FAKE_MINT, amount], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["venue"] == "launchlab" + assert mock_ll.call_args.args[3:6] == (_FAKE_MINT, mock_curve.call_args.args[4], 7) + + +def test_a_coin_on_neither_venue_says_so(tmp_path, monkeypatch): + _setup_wallet(tmp_path, monkeypatch) + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_curve, + patch("pumpfun_cli.commands.trade.buy_launchlab", new_callable=AsyncMock) as mock_ll, + ): + mock_curve.return_value = {"error": "not_found", "message": "No bonding curve"} + mock_ll.return_value = {"error": "not_found", "message": "No LaunchLab pool"} + result = runner.invoke(app, ["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"]) + assert result.exit_code == 1 + assert "No pump.fun or LaunchLab coin" in result.output + + +def _route_patches(command: str) -> dict: + """A patch for every venue `command` can route to.""" + venues = { + "curve": f"{command}_token", + "amm": f"{command}_pumpswap", + "launchlab": f"{command}_launchlab", + "cpmm": f"{command}_cpmm", + } + return { + venue: patch(f"pumpfun_cli.commands.trade.{fn}", new_callable=AsyncMock) + for venue, fn in venues.items() + } + + +def _run_routed(tmp_path, monkeypatch, command, args, results): + _setup_wallet(tmp_path, monkeypatch) + patches = _route_patches(command) + mocks = {venue: p.start() for venue, p in patches.items()} + try: + for venue, mock in mocks.items(): + mock.return_value = results.get( + venue, {"error": "unexpected", "message": f"{venue} called"} + ) + out = runner.invoke(app, ["--json", "--rpc", "http://rpc", command, *args]) + finally: + for p in patches.values(): + p.stop() + return out, mocks + + +_OK = {"action": "buy", "venue": "cpmm", "signature": "s"} +_NOT_FOUND = {"error": "not_found", "message": "none"} +_NO_PUMPSWAP_POOL = { + "error": "pumpswap_error", + "message": "No PumpSwap pool found", + "pool_missing": True, +} + + +@pytest.mark.parametrize("command,amount", [("buy", "0.01"), ("sell", "all")]) +def test_a_graduated_launchlab_coin_trades_on_cpmm(tmp_path, monkeypatch, command, amount): + out, mocks = _run_routed( + tmp_path, + monkeypatch, + command, + [_FAKE_MINT, amount], + { + "curve": _NOT_FOUND, + "launchlab": {"error": "graduated", "message": "g", "migrate_type": "cpmm"}, + "cpmm": {**_OK, "action": command}, + }, + ) + assert out.exit_code == 0, out.output + assert json.loads(out.output)["venue"] == "cpmm" + mocks["amm"].assert_not_called() + + +def test_force_amm_on_a_launchlab_coin_goes_to_cpmm(tmp_path, monkeypatch): + out, mocks = _run_routed( + tmp_path, + monkeypatch, + "buy", + ["--force-amm", _FAKE_MINT, "0.01"], + {"amm": _NO_PUMPSWAP_POOL, "cpmm": _OK}, + ) + assert out.exit_code == 0, out.output + assert json.loads(out.output)["venue"] == "cpmm" + mocks["curve"].assert_not_called() + + +@pytest.mark.parametrize( + "cpmm_result", + [_NOT_FOUND, {"error": "cpmm_error", "message": "could not search LaunchLab pools"}], +) +def test_force_amm_keeps_pumpswaps_error_when_the_coin_is_not_on_launchlab( + tmp_path, monkeypatch, cpmm_result +): + out, _ = _run_routed( + tmp_path, + monkeypatch, + "buy", + ["--force-amm", _FAKE_MINT, "0.01"], + {"amm": _NO_PUMPSWAP_POOL, "cpmm": cpmm_result}, + ) + assert out.exit_code == 1 + assert "No PumpSwap pool" in out.output + + +@pytest.mark.parametrize( + "amm_result", + [ + {"action": "buy", "venue": "pumpswap", "signature": "s"}, + {"error": "slippage", "message": "slippage"}, + # Sent but unconfirmed: the signature in the message must reach the user. + {"error": "pumpswap_error", "message": "Sent abc but could not confirm it"}, + ], +) +def test_force_amm_on_a_pump_coin_never_tries_cpmm(tmp_path, monkeypatch, amm_result): + _, mocks = _run_routed( + tmp_path, monkeypatch, "buy", ["--force-amm", _FAKE_MINT, "0.01"], {"amm": amm_result} + ) + mocks["cpmm"].assert_not_called() diff --git a/tests/test_core/helpers.py b/tests/test_core/helpers.py index 13371e1..815333a 100644 --- a/tests/test_core/helpers.py +++ b/tests/test_core/helpers.py @@ -1,6 +1,9 @@ """Shared test helpers: real account layouts and an in-memory RPC client.""" +import base64 +import json import struct +from pathlib import Path from types import SimpleNamespace from solders.pubkey import Pubkey @@ -44,7 +47,7 @@ def build_curve_data( virtual_token: int = 1_000_000_000_000_000, virtual_quote: int = 30_000_000_000, real_token: int = 793_100_000_000_000, - real_quote: int = 0, + real_quote: int = 1_000_000_000, supply: int = 1_000_000_000_000_000, complete: bool = False, creator: Pubkey = CREATOR, @@ -206,6 +209,24 @@ async def get_minimum_balance_for_rent_exemption(self, size: int) -> int: async def get_token_account_balance(self, account: Pubkey) -> int: return self.token_balances.get(account, 0) + async def get_program_accounts(self, program_id: Pubkey, filters: list): + """Accounts owned by `program_id` that pass every dataSize/memcmp filter.""" + + def passes(data: bytes) -> bool: + for f in filters: + if isinstance(f, int): + if len(data) != f: + return False + elif data[f.offset : f.offset + 32] != bytes(Pubkey.from_string(f.bytes)): + return False + return True + + return [ + SimpleNamespace(pubkey=address, account=account) + for address, account in self.accounts.items() + if account.owner == program_id and passes(account.data) + ] + async def send_tx( self, instructions, signers, compute_units=100_000, priority_fee=200_000, confirm=False ): @@ -278,3 +299,21 @@ def add_pool( def add_quote_control(client: FakeClient, entries: dict[Pubkey, int]) -> None: client.add(find_quote_control(), build_quote_control_data(entries), owner=Pubkey.new_unique()) + + +FIXTURES = Path(__file__).parents[1] / "fixtures" + + +def add_launchlab_fixture(client: FakeClient, name: str) -> dict: + """Load a LaunchLab pool, its configs and mints as captured from mainnet. + + Returns each account's address by role (pool, base_mint, quote_mint, ...). + """ + bundle = json.loads((FIXTURES / name).read_text()) + for account in bundle.values(): + client.add( + Pubkey.from_string(account["address"]), + base64.b64decode(account["data"]), + owner=Pubkey.from_string(account["owner"]), + ) + return {role: Pubkey.from_string(account["address"]) for role, account in bundle.items()} diff --git a/tests/test_core/test_cpmm.py b/tests/test_core/test_cpmm.py new file mode 100644 index 0000000..0a80dce --- /dev/null +++ b/tests/test_core/test_cpmm.py @@ -0,0 +1,183 @@ +"""CPMM buys, sells and info for graduated LaunchLab coins, on mainnet accounts.""" + +from unittest.mock import patch + +import pytest +from solders.pubkey import Pubkey + +from pumpfun_cli.core.cpmm import buy_cpmm, get_cpmm_info, sell_cpmm +from pumpfun_cli.protocol.address import find_ata +from pumpfun_cli.protocol.client import TransactionFailedError +from pumpfun_cli.protocol.contracts import TOKEN_2022_PROGRAM, TOKEN_PROGRAM +from pumpfun_cli.protocol.cpmm import CPMM_PROGRAM, parse_cpmm_pool, swap_amount_out +from pumpfun_cli.protocol.launchlab import withheld_fee +from tests.test_core.helpers import FakeClient, add_launchlab_fixture + +SOL_FIXTURE = "cpmm_sol_pool.json" +FEE_FIXTURE = "cpmm_transfer_fee_pool.json" + + +def _patched(client: FakeClient): + return ( + patch("pumpfun_cli.core.cpmm.RpcClient", return_value=client), + patch("pumpfun_cli.core.launchlab.RpcClient", return_value=client), + ) + + +def _user(tmp_keystore) -> Pubkey: + from pathlib import Path + + from pumpfun_cli.crypto import decrypt_keypair + + return decrypt_keypair(Path(tmp_keystore), "testpass").pubkey() + + +def _chain(fixture: str) -> tuple[FakeClient, dict, dict]: + """The chain, each account by role, and the pool with its live reserves.""" + client = FakeClient() + roles = add_launchlab_fixture(client, fixture) + pool = parse_cpmm_pool(client.accounts[roles["cpmm_pool"]].data, roles["base_mint"]) + + def held(vault): + return int.from_bytes(client.accounts[vault].data[64:72], "little") + + pool["base_reserve"] = held(pool["base_vault"]) - pool["base_fees"] + pool["quote_reserve"] = held(pool["quote_vault"]) - pool["quote_fees"] + return client, roles, pool + + +async def _call(client, fn, *args, **kwargs): + a, b = _patched(client) + with a, b: + return await fn(*args, **kwargs) + + +def _swap(client: FakeClient): + ix = next(i for i in client.sent[0]["instructions"] if i.program_id == CPMM_PROGRAM) + data = bytes(ix.data) + return ix, int.from_bytes(data[8:16], "little"), int.from_bytes(data[16:24], "little") + + +async def test_buy_spends_exactly_the_amount(tmp_keystore): + client, roles, pool = _chain(SOL_FIXTURE) + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.01) + assert result["venue"] == "cpmm" and result["platform"] == "StonkFun" + ix, amount_in, min_out = _swap(client) + assert amount_in == 10_000_000 + expected = swap_amount_out(pool["quote_reserve"], pool["base_reserve"], amount_in, 12_500) + assert result["tokens_received"] == expected / 10**6 + assert min_out == expected * 85 // 100 + user = _user(tmp_keystore) + assert ix.accounts[5].pubkey == find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM) + + +async def test_a_transfer_fee_coin_bought_with_another_quote(tmp_keystore): + client, roles, pool = _chain(FEE_FIXTURE) + user = _user(tmp_keystore) + quote_ata = find_ata(user, roles["quote_mint"], TOKEN_PROGRAM) + client.token_balances[quote_ata] = 10**8 + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.5) + ix, amount_in, min_out = _swap(client) + assert amount_in == 5 * 10**7 # 8 decimals + gross = swap_amount_out(pool["quote_reserve"], pool["base_reserve"], amount_in, 12_500) + net = gross - withheld_fee(gross, ((100, 10**15),)) + assert result["tokens_received"] * 10**6 == pytest.approx(net, abs=1) + assert min_out == net * 85 // 100 + assert ix.accounts[4].pubkey == quote_ata + + +async def test_buy_needs_the_quote_asset(tmp_keystore): + client, roles, _ = _chain(FEE_FIXTURE) + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.5) + assert result["error"] == "insufficient_balance" + assert result["required_quote_raw"] == 5 * 10**7 + assert client.sent == [] + + +async def test_sell_withholds_the_transfer_fee_first(tmp_keystore): + client, roles, pool = _chain(FEE_FIXTURE) + user = _user(tmp_keystore) + client.token_balances[find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM)] = 10**12 + mint = str(roles["base_mint"]) + result = await _call(client, sell_cpmm, "http://rpc", tmp_keystore, "testpass", mint, "all") + ix, amount_in, min_out = _swap(client) + assert amount_in == 10**12 + arrived = 10**12 - withheld_fee(10**12, ((100, 10**15),)) + expected = swap_amount_out(pool["base_reserve"], pool["quote_reserve"], arrived, 12_500) + assert result["quote_received"] == expected / 10**8 + assert min_out == expected * 85 // 100 + # A sell inputs the coin: its account and vault come first. + assert ix.accounts[4].pubkey == find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM) + assert ix.accounts[6].pubkey == pool["base_vault"] + + +async def test_dust_is_refused(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + user = _user(tmp_keystore) + client.token_balances[find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM)] = 1 + mint = str(roles["base_mint"]) + result = await _call(client, sell_cpmm, "http://rpc", tmp_keystore, "testpass", mint, "all") + assert result["error"] == "dust" and client.sent == [] + + +def _set(client: FakeClient, address: Pubkey, offset: int, value: int) -> None: + data = bytearray(client.accounts[address].data) + data[offset] = value + client.accounts[address].data = bytes(data) + + +@pytest.mark.parametrize( + "status,migrate_type,error", + [(0, 1, "not_graduated"), (2, 0, "unsupported_venue")], +) +async def test_coins_without_a_cpmm_pool(tmp_keystore, status, migrate_type, error): + client, roles, _ = _chain(SOL_FIXTURE) + _set(client, roles["launchlab_pool"], 17, status) + _set(client, roles["launchlab_pool"], 20, migrate_type) + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.01) + assert result["error"] == error and client.sent == [] + + +async def test_a_pool_not_open_yet(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + del client.accounts[roles["cpmm_pool"]] + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.01) + assert result["error"] == "migrating" + + +async def test_slippage_is_reported(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + client.send_error = TransactionFailedError({"InstructionError": [3, {"Custom": 6005}]}) + mint = str(roles["base_mint"]) + result = await _call(client, buy_cpmm, "http://rpc", tmp_keystore, "testpass", mint, 0.01) + assert result["error"] == "slippage" + + +async def test_info(): + client, roles, pool = _chain(SOL_FIXTURE) + info = await _call(client, get_cpmm_info, "http://rpc", str(roles["base_mint"])) + assert info["pool_address"] == str(roles["cpmm_pool"]) + assert info["quote_reserves"] == pool["quote_reserve"] / 10**9 + assert info["pool_fee_pct"] == 1.25 + assert info["price"] == pytest.approx( + (pool["quote_reserve"] / 1e9) / (pool["base_reserve"] / 1e6) + ) + + +async def test_token_info_shows_the_cpmm_pool_of_a_graduated_coin(): + from pumpfun_cli.core.info import get_token_info + + client, roles, pool = _chain(SOL_FIXTURE) + a, b = _patched(client) + with a, b, patch("pumpfun_cli.core.info.RpcClient", return_value=client): + info = await get_token_info("http://rpc", str(roles["base_mint"])) + assert info["venue"] == "cpmm" and info["graduated"] is True + assert info["migrate_type"] == "cpmm" + assert info["launchlab_pool"] == str(roles["launchlab_pool"]) + assert info["pool_address"] == str(roles["cpmm_pool"]) + assert info["base_reserves"] == pool["base_reserve"] / 10**6 diff --git a/tests/test_core/test_info.py b/tests/test_core/test_info.py index 42b32c3..4f09210 100644 --- a/tests/test_core/test_info.py +++ b/tests/test_core/test_info.py @@ -22,12 +22,13 @@ def _patched(client): return ( patch("pumpfun_cli.core.info.RpcClient", return_value=client), patch("pumpfun_cli.core.pumpswap.RpcClient", return_value=client), + patch("pumpfun_cli.core.launchlab.RpcClient", return_value=client), ) async def _info(client, mint=MINT_STR): - a, b = _patched(client) - with a, b: + a, b, c = _patched(client) + with a, b, c: return await get_token_info("http://rpc", mint) diff --git a/tests/test_core/test_launchlab.py b/tests/test_core/test_launchlab.py new file mode 100644 index 0000000..95b1b16 --- /dev/null +++ b/tests/test_core/test_launchlab.py @@ -0,0 +1,244 @@ +"""LaunchLab buys, sells and info, against mainnet pool accounts held in memory.""" + +from unittest.mock import patch + +import pytest +from solders.pubkey import Pubkey + +from pumpfun_cli.core.info import get_token_info +from pumpfun_cli.core.launchlab import buy_launchlab, get_launchlab_info, sell_launchlab +from pumpfun_cli.protocol.address import find_ata +from pumpfun_cli.protocol.client import TransactionFailedError +from pumpfun_cli.protocol.contracts import ( + ASSOCIATED_TOKEN_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, +) +from pumpfun_cli.protocol.launchlab import ( + BUY_EXACT_IN_DISCRIMINATOR, + LAUNCHLAB_PROGRAM, + SELL_EXACT_IN_DISCRIMINATOR, + buy_amount_out, + parse_pool_state, + sell_amount_out, +) +from tests.test_core.helpers import FakeClient, add_launchlab_fixture + +SOL_FIXTURE = "launchlab_sol_pool.json" +T22_FIXTURE = "launchlab_token2022_quote_pool.json" + + +def _patched(client: FakeClient): + return patch("pumpfun_cli.core.launchlab.RpcClient", return_value=client) + + +def _user(tmp_keystore) -> Pubkey: + from pathlib import Path + + from pumpfun_cli.crypto import decrypt_keypair + + return decrypt_keypair(Path(tmp_keystore), "testpass").pubkey() + + +def _chain(fixture: str) -> tuple[FakeClient, dict, dict]: + client = FakeClient() + roles = add_launchlab_fixture(client, fixture) + pool = parse_pool_state(client.accounts[roles["pool"]].data) + return client, roles, pool + + +def _swap(sent: dict): + return next(i for i in sent["instructions"] if i.program_id == LAUNCHLAB_PROGRAM) + + +def _u64s(ix) -> tuple[int, int]: + data = bytes(ix.data) + return int.from_bytes(data[8:16], "little"), int.from_bytes(data[16:24], "little") + + +async def test_buy_on_a_sol_pool_spends_exactly_the_amount(tmp_keystore): + client, roles, pool = _chain(SOL_FIXTURE) + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 0.01, slippage=10 + ) + assert result["venue"] == "launchlab" + assert result["platform"] == "Spots.fun" + assert result["quote_symbol"] == "SOL" + swap = _swap(client.sent[0]) + assert bytes(swap.data[:8]) == BUY_EXACT_IN_DISCRIMINATOR + amount_in, min_out = _u64s(swap) + assert amount_in == 10_000_000 + expected = buy_amount_out(pool, amount_in, 17_500) + assert result["tokens_received"] == expected / 10**6 + assert min_out == expected * 90 // 100 + # Coin account, then the throwaway WSOL account opened and closed around the swap. + ixs = client.sent[0]["instructions"] + assert ixs[0].program_id == ASSOCIATED_TOKEN_PROGRAM + assert ixs[-1].program_id == TOKEN_PROGRAM + user = _user(tmp_keystore) + assert swap.accounts[5].pubkey == find_ata(user, roles["base_mint"], TOKEN_PROGRAM) + assert swap.accounts[6].pubkey != find_ata(user, roles["quote_mint"], TOKEN_PROGRAM) + + +async def test_a_coin_priced_in_another_asset_is_found_by_searching(tmp_keystore): + client, roles, pool = _chain(T22_FIXTURE) + user = _user(tmp_keystore) + quote_ata = find_ata(user, roles["quote_mint"], TOKEN_2022_PROGRAM) + client.token_balances[quote_ata] = 5 * 10**8 + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 1 + ) + assert result["platform"] == "StonkFun" + assert result["quote_mint"] == str(roles["quote_mint"]) + swap = _swap(client.sent[0]) + amount_in, min_out = _u64s(swap) + assert amount_in == 10**8 # the quote has 8 decimals + # The coin's 1% transfer fee comes off what arrives. + assert result["tokens_received"] * 10**6 == buy_amount_out( + pool, amount_in, 12_500, ((100, 10**15),) + ) + assert min_out == buy_amount_out(pool, amount_in, 12_500, ((100, 10**15),)) * 85 // 100 + assert swap.accounts[6].pubkey == quote_ata + assert swap.accounts[5].pubkey == find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM) + assert swap.accounts[12].pubkey == TOKEN_2022_PROGRAM + # No WSOL wrapping: create the coin and quote accounts, then swap. + assert [i.program_id for i in client.sent[0]["instructions"]] == [ + ASSOCIATED_TOKEN_PROGRAM, + ASSOCIATED_TOKEN_PROGRAM, + LAUNCHLAB_PROGRAM, + ] + + +async def test_buy_needs_the_quote_asset(tmp_keystore): + client, roles, _ = _chain(T22_FIXTURE) + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 1 + ) + assert result["error"] == "insufficient_balance" + assert result["available_quote_raw"] == 0 and result["required_quote_raw"] == 10**8 + assert "available_sol" not in result + assert client.sent == [] + + +async def test_a_curve_type_it_cannot_price_is_refused(tmp_keystore): + client, roles, pool = _chain(SOL_FIXTURE) + config = bytearray(client.accounts[pool["global_config"]].data) + config[16] = 2 # linear + client.accounts[pool["global_config"]].data = bytes(config) + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 0.01 + ) + assert result["error"] == "launchlab_error" + assert client.sent == [] + + +async def test_buy_dry_run_sends_nothing(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 0.01, dry_run=True + ) + assert result["dry_run"] is True and result["venue"] == "launchlab" + assert result["expected_tokens_raw"] > result["min_tokens_out_raw"] > 0 + assert client.sent == [] + + +async def test_sell_all_of_a_transfer_fee_coin(tmp_keystore): + client, roles, pool = _chain(T22_FIXTURE) + user = _user(tmp_keystore) + client.token_balances[find_ata(user, roles["base_mint"], TOKEN_2022_PROGRAM)] = 10**10 + with _patched(client): + result = await sell_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), "all" + ) + swap = _swap(client.sent[0]) + assert bytes(swap.data[:8]) == SELL_EXACT_IN_DISCRIMINATOR + amount_in, min_out = _u64s(swap) + assert amount_in == 10**10 + expected = sell_amount_out(pool, 10**10, 12_500, ((100, 10**15),)) + assert result["quote_received"] == expected / 10**8 + assert min_out == expected * 85 // 100 + + +async def test_sell_more_than_held(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + with _patched(client): + result = await sell_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), "5" + ) + assert result["error"] == "insufficient_balance" + + +def _set_status(client: FakeClient, pool: Pubkey, status: int) -> None: + data = bytearray(client.accounts[pool].data) + data[17] = status + client.accounts[pool].data = bytes(data) + + +@pytest.mark.parametrize("fn,arg", [(buy_launchlab, 0.01), (sell_launchlab, "all")]) +async def test_a_graduated_curve_defers_to_its_pool(tmp_keystore, fn, arg): + client, roles, _ = _chain(SOL_FIXTURE) + _set_status(client, roles["pool"], 2) + with _patched(client): + result = await fn("http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), arg) + assert result["error"] == "graduated" + assert result["migrate_type"] == "cpmm" + + +async def test_no_pool(tmp_keystore): + client = FakeClient() + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(Pubkey.new_unique()), 0.01 + ) + assert result["error"] == "not_found" + + +async def test_slippage_is_reported(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + client.send_error = TransactionFailedError({"InstructionError": [3, {"Custom": 6004}]}) + with _patched(client): + result = await buy_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), 0.01 + ) + assert result["error"] == "slippage" + + +async def test_info(): + client, roles, pool = _chain(T22_FIXTURE) + with _patched(client): + info = await get_launchlab_info("http://rpc", str(roles["base_mint"])) + assert info["venue"] == "launchlab" + assert info["platform"] == "StonkFun" + assert info["launchlab_pool"] == str(roles["pool"]) + assert info["graduated"] is False + assert info["curve_fee_pct"] == 1.25 + assert info["transfer_fee_bps"] == 100 + assert info["bonding_progress"] == round(pool["real_base"] / pool["total_base_sell"] * 100, 1) + + +async def test_token_info_falls_back_to_launchlab(): + client, roles, _ = _chain(SOL_FIXTURE) + with ( + patch("pumpfun_cli.core.info.RpcClient", return_value=client), + _patched(client), + ): + info = await get_token_info("http://rpc", str(roles["base_mint"])) + assert info["venue"] == "launchlab" + assert info["platform"] == "Spots.fun" + + +async def test_a_sell_the_curve_would_pay_nothing_for_is_refused(tmp_keystore): + client, roles, _ = _chain(SOL_FIXTURE) + user = _user(tmp_keystore) + client.token_balances[find_ata(user, roles["base_mint"], TOKEN_PROGRAM)] = 1 + with _patched(client): + result = await sell_launchlab( + "http://rpc", tmp_keystore, "testpass", str(roles["base_mint"]), "all" + ) + assert result["error"] == "dust" + assert client.sent == [] diff --git a/tests/test_core/test_pumpswap.py b/tests/test_core/test_pumpswap.py index ba1dc70..6023f43 100644 --- a/tests/test_core/test_pumpswap.py +++ b/tests/test_core/test_pumpswap.py @@ -72,6 +72,7 @@ async def test_no_pool(tmp_keystore): with _patched(client): result = await buy_pumpswap("http://rpc", tmp_keystore, "testpass", MINT_STR, 0.01) assert result["error"] == "pumpswap_error" + assert result["pool_missing"] is True async def test_no_liquidity(tmp_keystore): diff --git a/tests/test_core/test_trade.py b/tests/test_core/test_trade.py index f2bba2f..87dbe56 100644 --- a/tests/test_core/test_trade.py +++ b/tests/test_core/test_trade.py @@ -263,6 +263,69 @@ async def test_sell_dry_run(tmp_keystore): assert client.sent == [] +async def test_buy_past_the_curve_reports_tokens_left(tmp_keystore): + client = chain_with_coin() + client.send_error = TransactionFailedError({"InstructionError": [2, {"Custom": 6021}]}) + with _patched(client): + result = await buy_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, 0.01, confirm=True + ) + assert result["error"] == "exceeds_curve" + assert result["curve_tokens_left_raw"] == 793_100_000_000_000 + assert "793100000.000000 tokens left" in result["message"] + + +# A mayhem-style curve whose real reserves fall short of what its price promises. +_SHORT_CURVE = {"real_quote": 1_000_000, "mayhem": True} +_HELD = 10**14 + + +async def test_sell_caps_at_what_the_curve_can_pay(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(**_SHORT_CURVE)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = _HELD + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + sold = int.from_bytes(bytes(_pump_ix(client.sent[0]).data[8:16]), "little") + assert 0 < sold < _HELD + assert result["tokens_sold_raw"] == sold + assert result["tokens_kept_raw"] == _HELD - sold + # The payout fits the real reserves, and one more token would not. + assert sold * 30_000_000_000 // (10**15 + sold) <= 1_000_000 + assert (sold + 1) * 30_000_000_000 // (10**15 + sold + 1) > 1_000_000 + + +async def test_sell_dry_run_shows_what_the_curve_cant_pay(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(**_SHORT_CURVE)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = _HELD + with _patched(client): + result = await sell_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, "all", dry_run=True + ) + assert result["tokens_in_raw"] + result["tokens_kept_raw"] == _HELD + assert client.sent == [] + + +async def test_sell_within_reserves_keeps_nothing(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin() + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = 5_000_000 + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + assert "tokens_kept" not in result + + +async def test_sell_refused_when_the_curve_can_pay_nothing(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(real_quote=0, mayhem=True)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = 5_000_000 + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + assert result["error"] == "curve_short" + assert client.sent == [] + + # --- cashback, accumulator, fees, migration --- diff --git a/tests/test_protocol/test_cpmm.py b/tests/test_protocol/test_cpmm.py new file mode 100644 index 0000000..8028739 --- /dev/null +++ b/tests/test_protocol/test_cpmm.py @@ -0,0 +1,141 @@ +"""Raydium CPMM: layouts against mainnet accounts, and the swap instruction.""" + +import base64 +import json +import struct +from pathlib import Path + +import pytest +from solders.pubkey import Pubkey + +from pumpfun_cli.protocol.contracts import TOKEN_2022_PROGRAM, TOKEN_PROGRAM, WSOL_MINT +from pumpfun_cli.protocol.cpmm import ( + CPMM_AUTHORITY, + CPMM_PROGRAM, + SWAP_BASE_INPUT_DISCRIMINATOR, + build_cpmm_swap_instruction, + cpmm_pool_address, + parse_cpmm_fee_rate, + parse_cpmm_pool, + swap_amount_out, +) +from pumpfun_cli.protocol.launchlab import PLATFORM_CPSWAP_CONFIG_OFFSET + +FIXTURES = Path(__file__).parents[1] / "fixtures" + + +def _fixture(name: str) -> dict: + """A migrated LaunchLab coin and its CPMM pool, as they were on mainnet.""" + bundle = json.loads((FIXTURES / name).read_text()) + return {key: {**acc, "bytes": base64.b64decode(acc["data"])} for key, acc in bundle.items()} + + +SOL = _fixture("cpmm_sol_pool.json") +FEE = _fixture("cpmm_transfer_fee_pool.json") + + +def _mint(bundle: dict) -> Pubkey: + return Pubkey.from_string(bundle["base_mint"]["address"]) + + +@pytest.mark.parametrize("bundle", [SOL, FEE], ids=["sol", "transfer_fee"]) +def test_the_pool_derives_from_the_platforms_amm_config(bundle): + config = bundle["platform_config"]["bytes"] + amm_config = Pubkey.from_bytes( + config[PLATFORM_CPSWAP_CONFIG_OFFSET : PLATFORM_CPSWAP_CONFIG_OFFSET + 32] + ) + assert str(amm_config) == bundle["amm_config"]["address"] + quote = Pubkey.from_string(bundle["quote_mint"]["address"]) + assert cpmm_pool_address(amm_config, _mint(bundle), quote) == Pubkey.from_string( + bundle["cpmm_pool"]["address"] + ) + # Either mint order derives the same pool. + assert cpmm_pool_address(amm_config, quote, _mint(bundle)) == cpmm_pool_address( + amm_config, _mint(bundle), quote + ) + + +@pytest.mark.parametrize("bundle", [SOL, FEE], ids=["sol", "transfer_fee"]) +def test_pool_state_decodes_a_mainnet_pool(bundle): + pool = parse_cpmm_pool(bundle["cpmm_pool"]["bytes"], _mint(bundle)) + assert str(pool["quote_mint"]) == bundle["quote_mint"]["address"] + assert str(pool["amm_config"]) == bundle["amm_config"]["address"] + assert pool["base_token_program"] == TOKEN_2022_PROGRAM + assert pool["quote_token_program"] == TOKEN_PROGRAM + assert pool["base_decimals"] == 6 + vaults = {bundle[k]["address"]: bundle[k]["bytes"] for k in ("token_0_vault", "token_1_vault")} + base_vault, quote_vault = vaults[str(pool["base_vault"])], vaults[str(pool["quote_vault"])] + # Each vault is a token account of its own mint, held by the CPMM authority. + assert base_vault[:32] == bytes(_mint(bundle)) and base_vault[32:64] == bytes(CPMM_AUTHORITY) + assert quote_vault[:32] == bytes(pool["quote_mint"]) + assert 0 <= pool["quote_fees"] < int.from_bytes(quote_vault[64:72], "little") + + +def test_pool_state_rejects_other_accounts_and_mints(): + with pytest.raises(ValueError): + parse_cpmm_pool(SOL["amm_config"]["bytes"], _mint(SOL)) + with pytest.raises(ValueError): + parse_cpmm_pool(SOL["cpmm_pool"]["bytes"], Pubkey.new_unique()) + + +def test_fee_rate_from_a_mainnet_amm_config(): + data = SOL["amm_config"]["bytes"] + assert parse_cpmm_fee_rate(data, creator_fee_enabled=True) == 12_500 + assert parse_cpmm_fee_rate(data, creator_fee_enabled=False) == 2_500 + + +def test_swap_output_takes_the_fee_off_the_input(): + net = 10**9 - 12_500_000 + assert swap_amount_out(30 * 10**9, 10**15, 10**9, 12_500) == 10**15 * net // (30 * 10**9 + net) + + +@pytest.mark.parametrize("buy", [True, False]) +def test_swap_puts_the_input_side_first(buy): + pool = parse_cpmm_pool(SOL["cpmm_pool"]["bytes"], _mint(SOL)) + user, base_ata, quote_acc = Pubkey.new_unique(), Pubkey.new_unique(), Pubkey.new_unique() + pool_address = Pubkey.from_string(SOL["cpmm_pool"]["address"]) + ix = build_cpmm_swap_instruction( + buy=buy, + pool_address=pool_address, + pool=pool, + user=user, + user_base_token=base_ata, + user_quote_token=quote_acc, + amount_in=7, + minimum_amount_out=3, + ) + assert ix.program_id == CPMM_PROGRAM + assert bytes(ix.data) == SWAP_BASE_INPUT_DISCRIMINATOR + struct.pack(" dict: + """A pool and its configs and mints as they were on mainnet.""" + bundle = json.loads((ROOT / "tests" / "fixtures" / name).read_text()) + return {key: {**acc, "bytes": base64.b64decode(acc["data"])} for key, acc in bundle.items()} + + +SOL_POOL = _fixture("launchlab_sol_pool.json") +T22_POOL = _fixture("launchlab_token2022_quote_pool.json") + + +def _swap(buy: bool, pool: dict, **overrides): + args = { + "buy": buy, + "pool_address": Pubkey.new_unique(), + "pool": pool, + "user": Pubkey.new_unique(), + "user_base_token": Pubkey.new_unique(), + "user_quote_token": Pubkey.new_unique(), + "base_token_program": TOKEN_PROGRAM, + "quote_token_program": TOKEN_PROGRAM, + "amount_in": 123, + "minimum_amount_out": 45, + } + return build_launchlab_swap_instruction(**{**args, **overrides}) + + +@pytest.mark.parametrize("buy,name", [(True, "buy_exact_in"), (False, "sell_exact_in")]) +def test_swap_matches_idl(buy, name): + pool = parse_pool_state(SOL_POOL["pool"]["bytes"]) + built = _swap(buy, pool) + assert_matches_idl(IDL, name, built) + assert bytes(built.data[8:]) == struct.pack(" str: + mint = os.environ.get("SURFPOOL_CPMM_MINT") + if not mint: + pytest.skip("Set SURFPOOL_CPMM_MINT to a LaunchLab coin migrated to CPMM") + return mint + + +def _cli(surfpool_rpc, keystore, password, monkeypatch, *args) -> dict: + monkeypatch.setenv("PUMPFUN_PASSWORD", password) + out = runner.invoke(app, ["--json", "--rpc", surfpool_rpc, "--keyfile", str(keystore), *args]) + assert out.exit_code == 0, out.output + return json.loads(out.output) + + +@pytest.mark.asyncio +async def test_info_shows_the_cpmm_pool(surfpool_rpc): + info = await get_token_info(surfpool_rpc, _mint()) + assert info["venue"] == "cpmm" and info["graduated"] is True, info + assert info["quote_reserves"] > 0 + + +def test_buy_and_sell_route_to_cpmm( + surfpool_rpc, funded_keypair, test_keystore, test_password, monkeypatch +): + mint = _mint() + bought = _cli( + surfpool_rpc, test_keystore, test_password, monkeypatch, "buy", mint, "0.01", "--confirm" + ) + assert bought["venue"] == "cpmm" and bought["tokens_received_raw"] > 0 + sold = _cli( + surfpool_rpc, test_keystore, test_password, monkeypatch, "sell", mint, "all", "--confirm" + ) + assert sold["venue"] == "cpmm" + assert sold["tokens_sold_raw"] == bought["tokens_received_raw"] + + +def test_force_amm_trades_a_launchlab_coin_on_cpmm( + surfpool_rpc, funded_keypair, test_keystore, test_password, monkeypatch +): + mint = _mint() + bought = _cli( + surfpool_rpc, + test_keystore, + test_password, + monkeypatch, + "buy", + "--force-amm", + mint, + "0.01", + "--confirm", + ) + assert bought["venue"] == "cpmm" + sold = _cli( + surfpool_rpc, + test_keystore, + test_password, + monkeypatch, + "sell", + "--force-amm", + mint, + "all", + "--confirm", + ) + assert sold["venue"] == "cpmm" diff --git a/tests/test_surfpool/test_extras.py b/tests/test_surfpool/test_extras.py index ea007fa..01233c0 100644 --- a/tests/test_surfpool/test_extras.py +++ b/tests/test_surfpool/test_extras.py @@ -84,3 +84,90 @@ async def test_collect_creator_fees_no_fees( ) assert result["error"] == "no_fees" assert "No creator fees" in result["message"] + + +@pytest.mark.asyncio +async def test_migrate_a_completed_curve( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """Launch a coin, buy its whole curve, then migrate it into its PumpSwap pool.""" + from solana.rpc.async_api import AsyncClient + from solders.pubkey import Pubkey + + from pumpfun_cli.core.info import get_token_info + from pumpfun_cli.core.launch import launch_token + from pumpfun_cli.core.trade import buy_token + from pumpfun_cli.protocol.address import derive_amm_pool, derive_bonding_curve + from pumpfun_cli.protocol.client import RpcClient + from pumpfun_cli.protocol.curve import parse_bonding_curve + from pumpfun_cli.protocol.instructions import build_buy_v2_instruction + + client = AsyncClient(surfpool_rpc) + try: + await client.request_airdrop(funded_keypair.pubkey(), 200_000_000_000) + finally: + await client.close() + + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Migrate", + ticker="MIG", + description="migrate test", + uri="https://example.com/migrate.json", + confirm=True, + ) + assert "error" not in launched, launched + mint = launched["mint"] + mint_pk = Pubkey.from_string(mint) + + # A small buy creates the token account; buy_v2 then takes every token left. + bought = await buy_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=mint, + amount=0.01, + confirm=True, + ) + assert "error" not in bought, bought + client = RpcClient(surfpool_rpc) + try: + curve = parse_bonding_curve( + bytes((await client.get_account_info(derive_bonding_curve(mint_pk))).value.data) + ) + await client.send_tx( + [ + build_buy_v2_instruction( + mint=mint_pk, + creator=curve["creator"], + user=funded_keypair.pubkey(), + token_amount=curve["real_token_reserves"], + max_quote_cost=150_000_000_000, + ) + ], + [funded_keypair], + compute_units=400_000, + confirm=True, + ) + finally: + await client.close() + + migrated = await migrate_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=mint, + confirm=True, + ) + assert "error" not in migrated, migrated + + client = AsyncClient(surfpool_rpc) + try: + pool = await client.get_account_info(derive_amm_pool(mint_pk)) + finally: + await client.close() + assert pool.value is not None + info = await get_token_info(surfpool_rpc, mint) + assert info["graduated"] is True diff --git a/tests/test_surfpool/test_launchlab.py b/tests/test_surfpool/test_launchlab.py new file mode 100644 index 0000000..f24b5ef --- /dev/null +++ b/tests/test_surfpool/test_launchlab.py @@ -0,0 +1,74 @@ +"""Surfpool integration: buy and sell forked Raydium LaunchLab coins. + +Set SURFPOOL_LAUNCHLAB_MINT to a SOL-priced LaunchLab coin still on its curve, +and optionally SURFPOOL_LAUNCHLAB_QUOTE_MINT to one priced in another asset; +the test wallet is given that asset with surfnet_setTokenAccount. +""" + +import os + +import httpx +import pytest + +from pumpfun_cli.core.info import get_token_info +from pumpfun_cli.core.launchlab import buy_launchlab, sell_launchlab + + +def _mint(var: str) -> str: + mint = os.environ.get(var) + if not mint: + pytest.skip(f"Set {var} to a LaunchLab coin on its curve") + return mint + + +async def _round_trip(rpc, keystore, password, mint, amount): + bought = await buy_launchlab(rpc, str(keystore), password, mint, amount, 25, confirm=True) + assert "error" not in bought, bought + assert bought["venue"] == "launchlab" + assert bought["tokens_received_raw"] > 0 + sold = await sell_launchlab(rpc, str(keystore), password, mint, "all", 25, confirm=True) + assert "error" not in sold, sold + assert sold["tokens_sold_raw"] == bought["tokens_received_raw"] + return bought, sold + + +@pytest.mark.asyncio +async def test_sol_coin_round_trip(surfpool_rpc, funded_keypair, test_keystore, test_password): + mint = _mint("SURFPOOL_LAUNCHLAB_MINT") + info = await get_token_info(surfpool_rpc, mint) + assert info["venue"] == "launchlab" and info["quote_symbol"] == "SOL", info + await _round_trip(surfpool_rpc, test_keystore, test_password, mint, 0.01) + + +@pytest.mark.asyncio +async def test_other_quote_round_trip(surfpool_rpc, funded_keypair, test_keystore, test_password): + mint = _mint("SURFPOOL_LAUNCHLAB_QUOTE_MINT") + info = await get_token_info(surfpool_rpc, mint) + assert info["venue"] == "launchlab" and info["quote_symbol"] != "SOL", info + quote = info["quote_mint"] + async with httpx.AsyncClient() as http: + owner = ( + await http.post( + surfpool_rpc, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": [quote, {"encoding": "base64"}], + }, + ) + ).json()["result"]["value"]["owner"] + funded = ( + await http.post( + surfpool_rpc, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "surfnet_setTokenAccount", + "params": [str(funded_keypair.pubkey()), quote, {"amount": 10**12}, owner], + }, + ) + ).json() + assert "error" not in funded, funded + bought, sold = await _round_trip(surfpool_rpc, test_keystore, test_password, mint, 1) + assert bought["quote_mint"] == quote and sold["quote_received"] > 0 diff --git a/tests/test_surfpool/test_trade.py b/tests/test_surfpool/test_trade.py index ddbc20f..96856a5 100644 --- a/tests/test_surfpool/test_trade.py +++ b/tests/test_surfpool/test_trade.py @@ -142,3 +142,137 @@ async def test_buy_without_confirm( assert "error" not in result, f"Buy failed: {result}" assert result["signature"] assert "confirmed" not in result + + +@pytest.mark.asyncio +async def test_sell_caps_at_the_curves_real_reserves( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """A curve holding less than its price promises takes the most it can pay for. + + The curve's real reserves are lowered with surfnet_setAccount, the way they + drift on a mayhem coin. The capped sell lands; one more token reverts. + """ + import struct + + import httpx + from solders.pubkey import Pubkey + + from pumpfun_cli.core.launch import launch_token + from pumpfun_cli.protocol.address import derive_bonding_curve + from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError + from pumpfun_cli.protocol.curve import parse_bonding_curve + from pumpfun_cli.protocol.instructions import build_sell_v2_instruction + + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Short", + ticker="SHRT", + description="short reserves", + uri="https://example.com/short.json", + initial_buy=1, + confirm=True, + ) + assert "error" not in launched, launched + mint = Pubkey.from_string(launched["mint"]) + curve_pda = derive_bonding_curve(mint) + + client = RpcClient(surfpool_rpc) + try: + data = bytearray((await client.get_account_info(curve_pda)).value.data) + struct.pack_into(" 0, preview + sellable = preview["tokens_in_raw"] + + # One token more than the cap is more than the curve can pay. + with pytest.raises(TransactionFailedError) as over: + await client.send_tx( + [ + build_sell_v2_instruction( + mint=mint, + creator=curve["creator"], + user=funded_keypair.pubkey(), + token_amount=sellable + 1, + min_quote_out=1, + ) + ], + [funded_keypair], + compute_units=200_000, + confirm=True, + ) + assert over.value.error_code == 6024 # Overflow: the payout exceeds real reserves + finally: + await client.close() + + sold = await sell_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=str(mint), + amount_str="all", + slippage=50, + confirm=True, + ) + assert "error" not in sold, sold + assert sold["tokens_sold_raw"] == sellable + assert sold["tokens_kept_raw"] > 0 + + +@pytest.mark.asyncio +async def test_buy_past_the_curve_is_reported( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """Spending more than the curve has left fails with how many tokens remain.""" + from solana.rpc.async_api import AsyncClient + + from pumpfun_cli.core.launch import launch_token + + client = AsyncClient(surfpool_rpc) + try: + await client.request_airdrop(funded_keypair.pubkey(), 200_000_000_000) + finally: + await client.close() + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Big", + ticker="BIG", + description="buy past the curve", + uri="https://example.com/big.json", + confirm=True, + ) + assert "error" not in launched, launched + result = await buy_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=launched["mint"], + amount=150, + confirm=True, + ) + assert result["error"] == "exceeds_curve", result + assert result["curve_tokens_left_raw"] > 0 diff --git a/uv.lock b/uv.lock index 3bb42ff..cb74b3e 100644 --- a/uv.lock +++ b/uv.lock @@ -565,7 +565,7 @@ wheels = [ [[package]] name = "pumpfun-cli" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "base58" },