From fac7940c26bd71ddadb995e46f373950ae313849 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 8 Nov 2026 23:18:14 +0100 Subject: [PATCH] feat: trade graduated LaunchLab coins on Raydium CPMM buy and sell follow a graduated LaunchLab coin to its CPMM pool, the way they follow a pump.fun coin to PumpSwap; --force-amm covers both, and info shows the pool. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- README.md | 6 +- src/pumpfun_cli/commands/info.py | 9 +- src/pumpfun_cli/commands/trade.py | 32 +- src/pumpfun_cli/core/cpmm.py | 406 +++++++++++++++++++++ src/pumpfun_cli/core/info.py | 7 + src/pumpfun_cli/core/pumpswap.py | 10 + src/pumpfun_cli/protocol/cpmm.py | 162 ++++++++ src/pumpfun_cli/protocol/pumpswap.py | 9 +- tests/fixtures/cpmm_sol_pool.json | 47 +++ tests/fixtures/cpmm_transfer_fee_pool.json | 47 +++ tests/test_commands/test_trade_cmd.py | 104 ++++++ tests/test_core/test_cpmm.py | 183 ++++++++++ tests/test_core/test_pumpswap.py | 1 + tests/test_protocol/test_cpmm.py | 141 +++++++ tests/test_surfpool/test_cpmm.py | 83 +++++ 16 files changed, 1242 insertions(+), 16 deletions(-) create mode 100644 src/pumpfun_cli/core/cpmm.py create mode 100644 src/pumpfun_cli/protocol/cpmm.py create mode 100644 tests/fixtures/cpmm_sol_pool.json create mode 100644 tests/fixtures/cpmm_transfer_fee_pool.json create mode 100644 tests/test_core/test_cpmm.py create mode 100644 tests/test_protocol/test_cpmm.py create mode 100644 tests/test_surfpool/test_cpmm.py diff --git a/CLAUDE.md b/CLAUDE.md index 265cedb..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, launchlab, pumpswap, tokens, trade, tx_status, validate, wallet) -└── protocol/ # Solana primitives (address, client, contracts, curve, idl_parser, instructions, launchlab, 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:** `_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`). Never suppress or swallow those errors in core — the routing decision belongs in the command layer. `info` falls back to LaunchLab the same way in `core/info.py`. +**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 @@ -140,6 +140,11 @@ lists; these are the ones this CLI depends on. (`[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 30764fd..fe5965d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ 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 @@ -113,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 (pump.fun or LaunchLab) + 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] diff --git a/src/pumpfun_cli/commands/info.py b/src/pumpfun_cli/commands/info.py index 4dde602..f3a031b 100644 --- a/src/pumpfun_cli/commands/info.py +++ b/src/pumpfun_cli/commands/info.py @@ -31,7 +31,7 @@ def info( error(result["message"]) if not render(result, state.json_mode): typer.echo(f"Mint: {result['mint']}") - if result.get("venue") == "launchlab": + if result.get("venue") in ("launchlab", "cpmm"): typer.echo( f"Launchpad: {result['platform'] or 'LaunchLab'} (Raydium LaunchLab)" ) @@ -45,8 +45,9 @@ def info( 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.get("is_mayhem_mode"): typer.echo("Mayhem mode: Yes") @@ -62,3 +63,5 @@ def info( 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 8f60c50..960d2c0 100644 --- a/src/pumpfun_cli/commands/trade.py +++ b/src/pumpfun_cli/commands/trade.py @@ -4,6 +4,7 @@ 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 ( @@ -23,19 +24,30 @@ from pumpfun_cli.output import error, render -def _route(curve, amm, launchlab, force_amm: bool, mint: str, call) -> dict: - """Trade where the coin lives: pump.fun's curve, then PumpSwap once it has - graduated, or a LaunchLab curve when pump.fun has no such coin. +def _route(curve, amm, launchlab, cpmm, force_amm: bool, mint: str, call) -> dict: + """Trade where the coin lives. - `call(fn)` runs one venue's core function with the command's arguments. + 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: - return call(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 @@ -94,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( @@ -113,6 +127,7 @@ def buy( buy_token, buy_pumpswap, buy_launchlab, + buy_cpmm, force_amm, mint, lambda fn: asyncio.run( @@ -167,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( @@ -184,6 +201,7 @@ def sell( sell_token, sell_pumpswap, sell_launchlab, + sell_cpmm, force_amm, mint, lambda fn: asyncio.run( 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 a232e43..74ed159 100644 --- a/src/pumpfun_cli/core/info.py +++ b/src/pumpfun_cli/core/info.py @@ -1,5 +1,6 @@ """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 @@ -26,6 +27,12 @@ async def get_token_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) 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"] 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/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(" 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/test_commands/test_trade_cmd.py b/tests/test_commands/test_trade_cmd.py index 16dc72e..ec1e64d 100644 --- a/tests/test_commands/test_trade_cmd.py +++ b/tests/test_commands/test_trade_cmd.py @@ -1125,3 +1125,107 @@ def test_a_coin_on_neither_venue_says_so(tmp_path, monkeypatch): 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/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_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_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(" 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"