diff --git a/README.md b/README.md index 68158ae..1ab66e4 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ spends that asset, so the wallet has to hold it. ## Commands ``` -Token discovery (zero config): - pumpfun tokens trending [--limit N] Top runners + recommended +Token discovery (zero config; price and progress added when an RPC is set): + pumpfun tokens trending [--limit N] pump.fun's movers board pumpfun tokens new [--limit N] Recently launched - pumpfun tokens graduating [--limit N] Near bonding curve completion - pumpfun tokens recommended [--limit N] Recommended by pump.fun - pumpfun tokens search Search by keyword + pumpfun tokens graduating [--limit N] Closest to bonding curve completion + pumpfun tokens recommended [--limit N] Recommended by pump.fun (at most 10) + pumpfun tokens search Search by name or ticker Token info (requires RPC): pumpfun info Price, bonding progress, reserves @@ -134,11 +134,12 @@ Global options: | Command | Endpoint | Protocol | |---------|----------|----------| -| `tokens trending` | `frontend-api-v3.pump.fun/coins/top-runners` + `/coins/recommended` | HTTPS | -| `tokens new` | `frontend-api-v3.pump.fun/coins/recommended` | HTTPS | -| `tokens graduating` | `frontend-api-v3.pump.fun/coins/recommended` | HTTPS | -| `tokens recommended` | `frontend-api-v3.pump.fun/coins/recommended` | HTTPS | -| `tokens search` | `frontend-api-v3.pump.fun/coins/recommended` (client-side filter) | HTTPS | +| `tokens trending` | `frontend-api-v3.pump.fun/coins/boards/movers` | HTTPS | +| `tokens new` | `frontend-api-v3.pump.fun/coins/boards/new` | HTTPS | +| `tokens graduating` | `frontend-api-v3.pump.fun/coins/boards/graduating` | HTTPS | +| `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 | @@ -154,6 +155,11 @@ Global options: | `wallet create/show/import/export` | None (local keystore only) | — | | `config *` | None (local config file only) | — | +The `tokens` listings use pump.fun's frontend API, which is unofficial and +changes without notice. They are narrowed to pump.fun coins on Solana; the API +no longer returns curve reserves, so price and bonding progress come from the +chain and need an RPC endpoint. + ## Security - Private keys are encrypted at rest (AES-256-GCM + scrypt) diff --git a/scripts/mainnet-test.sh b/scripts/mainnet-test.sh index ccb959f..5c412c5 100755 --- a/scripts/mainnet-test.sh +++ b/scripts/mainnet-test.sh @@ -184,11 +184,12 @@ echo " Done. ($PASS pass)" # --- Group 3: Token Discovery --- echo "=== Group 3: Token Discovery ===" -run_json_array "Discovery" "tokens trending" "uv run pumpfun tokens trending --limit 3" +run_json_array "Discovery" "tokens trending" "uv run pumpfun tokens trending --limit 20" TRENDING=$(last_output) run_json_array "Discovery" "tokens new" "uv run pumpfun tokens new --limit 10" NEW_TOKENS=$(last_output) -run_json_array "Discovery" "tokens graduating" "uv run pumpfun tokens graduating --limit 3" +run_json_array "Discovery" "tokens graduating" "uv run pumpfun tokens graduating --limit 10" +GRADUATING=$(last_output) run_json_array "Discovery" "tokens recommended" "uv run pumpfun tokens recommended --limit 3" # Search — known to sometimes return empty @@ -207,11 +208,12 @@ fi # --- Discover mints for later groups --- -ACTIVE_MINT=$(echo "$NEW_TOKENS" | python3 -c " +# A SOL-priced coin still on its curve; the graduating board has the deepest ones. +ACTIVE_MINT=$(echo "$GRADUATING" | python3 -c " import json, sys tokens = json.load(sys.stdin) for t in tokens: - if not t.get('complete', True) and t.get('real_token_reserves', 0) > 0 and t.get('real_sol_reserves', 0) > 1_000_000_000: + if not t.get('complete', True) and t.get('quote_symbol') == 'SOL': print(t['mint']) break else: @@ -221,14 +223,11 @@ else: GRADUATED_MINT=$(echo "$TRENDING" | python3 -c " import json, sys tokens = json.load(sys.stdin) -# Prefer graduated tokens with higher SOL reserves — pools with extreme -# base/quote ratios can trigger on-chain overflow at buy.rs:400. +# A graduated SOL-priced coin; the largest one has the deepest PumpSwap pool. best = None for t in tokens: - if t.get('complete') and t.get('pump_swap_pool'): - if best is None: - best = t - elif t.get('market_cap', 0) > best.get('market_cap', 0): + if t.get('complete') and t.get('quote_symbol') == 'SOL': + if best is None or (t.get('usd_market_cap') or 0) > (best.get('usd_market_cap') or 0): best = t if best: print(best['mint']) @@ -309,7 +308,7 @@ else import json, sys tokens = json.load(sys.stdin) for t in tokens: - if t.get('complete') and t.get('pump_swap_pool'): + if t.get('complete') and t.get('quote_symbol') == 'SOL': print(t['mint']) " 2>/dev/null) diff --git a/src/pumpfun_cli/commands/info.py b/src/pumpfun_cli/commands/info.py index d2d87bf..1ed2f7d 100644 --- a/src/pumpfun_cli/commands/info.py +++ b/src/pumpfun_cli/commands/info.py @@ -43,7 +43,7 @@ def info( typer.echo(f"Quote reserves: {result['real_quote_reserves']:.4f} {symbol}") if result.get("pumpswap_warning"): typer.echo(f"Warning: {result['pumpswap_warning']}") - typer.echo(f"Creator: {result['creator']}") + typer.echo(f"Creator: {result['creator'] or '\u2014'}") if result["is_mayhem_mode"]: typer.echo("Mayhem mode: Yes") if result["is_cashback_coin"]: diff --git a/src/pumpfun_cli/commands/tokens.py b/src/pumpfun_cli/commands/tokens.py index b26f42e..b3c8888 100644 --- a/src/pumpfun_cli/commands/tokens.py +++ b/src/pumpfun_cli/commands/tokens.py @@ -5,6 +5,8 @@ import typer from pumpfun_cli.core.tokens import ( + TokenApiError, + enrich_from_chain, get_graduating_tokens, get_new_tokens, get_recommended_tokens, @@ -12,7 +14,7 @@ search_tokens, ) from pumpfun_cli.group import JsonAwareGroup -from pumpfun_cli.output import render, render_table +from pumpfun_cli.output import error, render, render_table app = typer.Typer( help="Browse and search pump.fun tokens.", invoke_without_command=True, cls=JsonAwareGroup @@ -29,7 +31,7 @@ def _tokens_callback(ctx: typer.Context): TOKEN_COLUMNS = [ ("name", "Name"), ("symbol", "Ticker"), - ("price_sol", "Price (SOL)"), + ("_price", "Price"), ("_progress", "Progress"), ("_mcap", "MCap"), ("mint", "Mint"), @@ -46,46 +48,66 @@ def _fmt_mcap(mcap) -> str: return f"${mcap:.0f}" -def _display_tokens(ctx: typer.Context, tokens: list[dict]): +def _list(ctx: typer.Context, fetch) -> None: + """Fetch a listing, add chain data when an RPC is configured, and render it.""" state = ctx.obj + rpc = state.rpc if state else None + + enrich_failed = False + + async def run(): + nonlocal enrich_failed + tokens = await fetch + if rpc: + try: + tokens = await enrich_from_chain(tokens, rpc) + except (RuntimeError, ValueError): + enrich_failed = True # the listing is still useful without chain data + return tokens + + try: + tokens = asyncio.run(run()) + except TokenApiError as exc: + error(str(exc), hint="pump.fun's frontend API is unofficial and changes without notice.") if render(tokens, state and state.json_mode): return + if enrich_failed: + typer.echo("Could not read prices from chain; showing pump.fun's data only.", err=True) for t in tokens: - price = t.get("price_sol") - t["price_sol"] = f"{price:.8f}" if price is not None else "\u2014" + price = t.get("price") + symbol = t.get("quote_symbol") or "" + if len(symbol) > 6: + symbol = f"{symbol[:4]}\u2026" + t["_price"] = f"{price:.4g} {symbol}" if price is not None else "\u2014" progress = t.get("bonding_progress") t["_progress"] = f"{progress}%" if progress is not None else "\u2014" - t["_mcap"] = _fmt_mcap(t.get("usd_market_cap") or t.get("market_cap")) + t["_mcap"] = _fmt_mcap(t.get("usd_market_cap")) render_table(tokens, TOKEN_COLUMNS) @app.command("trending") def trending(ctx: typer.Context, limit: int = typer.Option(20, "--limit", "-n", min=1)): - """Top runners + recommended tokens.""" - tokens = asyncio.run(get_trending_tokens(limit=limit)) - _display_tokens(ctx, tokens) + """Coins moving most right now (pump.fun's movers board).""" + _list(ctx, get_trending_tokens(limit=limit)) @app.command("new") def new(ctx: typer.Context, limit: int = typer.Option(20, "--limit", "-n", min=1)): """Recently launched tokens.""" - tokens = asyncio.run(get_new_tokens(limit=limit)) - _display_tokens(ctx, tokens) + _list(ctx, get_new_tokens(limit=limit)) @app.command("graduating") def graduating(ctx: typer.Context, limit: int = typer.Option(20, "--limit", "-n", min=1)): """Tokens near bonding curve completion.""" - tokens = asyncio.run(get_graduating_tokens(limit=limit)) - _display_tokens(ctx, tokens) + _list(ctx, get_graduating_tokens(limit=limit)) @app.command("recommended") @app.command("top", hidden=True) -def recommended(ctx: typer.Context, limit: int = typer.Option(20, "--limit", "-n", min=1)): - """Recommended tokens by pump.fun.""" - tokens = asyncio.run(get_recommended_tokens(limit=limit)) - _display_tokens(ctx, tokens) +def recommended(ctx: typer.Context, limit: int = typer.Option(10, "--limit", "-n", min=1)): + """Recommended tokens by pump.fun (at most 10).""" + _list(ctx, get_recommended_tokens(limit=limit)) @app.command("search") @@ -94,6 +116,5 @@ def search( query: str = typer.Argument(...), limit: int = typer.Option(20, "--limit", "-n", min=1), ): - """Search tokens by keyword.""" - tokens = asyncio.run(search_tokens(query, limit=limit)) - _display_tokens(ctx, tokens) + """Search tokens by name or ticker.""" + _list(ctx, search_tokens(query, limit=limit)) diff --git a/src/pumpfun_cli/core/info.py b/src/pumpfun_cli/core/info.py index 6cea6f2..4f6103e 100644 --- a/src/pumpfun_cli/core/info.py +++ b/src/pumpfun_cli/core/info.py @@ -41,7 +41,7 @@ async def get_token_info(rpc_url: str, mint_str: str, rpc_timeout: float = 30.0) "real_quote_reserves": state["real_quote_reserves"] / 10**quote_decimals, "virtual_quote_reserves": state["virtual_quote_reserves"] / 10**quote_decimals, "virtual_token_reserves": state["virtual_token_reserves"], - "creator": str(state["creator"]), + "creator": str(state["creator"]) if state["creator"] else None, "is_mayhem_mode": state["is_mayhem_mode"], "is_cashback_coin": state["is_cashback_coin"], "is_holder_reward": bool(state["is_holder_reward"]), diff --git a/src/pumpfun_cli/core/tokens.py b/src/pumpfun_cli/core/tokens.py index 3d1f0cf..7ea07cd 100644 --- a/src/pumpfun_cli/core/tokens.py +++ b/src/pumpfun_cli/core/tokens.py @@ -1,97 +1,208 @@ -"""Fetch pump.fun token data via v3 frontend API.""" +"""Token discovery through pump.fun's frontend API, optionally enriched from chain. + +The API ranks and lists coins but no longer returns curve reserves, so price +and bonding progress come from the bonding curves themselves when an RPC +endpoint is available. Lists are narrowed to pump.fun coins on Solana, the only +ones this CLI can trade; pump.fun's boards also carry other launchpads and chains. +""" import httpx +from solana.exceptions import SolanaRpcException +from solders.pubkey import Pubkey +from pumpfun_cli.protocol.address import derive_bonding_curve +from pumpfun_cli.protocol.client import RpcClient from pumpfun_cli.protocol.contracts import HTTP_HEADERS +from pumpfun_cli.protocol.curve import get_bonding_progress, get_token_price, parse_bonding_curve +from pumpfun_cli.protocol.quote import ( + fetch_global, + get_mint_info, + normalize_quote_mint, + quote_symbol, +) PUMP_API = "https://frontend-api-v3.pump.fun" - -# The frontend API reports SOL-named reserves; these read them as the API returns them. -_LAMPORTS_PER_SOL = 1_000_000_000 -_TOKEN_DECIMALS = 6 -_GRADUATION_SOL = 85.0 - - -def get_token_price_sol(token: dict) -> float: - """Price in SOL per whole token from the API's virtual reserves.""" - if token["virtual_token_reserves"] == 0: - return 0.0 - return ( - token["virtual_sol_reserves"] - / token["virtual_token_reserves"] - * 10**_TOKEN_DECIMALS - / _LAMPORTS_PER_SOL - ) - - -def get_bonding_progress(token: dict) -> float: - """Approximate completion 0.0 – 1.0 from the API's real SOL reserves.""" - return min(token["real_sol_reserves"] / _LAMPORTS_PER_SOL / _GRADUATION_SOL, 1.0) - - -def _enrich_tokens(tokens: list[dict]) -> list[dict]: - """Add price_sol and bonding_progress to each token dict.""" - for t in tokens: - try: - t["price_sol"] = get_token_price_sol(t) - except KeyError: - t["price_sol"] = None - try: - t["bonding_progress"] = round(get_bonding_progress(t) * 100, 1) - except KeyError: - t["bonding_progress"] = None - return tokens - - -async def get_trending_tokens(limit: int = 20) -> list[dict]: - async with httpx.AsyncClient(timeout=10.0, headers=HTTP_HEADERS) as client: - resp = await client.get(f"{PUMP_API}/coins/top-runners") - resp.raise_for_status() - runners = resp.json() - tokens = [item["coin"] for item in runners if "coin" in item] - if len(tokens) >= limit: - return _enrich_tokens(tokens[:limit]) - recommended = await get_recommended_tokens(limit=limit - len(tokens)) - seen = {t["mint"] for t in tokens} - for t in recommended: - if t["mint"] not in seen: - tokens.append(t) - seen.add(t["mint"]) - if len(tokens) >= limit: +SOLANA_CHAIN_ID = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" +PUMP_PROGRAM_LABEL = "pump" + +# Board pages hold up to 150 rows and the offset stops at 149. +_BOARD_PAGE = 150 +_BOARD_MAX_OFFSET = 149 +_RECOMMENDATIONS_MAX = 10 +_SEARCH_MAX = 100 +_MULTIPLE_ACCOUNTS_MAX = 100 + + +class TokenApiError(RuntimeError): + """pump.fun's API refused or failed a listing request.""" + + +async def _get(path: str, params: dict) -> object: + try: + async with httpx.AsyncClient(timeout=15.0, headers=HTTP_HEADERS) as client: + resp = await client.get(f"{PUMP_API}{path}", params=params) + except httpx.HTTPError as exc: + raise TokenApiError(f"pump.fun API request failed ({type(exc).__name__})") from None + if resp.status_code != 200: + raise TokenApiError( + f"pump.fun API returned HTTP {resp.status_code} for {path}{_reason(resp)}" + ) + try: + return resp.json() + except ValueError: + raise TokenApiError(f"pump.fun API returned a non-JSON response for {path}") from None + + +def _reason(resp: httpx.Response) -> str: + """pump.fun's own error message, when the body is its JSON error; an HTML page says nothing.""" + try: + message = resp.json().get("message") + except (ValueError, AttributeError): + return "" + if isinstance(message, list): + message = "; ".join(str(m) for m in message) + return f": {str(message)[:200]}" if message else "" + + +def _is_pump_coin(row: object) -> bool: + if not isinstance(row, dict) or not isinstance(row.get("mint"), str): + return False + if row.get("chain_id") != SOLANA_CHAIN_ID or row.get("program") != PUMP_PROGRAM_LABEL: + return False + # A row whose mint or quote asset is not a pubkey cannot be priced or traded. + try: + Pubkey.from_string(row["mint"]) + if row.get("quote_mint") is not None: + Pubkey.from_string(row["quote_mint"]) + except (ValueError, TypeError): + return False + return True + + +def _summarize(row: dict) -> dict: + """The fields a caller acts on, in one shape whichever endpoint served the row.""" + raw_quote = row.get("quote_mint") + quote_mint = normalize_quote_mint(Pubkey.from_string(raw_quote) if raw_quote else None) + return { + "mint": row.get("mint"), + "name": row.get("name"), + "symbol": row.get("symbol"), + "quote_mint": str(quote_mint), + "quote_symbol": quote_symbol(quote_mint), + "usd_market_cap": row.get("usd_market_cap"), + "complete": bool(row.get("complete")), + "created_timestamp": row.get("created_timestamp"), + "last_trade_timestamp": row.get("last_trade_timestamp"), + "is_mayhem_mode": bool(row.get("mayhem")), + "is_cashback_coin": bool(row.get("is_cashback_enabled")), + "is_holder_reward": bool(row.get("is_holder_reward")), + } + + +def _pump_coins(rows: object, limit: int, path: str) -> list[dict]: + if not isinstance(rows, list): + raise TokenApiError(f"pump.fun API returned an unexpected shape for {path}") + return [_summarize(r) for r in rows if _is_pump_coin(r)][:limit] + + +async def _board(board: str, limit: int) -> list[dict]: + """A pump.fun board, narrowed to pump.fun coins, paging until `limit` are found. + + The API caps the offset at 149, so a board holds at most 299 rows and the + second page overlaps the first by one; rows are deduplicated by mint. + """ + found: dict[str, dict] = {} + offset = 0 + while len(found) < limit: + page = await _get(f"/coins/boards/{board}", {"limit": _BOARD_PAGE, "offset": offset}) + rows = page.get("coins") if isinstance(page, dict) else None + if not isinstance(rows, list): + raise TokenApiError(f"pump.fun API returned an unexpected shape for board {board}") + for row in rows: + if _is_pump_coin(row): + found.setdefault(row.get("mint"), row) + if len(rows) < _BOARD_PAGE or offset >= _BOARD_MAX_OFFSET: break - return _enrich_tokens(tokens[:limit]) + offset = min(offset + len(rows), _BOARD_MAX_OFFSET) + return [_summarize(r) for r in list(found.values())[:limit]] -async def get_recommended_tokens(limit: int = 50) -> list[dict]: - async with httpx.AsyncClient(timeout=10.0, headers=HTTP_HEADERS) as client: - resp = await client.get( - f"{PUMP_API}/coins/recommended", - params={"limit": min(limit, 300), "includeNsfw": "false"}, - ) - resp.raise_for_status() - return _enrich_tokens(resp.json()) +async def get_trending_tokens(limit: int = 20) -> list[dict]: + """pump.fun's "movers" board.""" + return await _board("movers", limit) async def get_new_tokens(limit: int = 20) -> list[dict]: - tokens = await get_recommended_tokens(limit=300) - tokens.sort(key=lambda t: t.get("created_timestamp", 0), reverse=True) - return tokens[:limit] + """Newest coins first.""" + return await _board("new", limit) async def get_graduating_tokens(limit: int = 20) -> list[dict]: - tokens = await get_recommended_tokens(limit=300) - bonding = [t for t in tokens if not t.get("complete", True) and not t.get("pump_swap_pool")] - bonding.sort(key=lambda t: t.get("real_sol_reserves", 0), reverse=True) - return bonding[:limit] + """Coins pump.fun ranks as closest to graduating.""" + return await _board("graduating", limit) + + +async def get_recommended_tokens(limit: int = 10) -> list[dict]: + """pump.fun's own recommendations; it serves at most 10.""" + path = "/coins/recommendations" + rows = await _get(path, {"surface": "search_entry", "limit": min(limit, _RECOMMENDATIONS_MAX)}) + return _pump_coins(rows, limit, path) async def search_tokens(query: str, limit: int = 20) -> list[dict]: - tokens = await get_recommended_tokens(limit=300) - q = query.lower() - return [ - t - for t in tokens - if q in (t.get("name") or "").lower() - or q in (t.get("symbol") or "").lower() - or q in (t.get("description") or "").lower() - ][:limit] + """Search pump.fun coins by name or ticker, older coins included.""" + path = "/coins/search-unrestricted" + rows = await _get(path, {"searchTerm": query, "limit": _SEARCH_MAX}) + return _pump_coins(rows, limit, path) + + +async def enrich_from_chain(tokens: list[dict], rpc_url: str) -> list[dict]: + """Add price (in each coin's quote asset) and bonding progress read from the curves. + + A coin whose curve cannot be read keeps the API's fields only. + + Raises: + RuntimeError: If the RPC endpoint fails; the caller can still show the listing + """ + if not tokens: + return tokens + try: + return await _enrich(tokens, rpc_url) + except SolanaRpcException as exc: + # Rebuilt without the original message, which can carry the endpoint URL. + raise RuntimeError(f"RPC request failed ({type(exc).__name__})") from None + + +async def _enrich(tokens: list[dict], rpc_url: str) -> list[dict]: + client = RpcClient(rpc_url) + try: + global_state = await fetch_global(client) + initial_real = global_state["initial_real_token_reserves"] + curves = [derive_bonding_curve(Pubkey.from_string(t["mint"])) for t in tokens] + accounts = [] + for i in range(0, len(curves), _MULTIPLE_ACCOUNTS_MAX): + accounts += await client.get_multiple_accounts(curves[i : i + _MULTIPLE_ACCOUNTS_MAX]) + decimals: dict[Pubkey, int] = {} + for token, account in zip(tokens, accounts, strict=True): + if account is None: + continue + try: + state = parse_bonding_curve(bytes(account.data)) + except ValueError: + continue + quote_mint = state["quote_mint"] + if quote_mint not in decimals: + try: + decimals[quote_mint] = (await get_mint_info(client, quote_mint))[1] + except RuntimeError: + continue + token["quote_mint"] = str(quote_mint) + token["quote_symbol"] = quote_symbol(quote_mint) + token["complete"] = state["complete"] + token["bonding_progress"] = round(get_bonding_progress(state, initial_real) * 100, 1) + token["price"] = ( + None if state["complete"] else get_token_price(state, decimals[quote_mint]) + ) + return tokens + finally: + await client.close() diff --git a/src/pumpfun_cli/core/trade.py b/src/pumpfun_cli/core/trade.py index 0a6a6f8..6a43a6b 100644 --- a/src/pumpfun_cli/core/trade.py +++ b/src/pumpfun_cli/core/trade.py @@ -142,6 +142,11 @@ async def buy_token( return {"error": "not_found", "message": f"No bonding curve for {mint_str}"} if state["complete"]: return {"error": "graduated", "message": "Token has graduated to PumpSwap."} + if state["creator"] is None: + return { + "error": "legacy_curve", + "message": "This coin's bonding curve predates the creator field and cannot be traded.", + } quote_mint = state["quote_mint"] symbol = quote_symbol(quote_mint) @@ -280,6 +285,11 @@ async def sell_token( return {"error": "not_found", "message": f"No bonding curve for {mint_str}"} if state["complete"]: return {"error": "graduated", "message": "Token has graduated to PumpSwap."} + if state["creator"] is None: + return { + "error": "legacy_curve", + "message": "This coin's bonding curve predates the creator field and cannot be traded.", + } quote_mint = state["quote_mint"] symbol = quote_symbol(quote_mint) diff --git a/src/pumpfun_cli/protocol/curve.py b/src/pumpfun_cli/protocol/curve.py index abf8aa7..4b65f8a 100644 --- a/src/pumpfun_cli/protocol/curve.py +++ b/src/pumpfun_cli/protocol/curve.py @@ -1,8 +1,9 @@ """BondingCurve decoding and curve math. A curve is 125 bytes as create_v2 allocates it, 151 for older coins, and -extend_account can grow it further. Every field read here sits at a fixed -offset regardless of length, so any length is decoded the same way. +extend_account can grow it further. The oldest coins still carry the original +49-byte curve, which ends after `complete`. Every field sits at a fixed offset, +so any length is decoded the same way and a field past the end reads as absent. """ import struct @@ -28,16 +29,17 @@ def parse_bonding_curve(data: bytes) -> dict: """Decode a BondingCurve account. `quote_mint` comes back normalized (wrapped SOL for a SOL-paired coin). - `creator_fee_bps` and `is_holder_reward` are None on a curve too short to - carry them, i.e. one created before those fields existed. + A field the curve is too short to carry predates that feature: `creator`, + `creator_fee_bps` and `is_holder_reward` read as None, the mayhem and + cashback flags as False, and the quote asset as SOL. Raises: - ValueError: If the discriminator is wrong or the data ends before a - field every trade needs + ValueError: If the discriminator is wrong or the data ends before the + reserves """ if data[:8] != BONDING_CURVE_DISCRIMINATOR: raise ValueError("Account is not a pump.fun bonding curve") - if len(data) <= _CASHBACK_OFFSET: + if len(data) < _CREATOR_OFFSET: raise ValueError(f"Bonding curve data is only {len(data)} bytes") ( virtual_token, @@ -68,9 +70,13 @@ def parse_bonding_curve(data: bytes) -> dict: "real_quote_reserves": real_quote, "token_total_supply": supply, "complete": bool(complete), - "creator": Pubkey.from_bytes(data[_CREATOR_OFFSET : _CREATOR_OFFSET + 32]), - "is_mayhem_mode": bool(data[_MAYHEM_OFFSET]), - "is_cashback_coin": bool(data[_CASHBACK_OFFSET]), + "creator": ( + Pubkey.from_bytes(data[_CREATOR_OFFSET : _CREATOR_OFFSET + 32]) + if len(data) >= _CREATOR_OFFSET + 32 + else None + ), + "is_mayhem_mode": len(data) > _MAYHEM_OFFSET and bool(data[_MAYHEM_OFFSET]), + "is_cashback_coin": len(data) > _CASHBACK_OFFSET and bool(data[_CASHBACK_OFFSET]), "quote_mint": normalize_quote_mint(raw_quote), "is_sol_paired": is_sol(raw_quote), "creator_fee_bps": creator_fee_bps, diff --git a/tests/test_commands/test_human_output.py b/tests/test_commands/test_human_output.py index 22a4b6a..e4fa205 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -217,3 +217,71 @@ def test_launch_rejects_negative_buy(wallet): ) assert result.exit_code != 0 launch.assert_not_called() + + +def test_tokens_table_names_quote_and_survives_missing_chain_data(): + rows = [ + { + "name": "A", + "symbol": "A", + "mint": "m1", + "quote_symbol": "SOL", + "price": 3.2e-7, + "bonding_progress": 96.2, + "usd_market_cap": 31_800, + }, + { + "name": "B", + "symbol": "B", + "mint": "m2", + "quote_symbol": "6FhVPctYg363pQbs4iSv", + "price": 0.0093, + "bonding_progress": 96.3, + "usd_market_cap": 1_200_000, + }, + {"name": "C", "symbol": "C", "mint": "m3", "quote_symbol": "SOL", "usd_market_cap": None}, + ] + with ( + _human("tokens"), + patch( + "pumpfun_cli.commands.tokens.get_graduating_tokens", new_callable=AsyncMock + ) as fetch, + patch("pumpfun_cli.commands.tokens.render_table") as table, + ): + fetch.return_value = rows + result = runner.invoke(app, ["tokens", "graduating"]) + assert result.exit_code == 0, result.output + shown = table.call_args.args[0] + assert [t["_price"] for t in shown] == ["3.2e-07 SOL", "0.0093 6FhV…", "—"] + assert [t["_mcap"] for t in shown] == ["$31.8K", "$1.2M", ""] + + +def test_tokens_api_failure_is_a_clean_error(): + from pumpfun_cli.core.tokens import TokenApiError + + with patch( + "pumpfun_cli.commands.tokens.get_new_tokens", + new=AsyncMock(side_effect=TokenApiError("pump.fun API returned HTTP 404 for /x")), + ): + result = runner.invoke(app, ["tokens", "new"]) + assert result.exit_code == 1 + assert "404" in result.output + + +def test_tokens_listing_survives_an_rpc_failure(monkeypatch): + monkeypatch.setenv("PUMPFUN_RPC", "http://rpc.test") + rows = [{"name": "A", "symbol": "A", "mint": "m1", "quote_symbol": "SOL", "usd_market_cap": 1}] + with ( + _human("tokens"), + patch("pumpfun_cli.commands.tokens.get_new_tokens", new_callable=AsyncMock) as fetch, + patch( + "pumpfun_cli.commands.tokens.enrich_from_chain", + new=AsyncMock(side_effect=RuntimeError("RPC request failed")), + ), + patch("pumpfun_cli.commands.tokens.render_table") as table, + ): + fetch.return_value = rows + result = runner.invoke(app, ["tokens", "new"]) + assert result.exit_code == 0 + assert table.called + assert "Could not read prices from chain" in result.output diff --git a/tests/test_core/test_tokens.py b/tests/test_core/test_tokens.py index 5eb7a1e..cd048c9 100644 --- a/tests/test_core/test_tokens.py +++ b/tests/test_core/test_tokens.py @@ -1,224 +1,250 @@ +"""Token discovery: the endpoints used, the pump.fun-only filter, and chain enrichment.""" + +from unittest.mock import patch + import httpx import pytest -import respx +from solders.pubkey import Pubkey from pumpfun_cli.core.tokens import ( + PUMP_API, + SOLANA_CHAIN_ID, + TokenApiError, + enrich_from_chain, get_graduating_tokens, + get_new_tokens, get_recommended_tokens, get_trending_tokens, search_tokens, ) +from pumpfun_cli.protocol.address import derive_bonding_curve +from pumpfun_cli.protocol.contracts import USDC_MINT +from tests.test_core.helpers import FakeClient, build_curve_data, build_global_data + +PUMP_GLOBAL_DATA = build_global_data() + + +def _row( + symbol: str, + *, + program: str | None = "pump", + chain: str = SOLANA_CHAIN_ID, + quote: str | None = None, + mint: str | None = None, +) -> dict: + return { + "mint": mint or str(Pubkey.new_unique()), + "name": symbol.title(), + "symbol": symbol, + "chain_id": chain, + "program": program, + "quote_mint": quote, + "usd_market_cap": 1234.5, + "complete": False, + "created_timestamp": 1, + "last_trade_timestamp": 2, + "mayhem": None, + "is_cashback_enabled": False, + "is_holder_reward": True, + } -PUMP_API = "https://frontend-api-v3.pump.fun" -MOCK_RECOMMENDED = [ - { - "name": "TestToken", - "symbol": "TEST", - "mint": "abc123", - "usd_market_cap": 100000, - "created_timestamp": 1000, - "complete": False, - "real_sol_reserves": 50_000_000_000, - "virtual_sol_reserves": 30_000_000_000, - "virtual_token_reserves": 1_000_000_000_000, - "description": "A test token", - }, - { - "name": "Another", - "symbol": "ANO", - "mint": "def456", - "usd_market_cap": 50000, - "created_timestamp": 2000, - "complete": False, - "real_sol_reserves": 10_000_000_000, - "virtual_sol_reserves": 10_000_000_000, - "virtual_token_reserves": 500_000_000_000, - "description": "Another token", - }, -] - - -@pytest.mark.asyncio -@respx.mock -async def test_get_recommended(): - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=MOCK_RECOMMENDED) +OTHER_LAUNCHPAD = _row("MDBC", program="meteora_dbc") +EVM = _row("EVM", program=None, chain="eip155:56") + + +@pytest.mark.parametrize( + "fn,board", + [ + (get_trending_tokens, "movers"), + (get_new_tokens, "new"), + (get_graduating_tokens, "graduating"), + ], +) +async def test_boards_keep_only_pump_coins(respx_mock, fn, board): + route = respx_mock.get(f"{PUMP_API}/coins/boards/{board}").mock( + return_value=httpx.Response( + 200, json={"coins": [OTHER_LAUNCHPAD, _row("ONE"), EVM, _row("TWO")]} + ) ) - tokens = await get_recommended_tokens(limit=10) - assert len(tokens) == 2 - assert tokens[0]["symbol"] == "TEST" - assert "price_sol" in tokens[0] - assert "bonding_progress" in tokens[0] - - -@pytest.mark.asyncio -@respx.mock -async def test_search_filters_by_name(): - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=MOCK_RECOMMENDED) + tokens = await fn(limit=5) + assert [t["symbol"] for t in tokens] == ["ONE", "TWO"] + assert route.call_count == 1 # a short page is the end of the board + assert route.calls.last.request.headers["user-agent"].startswith("pumpfun-cli") + + +async def test_board_pages_until_the_limit(respx_mock): + edge = str(Pubkey.new_unique()) + first = [_row(f"A{i}") for i in range(3)] + [EVM] * 146 + [_row("EDGE", mint=edge)] + second = [_row("EDGE", mint=edge)] + [_row(f"B{i}") for i in range(5)] + route = respx_mock.get(f"{PUMP_API}/coins/boards/new").mock( + side_effect=[ + httpx.Response(200, json={"coins": first}), + httpx.Response(200, json={"coins": second}), + ] ) - results = await search_tokens("test", limit=10) - assert len(results) == 1 - assert results[0]["name"] == "TestToken" + tokens = await get_new_tokens(limit=6) + assert [t["symbol"] for t in tokens] == ["A0", "A1", "A2", "EDGE", "B0", "B1"] + assert route.calls[1].request.url.params["offset"] == "149" + assert route.call_count == 2 -@pytest.mark.asyncio -@respx.mock -async def test_enriched_tokens_have_price_and_progress(): - """Tokens with reserve fields should have price_sol (float > 0) and bonding_progress (0-100).""" - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=MOCK_RECOMMENDED) +async def test_board_stops_on_an_empty_page(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/boards/graduating").mock( + return_value=httpx.Response(200, json={"coins": []}) ) - tokens = await get_recommended_tokens(limit=10) - for t in tokens: - assert isinstance(t["price_sol"], float) - assert t["price_sol"] > 0 - assert isinstance(t["bonding_progress"], float) - assert 0 <= t["bonding_progress"] <= 100 - - -@pytest.mark.asyncio -@respx.mock -async def test_enriched_tokens_missing_reserves_fields(): - """Tokens missing reserve fields should have price_sol and bonding_progress as None.""" - bare_token = [ - { - "name": "Bare", - "symbol": "BARE", - "mint": "bare111", - "complete": False, - "description": "no reserves", - } - ] - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=bare_token) + assert await get_graduating_tokens(limit=5) == [] + + +async def test_recommendations_are_capped_at_ten(respx_mock): + route = respx_mock.get(f"{PUMP_API}/coins/recommendations").mock( + return_value=httpx.Response(200, json=[_row("REC")]) ) - tokens = await get_recommended_tokens(limit=10) - assert tokens[0]["price_sol"] is None - assert tokens[0]["bonding_progress"] is None - - -@pytest.mark.asyncio -@respx.mock -async def test_enriched_tokens_zero_token_reserves(): - """Token with virtual_token_reserves: 0 should have price_sol == 0.0.""" - zero_token = [ - { - "name": "Zero", - "symbol": "ZERO", - "mint": "zero111", - "complete": False, - "virtual_sol_reserves": 10_000_000_000, - "virtual_token_reserves": 0, - "real_sol_reserves": 5_000_000_000, - "description": "zero reserves", - } - ] - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=zero_token) + await get_recommended_tokens(limit=50) + params = route.calls.last.request.url.params + assert params["surface"] == "search_entry" and params["limit"] == "10" + + +async def test_search_is_server_side(respx_mock): + route = respx_mock.get(f"{PUMP_API}/coins/search-unrestricted").mock( + return_value=httpx.Response(200, json=[EVM, _row("PEPE"), OTHER_LAUNCHPAD]) ) - tokens = await get_recommended_tokens(limit=10) - assert tokens[0]["price_sol"] == 0.0 - assert isinstance(tokens[0]["bonding_progress"], float) - - -@pytest.mark.asyncio -@respx.mock -async def test_graduating_filters_pump_swap_pool(): - """Tokens with pump_swap_pool set should be excluded from graduating list.""" - tokens_data = [ - { - "name": "Normal", - "symbol": "NRM", - "mint": "nrm111", - "complete": False, - "real_sol_reserves": 50_000_000_000, - "virtual_sol_reserves": 30_000_000_000, - "virtual_token_reserves": 1_000_000_000_000, - }, - { - "name": "Swapped", - "symbol": "SWP", - "mint": "swp111", - "complete": False, - "real_sol_reserves": 70_000_000_000, - "virtual_sol_reserves": 40_000_000_000, - "virtual_token_reserves": 800_000_000_000, - "pump_swap_pool": "somePoolAddress", - }, - { - "name": "Completed", - "symbol": "CMP", - "mint": "cmp111", - "complete": True, - "real_sol_reserves": 85_000_000_000, - "virtual_sol_reserves": 50_000_000_000, - "virtual_token_reserves": 500_000_000_000, - }, - ] - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=tokens_data) + tokens = await search_tokens("pepe", limit=5) + assert [t["symbol"] for t in tokens] == ["PEPE"] + assert route.calls.last.request.url.params["searchTerm"] == "pepe" + + +async def test_summary_names_the_quote_asset(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/boards/new").mock( + return_value=httpx.Response( + 200, + json={ + "coins": [ + _row("SOLCOIN", quote="11111111111111111111111111111111"), + _row("OLD", quote=None), + _row("USD", quote=str(USDC_MINT)), + ] + }, + ) + ) + tokens = await get_new_tokens(limit=3) + assert [t["quote_symbol"] for t in tokens] == ["SOL", "SOL", "USDC"] + assert tokens[0]["is_holder_reward"] is True + assert set(tokens[0]) >= {"mint", "name", "symbol", "usd_market_cap", "complete"} + + +@pytest.mark.parametrize( + "response", + [httpx.Response(404, json={"message": "gone"}), httpx.Response(403, text="cf")], +) +async def test_api_failures_raise_a_clean_error(respx_mock, response): + respx_mock.get(f"{PUMP_API}/coins/boards/movers").mock(return_value=response) + with pytest.raises(TokenApiError, match=str(response.status_code)): + await get_trending_tokens() + + +async def test_transport_failure_raises_a_clean_error(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/boards/movers").mock(side_effect=httpx.ConnectTimeout("x")) + with pytest.raises(TokenApiError, match="ConnectTimeout"): + await get_trending_tokens() + + +async def test_enrich_reads_price_and_progress_from_the_curves(): + from pumpfun_cli.protocol.contracts import PUMP_GLOBAL + + sol_coin, usdc_coin, missing, done = (Pubkey.new_unique() for _ in range(4)) + client = FakeClient() + client.add(PUMP_GLOBAL, PUMP_GLOBAL_DATA) + client.add(derive_bonding_curve(sol_coin), build_curve_data(real_token=693_100_000_000_000)) + client.add( + derive_bonding_curve(usdc_coin), + build_curve_data(quote_mint=USDC_MINT, virtual_quote=5_000_000_000), ) - result = await get_graduating_tokens(limit=20) - assert len(result) == 1 - assert result[0]["symbol"] == "NRM" - - -@pytest.mark.asyncio -@respx.mock -async def test_graduating_keeps_tokens_without_pool(): - """Tokens without pump_swap_pool and complete=False should all be included.""" - tokens_data = [ - { - "name": "A", - "symbol": "AAA", - "mint": "aaa111", - "complete": False, - "real_sol_reserves": 50_000_000_000, - "virtual_sol_reserves": 30_000_000_000, - "virtual_token_reserves": 1_000_000_000_000, - }, - { - "name": "B", - "symbol": "BBB", - "mint": "bbb111", - "complete": False, - "real_sol_reserves": 40_000_000_000, - "virtual_sol_reserves": 20_000_000_000, - "virtual_token_reserves": 900_000_000_000, - }, + client.add(derive_bonding_curve(done), build_curve_data(complete=True, length=49)) + tokens = [ + {"mint": str(m), "quote_symbol": "SOL"} for m in (sol_coin, usdc_coin, missing, done) ] - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=tokens_data) + with patch("pumpfun_cli.core.tokens.RpcClient", return_value=client): + enriched = await enrich_from_chain(tokens, "http://rpc") + sol, usdc, gone, graduated = enriched + assert sol["price"] == pytest.approx(3e-8) + assert sol["bonding_progress"] == pytest.approx(12.6, abs=0.1) + assert usdc["quote_symbol"] == "USDC" and usdc["price"] == pytest.approx(5e-6) + assert "price" not in gone + assert graduated["price"] is None and graduated["bonding_progress"] == 100.0 + + +async def test_error_carries_pump_funs_own_message(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/boards/movers").mock( + return_value=httpx.Response(400, json={"message": ["board must be one of: new"]}) + ) + with pytest.raises(TokenApiError, match="400.*board must be one of: new"): + await get_trending_tokens() + + +async def test_html_error_page_adds_nothing(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/boards/movers").mock( + return_value=httpx.Response(403, text="challenge") + ) + with pytest.raises(TokenApiError) as info: + await get_trending_tokens() + assert "DOCTYPE" not in str(info.value) + + +async def test_non_json_success_is_a_clean_error(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/search-unrestricted").mock( + return_value=httpx.Response(200, text="challenge") ) - result = await get_graduating_tokens(limit=20) - assert len(result) == 2 - - -@pytest.mark.asyncio -@respx.mock -async def test_trending_tokens_enriched(): - """Trending results should include price_sol and bonding_progress.""" - runners = [{"coin": MOCK_RECOMMENDED[0]}] - respx.get(f"{PUMP_API}/coins/top-runners").mock(return_value=httpx.Response(200, json=runners)) - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=MOCK_RECOMMENDED) + with pytest.raises(TokenApiError, match="non-JSON"): + await search_tokens("pepe") + + +@pytest.mark.parametrize("body", [{"coins": "nope"}, {"other": []}, ["not", "a", "board"]]) +async def test_unexpected_board_shape_is_a_clean_error(respx_mock, body): + respx_mock.get(f"{PUMP_API}/coins/boards/new").mock( + return_value=httpx.Response(200, json=body) + ) + with pytest.raises(TokenApiError, match="unexpected shape"): + await get_new_tokens() + + +async def test_unexpected_search_shape_is_a_clean_error(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/recommendations").mock( + return_value=httpx.Response(200, json={"coins": []}) ) - tokens = await get_trending_tokens(limit=5) - for t in tokens: - assert "price_sol" in t - assert "bonding_progress" in t - - -@pytest.mark.asyncio -@respx.mock -async def test_search_tokens_enriched(): - """Search results should include price_sol and bonding_progress.""" - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=MOCK_RECOMMENDED) + with pytest.raises(TokenApiError, match="unexpected shape"): + await get_recommended_tokens() + + +async def test_malformed_rows_are_skipped_not_fatal(respx_mock): + respx_mock.get(f"{PUMP_API}/coins/search-unrestricted").mock( + return_value=httpx.Response( + 200, + json=[ + "a string", + _row("BADQUOTE", quote="not-a-pubkey"), + _row("BADMINT", mint="nope"), + _row("GOOD"), + ], + ) + ) + assert [t["symbol"] for t in await search_tokens("x")] == ["GOOD"] + + +async def test_rpc_failure_during_enrichment_is_a_runtime_error_without_the_url(): + from unittest.mock import AsyncMock + + from solana.exceptions import SolanaRpcException + + client = FakeClient() + client.get_account_info = AsyncMock( + side_effect=SolanaRpcException( + TimeoutError("https://rpc.test/secret-api-key timed out"), lambda: None, None, object() + ) ) - results = await search_tokens("test", limit=10) - assert len(results) == 1 - assert "price_sol" in results[0] - assert "bonding_progress" in results[0] + with ( + patch("pumpfun_cli.core.tokens.RpcClient", return_value=client), + pytest.raises(RuntimeError) as info, + ): + await enrich_from_chain([{"mint": str(Pubkey.new_unique())}], "https://rpc.test") + assert "secret-api-key" not in str(info.value) diff --git a/tests/test_core/test_trade.py b/tests/test_core/test_trade.py index 5f2bd4e..f2bba2f 100644 --- a/tests/test_core/test_trade.py +++ b/tests/test_core/test_trade.py @@ -357,3 +357,12 @@ async def test_collect_ignores_the_vaults_rent_floor(tmp_keystore): result = await collect_creator_fees("http://rpc", tmp_keystore, "testpass") assert result["error"] == "no_fees" assert client.sent == [] + + +@pytest.mark.parametrize("fn,arg", [(buy_token, 0.01), (sell_token, "all")]) +async def test_legacy_curve_without_creator_is_refused(tmp_keystore, fn, arg): + client = chain_with_coin(curve=build_curve_data(length=49)) + with _patched(client): + result = await fn("http://rpc", tmp_keystore, "testpass", MINT_STR, arg) + assert result["error"] == "legacy_curve" + assert client.sent == [] diff --git a/tests/test_protocol/test_curve.py b/tests/test_protocol/test_curve.py index 93b5cc8..46e1bc5 100644 --- a/tests/test_protocol/test_curve.py +++ b/tests/test_protocol/test_curve.py @@ -38,11 +38,6 @@ def test_short_curve_reports_new_fields_as_absent(): assert state["is_holder_reward"] is None -def test_curve_without_flags_is_refused(): - with pytest.raises(ValueError): - parse_bonding_curve(build_curve_data(length=82)) - - def test_wrong_discriminator_is_refused(): data = bytearray(build_curve_data()) data[0] ^= 0xFF @@ -53,3 +48,16 @@ def test_wrong_discriminator_is_refused(): def test_holder_reward_curve_creator_is_read_as_stored(): pda = Pubkey.new_unique() assert parse_bonding_curve(build_curve_data(creator=pda))["creator"] == pda + + +def test_original_49_byte_curve_decodes_with_absent_fields(): + state = parse_bonding_curve(build_curve_data(complete=True, length=49)) + assert state["complete"] is True + assert state["creator"] is None + assert state["is_mayhem_mode"] is False and state["is_cashback_coin"] is False + assert state["quote_mint"] == WSOL_MINT + + +def test_curve_shorter_than_its_reserves_is_refused(): + with pytest.raises(ValueError): + parse_bonding_curve(build_curve_data(length=48))