From a1760b508c2a858aabdc67f75971a311e471a40c Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 1 Nov 2026 16:03:54 +0100 Subject: [PATCH 1/2] fix(tokens): list coins from pump.fun's current boards and search pump.fun removed the frontend API routes the listings used. trending, new and graduating now read its movers, new and graduating boards, recommended its recommendations, and search its server-side search. Rows are narrowed to pump.fun coins on Solana and reshaped into one record that names the quote asset; with an RPC set, price and bonding progress come from the curves, since the API no longer returns reserves. API failures are reported instead of a traceback. info no longer crashes on the original 49-byte bonding curve, and trading such a curve, which has no creator, is refused. --- README.md | 26 +- scripts/mainnet-test.sh | 19 +- src/pumpfun_cli/commands/info.py | 2 +- src/pumpfun_cli/commands/tokens.py | 61 ++-- src/pumpfun_cli/core/info.py | 2 +- src/pumpfun_cli/core/tokens.py | 230 ++++++++++----- src/pumpfun_cli/core/trade.py | 10 + src/pumpfun_cli/protocol/curve.py | 26 +- tests/test_commands/test_human_output.py | 49 ++++ tests/test_core/test_tokens.py | 357 ++++++++++------------- tests/test_core/test_trade.py | 9 + tests/test_protocol/test_curve.py | 18 +- 12 files changed, 469 insertions(+), 340 deletions(-) diff --git a/README.md b/README.md index 68158ae..0f8f6a1 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,17 @@ 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 | + +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. | `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 | diff --git a/scripts/mainnet-test.sh b/scripts/mainnet-test.sh index ccb959f..a7f6b86 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']) 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..2ded58a 100644 --- a/src/pumpfun_cli/core/tokens.py +++ b/src/pumpfun_cli/core/tokens.py @@ -1,97 +1,169 @@ -"""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 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}") + return resp.json() + + +def _is_pump_coin(row: dict) -> bool: + return row.get("chain_id") == SOLANA_CHAIN_ID and row.get("program") == PUMP_PROGRAM_LABEL + + +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: list[dict], limit: int) -> list[dict]: + 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 [] + 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.""" + rows = await _get( + "/coins/recommendations", + {"surface": "search_entry", "limit": min(limit, _RECOMMENDATIONS_MAX)}, + ) + return _pump_coins(rows if isinstance(rows, list) else [], limit) 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.""" + rows = await _get( + "/coins/search-unrestricted", + {"searchTerm": query, "limit": _SEARCH_MAX}, + ) + return _pump_coins(rows if isinstance(rows, list) else [], limit) + + +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. + """ + if not tokens: + return tokens + 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..1f63a80 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -217,3 +217,52 @@ 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 diff --git a/tests/test_core/test_tokens.py b/tests/test_core/test_tokens.py index 5eb7a1e..5b99c80 100644 --- a/tests/test_core/test_tokens.py +++ b/tests/test_core/test_tokens.py @@ -1,224 +1,173 @@ +"""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) - ) - 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) - ) - results = await search_tokens("test", limit=10) - assert len(results) == 1 - assert results[0]["name"] == "TestToken" +OTHER_LAUNCHPAD = _row("MDBC", program="meteora_dbc") +EVM = _row("EVM", program=None, chain="eip155:56") -@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) +@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) - 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) + 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): + 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}), + ] ) - 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) + 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 + + +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) - 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) + 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")]) ) - 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, - }, - ] - respx.get(f"{PUMP_API}/coins/recommended").mock( - return_value=httpx.Response(200, json=tokens_data) + 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]) ) - 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) + 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_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) + 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), ) - results = await search_tokens("test", limit=10) - assert len(results) == 1 - assert "price_sol" in results[0] - assert "bonding_progress" in results[0] + 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) + ] + 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 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)) From 5c8bff1c93feedb1adf79b75b814410df7eb0bc8 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 1 Nov 2026 16:24:19 +0100 Subject: [PATCH 2/2] fix(tokens): report malformed responses and RPC failures cleanly A non-JSON or unexpectedly shaped API response now raises the listing error, a row whose mint or quote asset is not a pubkey is skipped, pump.fun's own error message is kept, and an RPC failure during enrichment falls back to the API listing without echoing the endpoint. The README note sits below the endpoints table, and the e2e PumpSwap candidates use the new record. --- README.md | 10 +-- scripts/mainnet-test.sh | 2 +- src/pumpfun_cli/core/tokens.py | 71 ++++++++++++++++----- tests/test_commands/test_human_output.py | 19 ++++++ tests/test_core/test_tokens.py | 81 +++++++++++++++++++++++- 5 files changed, 159 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0f8f6a1..1ab66e4 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,6 @@ Global options: | `tokens recommended` | `frontend-api-v3.pump.fun/coins/recommendations` | HTTPS | | `tokens search` | `frontend-api-v3.pump.fun/coins/search-unrestricted` | HTTPS | | `tokens *` with an RPC set | + Solana RPC (`getMultipleAccounts` for the bonding curves) | JSON-RPC | - -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. | `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 | @@ -160,6 +155,11 @@ chain and need an RPC endpoint. | `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 a7f6b86..5c412c5 100755 --- a/scripts/mainnet-test.sh +++ b/scripts/mainnet-test.sh @@ -308,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/core/tokens.py b/src/pumpfun_cli/core/tokens.py index 2ded58a..7ea07cd 100644 --- a/src/pumpfun_cli/core/tokens.py +++ b/src/pumpfun_cli/core/tokens.py @@ -7,6 +7,7 @@ """ import httpx +from solana.exceptions import SolanaRpcException from solders.pubkey import Pubkey from pumpfun_cli.protocol.address import derive_bonding_curve @@ -43,12 +44,39 @@ async def _get(path: str, params: dict) -> object: 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}") - return resp.json() + 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 _is_pump_coin(row: dict) -> bool: - return row.get("chain_id") == SOLANA_CHAIN_ID and row.get("program") == PUMP_PROGRAM_LABEL +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: @@ -71,7 +99,9 @@ def _summarize(row: dict) -> dict: } -def _pump_coins(rows: list[dict], limit: int) -> list[dict]: +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] @@ -85,7 +115,9 @@ async def _board(board: str, limit: int) -> list[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 [] + 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) @@ -112,29 +144,36 @@ async def get_graduating_tokens(limit: int = 20) -> list[dict]: async def get_recommended_tokens(limit: int = 10) -> list[dict]: """pump.fun's own recommendations; it serves at most 10.""" - rows = await _get( - "/coins/recommendations", - {"surface": "search_entry", "limit": min(limit, _RECOMMENDATIONS_MAX)}, - ) - return _pump_coins(rows if isinstance(rows, list) else [], limit) + 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]: """Search pump.fun coins by name or ticker, older coins included.""" - rows = await _get( - "/coins/search-unrestricted", - {"searchTerm": query, "limit": _SEARCH_MAX}, - ) - return _pump_coins(rows if isinstance(rows, list) else [], limit) + 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) diff --git a/tests/test_commands/test_human_output.py b/tests/test_commands/test_human_output.py index 1f63a80..e4fa205 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -266,3 +266,22 @@ def test_tokens_api_failure_is_a_clean_error(): 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 5b99c80..cd048c9 100644 --- a/tests/test_core/test_tokens.py +++ b/tests/test_core/test_tokens.py @@ -74,8 +74,9 @@ async def test_boards_keep_only_pump_coins(respx_mock, fn, board): async def test_board_pages_until_the_limit(respx_mock): - 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)] + 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}), @@ -171,3 +172,79 @@ async def test_enrich_reads_price_and_progress_from_the_curves(): 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") + ) + 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": []}) + ) + 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() + ) + ) + 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)