Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,7 +117,7 @@ Token discovery (zero config; price and progress added when an RPC is set):
pumpfun tokens search <query> Search by name or ticker

Token info (requires RPC):
pumpfun info <mint> Price, bonding progress, reserves (pump.fun or LaunchLab)
pumpfun info <mint> Price, bonding progress, reserves (pump.fun or LaunchLab, and their pools)

Trading (requires RPC + wallet):
pumpfun buy <mint> <amount> [--slippage N] [--force-amm] [--dry-run] [--confirm]
Expand Down
9 changes: 6 additions & 3 deletions src/pumpfun_cli/commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)
Expand All @@ -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")
Expand All @@ -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}%")
32 changes: 25 additions & 7 deletions src/pumpfun_cli/commands/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -113,6 +127,7 @@ def buy(
buy_token,
buy_pumpswap,
buy_launchlab,
buy_cpmm,
force_amm,
mint,
lambda fn: asyncio.run(
Expand Down Expand Up @@ -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(
Expand All @@ -184,6 +201,7 @@ def sell(
sell_token,
sell_pumpswap,
sell_launchlab,
sell_cpmm,
force_amm,
mint,
lambda fn: asyncio.run(
Expand Down
Loading
Loading