From c63c907c6b9df00730dc09fe8a622df944eaa3e1 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 1 Nov 2026 16:52:06 +0100 Subject: [PATCH 1/3] fix: report exact raw amounts and send SOL transfers exactly Every amount in a result now has a `_raw` twin holding the exact integer the transaction used; the whole-unit float stays for reading but can drop a raw unit on large counts. `wallet transfer` scales a SOL amount through its decimal text, so 0.00013 SOL sends 130,000 lamports rather than 129,999. --- README.md | 4 ++ src/pumpfun_cli/core/amounts.py | 9 +++ src/pumpfun_cli/core/launch.py | 5 +- src/pumpfun_cli/core/pumpswap.py | 22 +++---- src/pumpfun_cli/core/trade.py | 26 ++++---- src/pumpfun_cli/core/wallet.py | 12 ++-- src/pumpfun_cli/protocol/contracts.py | 1 + tests/test_core/test_exact_amounts.py | 87 +++++++++++++++++++++++++++ 8 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 tests/test_core/test_exact_amounts.py diff --git a/README.md b/README.md index 1ab66e4..64b863c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ but a coin can be priced in USDC or another admitted mint; `buy`, `sell` and `info` read it from the coin and label their output with it. Buying such a coin spends that asset, so the wallet has to hold it. +In `--json` output every amount comes twice: in whole units for reading +(`tokens_sold`) and as the exact integer in raw units (`tokens_sold_raw`). Use the +`_raw` field for anything that has to add up; the float can drop a unit. + ## Commands ``` diff --git a/src/pumpfun_cli/core/amounts.py b/src/pumpfun_cli/core/amounts.py index 685759d..6e8538c 100644 --- a/src/pumpfun_cli/core/amounts.py +++ b/src/pumpfun_cli/core/amounts.py @@ -11,3 +11,12 @@ def to_raw(amount: float | str, decimals: int) -> int: """ scaled = Decimal(str(amount)) * (Decimal(10) ** decimals) return int(scaled.to_integral_value(ROUND_DOWN)) + + +def amount_fields(name: str, raw: int, decimals: int) -> dict: + """An amount for output: `name` in whole units for reading, `name_raw` exact. + + The float can lose precision on large raw counts; `name_raw` is what was + actually used on chain. + """ + return {name: raw / 10**decimals, f"{name}_raw": raw} diff --git a/src/pumpfun_cli/core/launch.py b/src/pumpfun_cli/core/launch.py index b3abac8..d614175 100644 --- a/src/pumpfun_cli/core/launch.py +++ b/src/pumpfun_cli/core/launch.py @@ -7,7 +7,7 @@ from solders.keypair import Keypair from spl.token.instructions import create_idempotent_associated_token_account -from pumpfun_cli.core.amounts import to_raw +from pumpfun_cli.core.amounts import amount_fields, to_raw from pumpfun_cli.core.validate import parse_pubkey from pumpfun_cli.crypto import decrypt_keypair from pumpfun_cli.protocol.address import derive_bonding_curve, find_ata, find_holder_rewards @@ -248,7 +248,8 @@ async def launch_token( "is_holder_reward": is_holder_reward, "creator_fee_bps": creator_fee_bps or 0, "initial_buy": initial_buy, - "expected_tokens": expected_tokens / 10**TOKEN_DECIMALS, + "initial_buy_raw": amount_raw, + **amount_fields("expected_tokens", expected_tokens, TOKEN_DECIMALS), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", "pump_url": f"https://pump.fun/coin/{mint}", diff --git a/src/pumpfun_cli/core/pumpswap.py b/src/pumpfun_cli/core/pumpswap.py index 34af1f0..cd859b6 100644 --- a/src/pumpfun_cli/core/pumpswap.py +++ b/src/pumpfun_cli/core/pumpswap.py @@ -8,7 +8,7 @@ from solders.pubkey import Pubkey -from pumpfun_cli.core.amounts import to_raw +from pumpfun_cli.core.amounts import amount_fields, to_raw from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair from pumpfun_cli.protocol.address import ( @@ -167,12 +167,12 @@ async def buy_pumpswap( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "quote_in": amount, - "expected_tokens": expected_tokens, + **amount_fields("quote_in", amount_raw, quote_decimals), + **amount_fields("expected_tokens", estimated_tokens, base_decimals), "effective_price": effective_price, "spot_price": spot_price, "price_impact_pct": round(price_impact, 4), - "min_tokens_out": min_base_amount_out / 10**base_decimals, + **amount_fields("min_tokens_out", min_base_amount_out, base_decimals), "slippage_pct": slippage, } if shortfall: @@ -221,8 +221,8 @@ async def buy_pumpswap( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "quote_spent": amount, - "tokens_received": estimated_tokens / 10**base_decimals, + **amount_fields("quote_spent", amount_raw, quote_decimals), + **amount_fields("tokens_received", estimated_tokens, base_decimals), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } @@ -306,12 +306,12 @@ async def sell_pumpswap( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "tokens_in": tokens_human, - "expected_quote": quote_out_human, + **amount_fields("tokens_in", token_amount, base_decimals), + **amount_fields("expected_quote", expected_quote, quote_decimals), "effective_price": effective_price, "spot_price": spot_price, "price_impact_pct": round(price_impact, 4), - "min_quote_out": min_quote_out / 10**quote_decimals, + **amount_fields("min_quote_out", min_quote_out, quote_decimals), "slippage_pct": slippage, } @@ -345,8 +345,8 @@ async def sell_pumpswap( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "tokens_sold": token_amount / 10**base_decimals, - "quote_received": expected_quote / 10**quote_decimals, + **amount_fields("tokens_sold", token_amount, base_decimals), + **amount_fields("quote_received", expected_quote, quote_decimals), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } diff --git a/src/pumpfun_cli/core/trade.py b/src/pumpfun_cli/core/trade.py index 6a43a6b..bdc5f98 100644 --- a/src/pumpfun_cli/core/trade.py +++ b/src/pumpfun_cli/core/trade.py @@ -10,7 +10,7 @@ from solders.pubkey import Pubkey from spl.token.instructions import create_idempotent_associated_token_account -from pumpfun_cli.core.amounts import to_raw +from pumpfun_cli.core.amounts import amount_fields, to_raw from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair from pumpfun_cli.protocol.address import ( @@ -201,12 +201,12 @@ async def buy_token( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "quote_in": amount, - "expected_tokens": expected_tokens, + **amount_fields("quote_in", amount_raw, quote_decimals), + **amount_fields("expected_tokens", tokens_out, TOKEN_DECIMALS), "effective_price": effective_price, "spot_price": spot_price, "price_impact_pct": round(price_impact, 4), - "min_tokens_out": min_tokens_out / 10**TOKEN_DECIMALS, + **amount_fields("min_tokens_out", min_tokens_out, TOKEN_DECIMALS), "slippage_pct": slippage, } if shortfall: @@ -254,8 +254,8 @@ async def buy_token( mint=mint_str, quote_mint=str(quote_mint), quote_symbol=symbol, - quote_spent=amount, - tokens_received=tokens_out / 10**TOKEN_DECIMALS, + **amount_fields("quote_spent", amount_raw, quote_decimals), + **amount_fields("tokens_received", tokens_out, TOKEN_DECIMALS), ) finally: await client.close() @@ -336,12 +336,12 @@ async def sell_token( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "tokens_in": tokens_human, - "expected_quote": quote_out_human, + **amount_fields("tokens_in", token_amount, TOKEN_DECIMALS), + **amount_fields("expected_quote", quote_out, quote_decimals), "effective_price": effective_price, "spot_price": spot_price, "price_impact_pct": round(price_impact, 4), - "min_quote_out": min_quote_out / 10**quote_decimals, + **amount_fields("min_quote_out", min_quote_out, quote_decimals), "slippage_pct": slippage, } @@ -389,8 +389,8 @@ async def sell_token( mint=mint_str, quote_mint=str(quote_mint), quote_symbol=symbol, - tokens_sold=token_amount / 10**TOKEN_DECIMALS, - quote_received=quote_out / 10**quote_decimals, + **amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS), + **amount_fields("quote_received", quote_out, quote_decimals), ) finally: await client.close() @@ -603,8 +603,8 @@ async def collect_creator_fees( confirm, quote_mint=str(quote_mint), quote_symbol=quote_symbol(quote_mint), - pump_recovered=pump_balance / 10**quote_decimals, - pumpswap_recovered=pumpswap_balance / 10**quote_decimals, + **amount_fields("pump_recovered", pump_balance, quote_decimals), + **amount_fields("pumpswap_recovered", pumpswap_balance, quote_decimals), ) finally: await client.close() diff --git a/src/pumpfun_cli/core/wallet.py b/src/pumpfun_cli/core/wallet.py index 4511348..b37612b 100644 --- a/src/pumpfun_cli/core/wallet.py +++ b/src/pumpfun_cli/core/wallet.py @@ -6,9 +6,10 @@ from solders.keypair import Keypair from solders.pubkey import Pubkey +from pumpfun_cli.core.amounts import amount_fields, to_raw from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair, encrypt_keypair, get_pubkey -from pumpfun_cli.protocol.contracts import LAMPORTS_PER_SOL +from pumpfun_cli.protocol.contracts import LAMPORTS_PER_SOL, SOL_DECIMALS def create_wallet(password: str, keystore_path: str, force: bool = False) -> dict: @@ -118,7 +119,7 @@ async def transfer_sol( recipient = parse_pubkey(recipient_str, "recipient address") if recipient is None: return invalid_pubkey_error(recipient_str, "recipient address") - lamports = int(sol_amount * LAMPORTS_PER_SOL) + lamports = to_raw(sol_amount, SOL_DECIMALS) client = RpcClient(rpc_url) try: ix = transfer( @@ -139,7 +140,7 @@ async def transfer_sol( "action": "transfer", "from": str(keypair.pubkey()), "to": recipient_str, - "sol_amount": sol_amount, + **amount_fields("sol_amount", lamports, SOL_DECIMALS), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } @@ -204,7 +205,7 @@ async def transfer_all_sol( "action": "transfer", "from": str(keypair.pubkey()), "to": recipient_str, - "sol_amount": send_lamports / LAMPORTS_PER_SOL, + **amount_fields("sol_amount", send_lamports, SOL_DECIMALS), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } @@ -234,7 +235,6 @@ async def transfer_token( ) from spl.token.models import TransferCheckedParams - from pumpfun_cli.core.amounts import to_raw from pumpfun_cli.protocol.client import RpcClient from pumpfun_cli.protocol.quote import get_mint_info @@ -291,7 +291,7 @@ async def transfer_token( "from": str(keypair.pubkey()), "to": recipient_str, "mint": mint_str, - "amount": raw_amount / (10**decimals), + **amount_fields("amount", raw_amount, decimals), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } diff --git a/src/pumpfun_cli/protocol/contracts.py b/src/pumpfun_cli/protocol/contracts.py index 7d85505..22e18cd 100644 --- a/src/pumpfun_cli/protocol/contracts.py +++ b/src/pumpfun_cli/protocol/contracts.py @@ -8,6 +8,7 @@ # Solana constants LAMPORTS_PER_SOL = 1_000_000_000 +SOL_DECIMALS = 9 TOKEN_DECIMALS = 6 # every pump.fun coin has 6 decimals; other PumpSwap pools may not # System programs diff --git a/tests/test_core/test_exact_amounts.py b/tests/test_core/test_exact_amounts.py new file mode 100644 index 0000000..ecf347e --- /dev/null +++ b/tests/test_core/test_exact_amounts.py @@ -0,0 +1,87 @@ +"""Every reported amount carries an exact `_raw` twin, and what is sent is exact.""" + +from pathlib import Path +from unittest.mock import patch + +from solders.pubkey import Pubkey +from solders.system_program import decode_transfer + +from pumpfun_cli.core.amounts import amount_fields +from pumpfun_cli.core.pumpswap import buy_pumpswap, sell_pumpswap +from pumpfun_cli.core.trade import buy_token, collect_creator_fees, sell_token +from pumpfun_cli.core.wallet import transfer_sol +from pumpfun_cli.crypto import decrypt_keypair +from pumpfun_cli.protocol.address import derive_creator_vault, find_ata +from pumpfun_cli.protocol.contracts import TOKEN_2022_PROGRAM +from tests.test_core.helpers import ( + COIN, + FakeClient, + add_pool, + build_curve_data, + chain_with_coin, +) + +MINT_STR = str(COIN) +# A count the float round trip turns into one raw unit less. +LOSSY_RAW = 65_758_096_180 + + +def _user(tmp_keystore): + return decrypt_keypair(Path(tmp_keystore), "testpass").pubkey() + + +def test_amount_fields_keeps_the_exact_count(): + fields = amount_fields("tokens_sold", LOSSY_RAW, 6) + assert fields["tokens_sold_raw"] == LOSSY_RAW + assert int(fields["tokens_sold"] * 10**6) == LOSSY_RAW - 1 # why the twin exists + + +async def test_bonding_curve_buy_and_sell_report_raw(tmp_keystore): + client = chain_with_coin() + client.token_balances[find_ata(_user(tmp_keystore), COIN, TOKEN_2022_PROGRAM)] = LOSSY_RAW + with patch("pumpfun_cli.core.trade.RpcClient", return_value=client): + bought = await buy_token("http://rpc", tmp_keystore, "testpass", MINT_STR, 0.00013) + sold = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + dry = await sell_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, "all", dry_run=True + ) + assert bought["quote_spent_raw"] == 130_000 + assert isinstance(bought["tokens_received_raw"], int) + assert sold["tokens_sold_raw"] == LOSSY_RAW + assert dry["tokens_in_raw"] == LOSSY_RAW + assert {"expected_quote_raw", "min_quote_out_raw"} <= set(dry) + + +async def test_pumpswap_reports_raw(tmp_keystore): + client = chain_with_coin(curve=build_curve_data(complete=True, virtual_quote=0)) + add_pool(client) + client.token_balances[find_ata(_user(tmp_keystore), COIN, TOKEN_2022_PROGRAM)] = LOSSY_RAW + with patch("pumpfun_cli.core.pumpswap.RpcClient", return_value=client): + bought = await buy_pumpswap( + "http://rpc", tmp_keystore, "testpass", MINT_STR, 0.00013, dry_run=True + ) + sold = await sell_pumpswap("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + assert bought["quote_in_raw"] == 130_000 + assert {"expected_tokens_raw", "min_tokens_out_raw"} <= set(bought) + assert sold["tokens_sold_raw"] == LOSSY_RAW + + +async def test_collect_reports_raw(tmp_keystore): + client = FakeClient() + client.balances[derive_creator_vault(_user(tmp_keystore))] = 650_240 + 123_456_789 + with patch("pumpfun_cli.core.trade.RpcClient", return_value=client): + result = await collect_creator_fees("http://rpc", tmp_keystore, "testpass") + assert result["pump_recovered_raw"] == 123_456_789 + + +async def test_sol_transfer_sends_the_exact_amount(tmp_keystore): + client = FakeClient() + recipient = Pubkey.new_unique() + with patch("pumpfun_cli.protocol.client.RpcClient", return_value=client): + result = await transfer_sol( + "http://rpc", tmp_keystore, "testpass", str(recipient), 0.00013 + ) + sent = decode_transfer(client.sent[0]["instructions"][0]) + assert int(0.00013 * 10**9) == 129_999 # what the float conversion sent + assert sent["lamports"] == 130_000 + assert result["sol_amount_raw"] == 130_000 From 7a39c5abb64e41e0bbc2e6e467ef21b744899a6d Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 1 Nov 2026 18:37:37 +0100 Subject: [PATCH 2/3] fix: give only exact amounts a _raw twin A buy's tokens_received, a sell's quote_received and the creator fee totals are estimates made before the transaction is sent, so they no longer carry a _raw twin. With --confirm, a buy reads the tokens it received from the confirmed transaction and reports them exactly. wallet balance and wallet drain gain sol_balance_raw and sol_transferred_raw. --- README.md | 9 ++++-- src/pumpfun_cli/core/amounts.py | 46 +++++++++++++++++++++++++++ src/pumpfun_cli/core/pumpswap.py | 12 +++++-- src/pumpfun_cli/core/trade.py | 17 +++++++--- src/pumpfun_cli/core/wallet.py | 5 +-- src/pumpfun_cli/protocol/client.py | 5 ++- tests/test_core/helpers.py | 5 +++ tests/test_core/test_exact_amounts.py | 38 ++++++++++++++++++++-- tests/test_protocol/test_client.py | 9 +++++- 9 files changed, 129 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 64b863c..98e29ec 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,12 @@ but a coin can be priced in USDC or another admitted mint; `buy`, `sell` and `info` read it from the coin and label their output with it. Buying such a coin spends that asset, so the wallet has to hold it. -In `--json` output every amount comes twice: in whole units for reading -(`tokens_sold`) and as the exact integer in raw units (`tokens_sold_raw`). Use the -`_raw` field for anything that has to add up; the float can drop a unit. +In `--json` output an exact amount comes twice: in whole units for reading +(`tokens_sold`) and as the integer in raw units (`tokens_sold_raw`). Use the +`_raw` field for anything that has to add up; the float can drop a unit. An +amount known only as an estimate has no `_raw` twin: `quote_received`, the +`collect-creator-fee` totals, and `tokens_received` unless the buy ran with +`--confirm`, in which case it is read from the confirmed transaction. ## Commands diff --git a/src/pumpfun_cli/core/amounts.py b/src/pumpfun_cli/core/amounts.py index 6e8538c..807faa8 100644 --- a/src/pumpfun_cli/core/amounts.py +++ b/src/pumpfun_cli/core/amounts.py @@ -1,7 +1,15 @@ """Exact conversion of user-entered amounts into raw on-chain units.""" +import asyncio from decimal import ROUND_DOWN, Decimal +from solders.pubkey import Pubkey + +from pumpfun_cli.protocol.client import RpcClient + +# A load-balanced endpoint can briefly miss a transaction another node confirmed. +_TX_READ_ATTEMPTS = 3 + def to_raw(amount: float | str, decimals: int) -> int: """Whole units to raw units, exact for the decimal text the user typed. @@ -20,3 +28,41 @@ def amount_fields(name: str, raw: int, decimals: int) -> dict: actually used on chain. """ return {name: raw / 10**decimals, f"{name}_raw": raw} + + +def received_fields(name: str, actual: int | None, estimate: int, decimals: int) -> dict: + """A trade's receipt: exact, with `name_raw`, when read from the confirmed + transaction; otherwise the pre-trade estimate alone, without `name_raw`.""" + if actual is None: + return {name: estimate / 10**decimals} + return amount_fields(name, actual, decimals) + + +def token_delta(tx: dict, owner: Pubkey, mint: Pubkey) -> int: + """Raw change in `owner`'s balance of `mint` across a transaction.""" + + def total(rows: list) -> int: + return sum( + int(r["uiTokenAmount"]["amount"]) + for r in rows + if r.get("owner") == str(owner) and r.get("mint") == str(mint) + ) + + return total(tx["post_token_balances"]) - total(tx["pre_token_balances"]) + + +async def confirmed_token_delta( + client: RpcClient, signature: str, owner: Pubkey, mint: Pubkey +) -> int | None: + """Raw tokens a confirmed transaction moved into `owner`'s account, or None + when the transaction cannot be read back.""" + for attempt in range(_TX_READ_ATTEMPTS): + try: + tx = await client.get_transaction(signature) + except RuntimeError: + return None + if tx is not None: + return None if tx["err"] else token_delta(tx, owner, mint) + if attempt < _TX_READ_ATTEMPTS - 1: + await asyncio.sleep(1) + return None diff --git a/src/pumpfun_cli/core/pumpswap.py b/src/pumpfun_cli/core/pumpswap.py index cd859b6..4657207 100644 --- a/src/pumpfun_cli/core/pumpswap.py +++ b/src/pumpfun_cli/core/pumpswap.py @@ -8,7 +8,12 @@ from solders.pubkey import Pubkey -from pumpfun_cli.core.amounts import amount_fields, to_raw +from pumpfun_cli.core.amounts import ( + amount_fields, + confirmed_token_delta, + received_fields, + to_raw, +) from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair from pumpfun_cli.protocol.address import ( @@ -215,6 +220,7 @@ async def buy_pumpswap( ) except TransactionFailedError as exc: return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES) + received = await confirmed_token_delta(client, sig, user, mint) if confirm else None result = { "action": "buy", "venue": "pumpswap", @@ -222,7 +228,7 @@ async def buy_pumpswap( "quote_mint": str(quote_mint), "quote_symbol": symbol, **amount_fields("quote_spent", amount_raw, quote_decimals), - **amount_fields("tokens_received", estimated_tokens, base_decimals), + **received_fields("tokens_received", received, estimated_tokens, base_decimals), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } @@ -346,7 +352,7 @@ async def sell_pumpswap( "quote_mint": str(quote_mint), "quote_symbol": symbol, **amount_fields("tokens_sold", token_amount, base_decimals), - **amount_fields("quote_received", expected_quote, quote_decimals), + "quote_received": expected_quote / 10**quote_decimals, "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } diff --git a/src/pumpfun_cli/core/trade.py b/src/pumpfun_cli/core/trade.py index bdc5f98..a0b789a 100644 --- a/src/pumpfun_cli/core/trade.py +++ b/src/pumpfun_cli/core/trade.py @@ -10,7 +10,12 @@ from solders.pubkey import Pubkey from spl.token.instructions import create_idempotent_associated_token_account -from pumpfun_cli.core.amounts import amount_fields, to_raw +from pumpfun_cli.core.amounts import ( + amount_fields, + confirmed_token_delta, + received_fields, + to_raw, +) from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair from pumpfun_cli.protocol.address import ( @@ -246,6 +251,7 @@ async def buy_token( ) except TransactionFailedError as exc: return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES) + received = await confirmed_token_delta(client, sig, user, mint) if confirm else None return _tx_result( "buy", sig, @@ -255,7 +261,7 @@ async def buy_token( quote_mint=str(quote_mint), quote_symbol=symbol, **amount_fields("quote_spent", amount_raw, quote_decimals), - **amount_fields("tokens_received", tokens_out, TOKEN_DECIMALS), + **received_fields("tokens_received", received, tokens_out, TOKEN_DECIMALS), ) finally: await client.close() @@ -390,7 +396,7 @@ async def sell_token( quote_mint=str(quote_mint), quote_symbol=symbol, **amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS), - **amount_fields("quote_received", quote_out, quote_decimals), + quote_received=quote_out / 10**quote_decimals, ) finally: await client.close() @@ -603,8 +609,9 @@ async def collect_creator_fees( confirm, quote_mint=str(quote_mint), quote_symbol=quote_symbol(quote_mint), - **amount_fields("pump_recovered", pump_balance, quote_decimals), - **amount_fields("pumpswap_recovered", pumpswap_balance, quote_decimals), + # Vault balances read before sending; fees landing in between are swept too. + pump_recovered=pump_balance / 10**quote_decimals, + pumpswap_recovered=pumpswap_balance / 10**quote_decimals, ) finally: await client.close() diff --git a/src/pumpfun_cli/core/wallet.py b/src/pumpfun_cli/core/wallet.py index b37612b..4842dae 100644 --- a/src/pumpfun_cli/core/wallet.py +++ b/src/pumpfun_cli/core/wallet.py @@ -9,7 +9,7 @@ from pumpfun_cli.core.amounts import amount_fields, to_raw from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey from pumpfun_cli.crypto import decrypt_keypair, encrypt_keypair, get_pubkey -from pumpfun_cli.protocol.contracts import LAMPORTS_PER_SOL, SOL_DECIMALS +from pumpfun_cli.protocol.contracts import SOL_DECIMALS def create_wallet(password: str, keystore_path: str, force: bool = False) -> dict: @@ -69,7 +69,7 @@ async def get_balance(rpc_url: str, pubkey_str: str, commitment: str = "confirme client = RpcClient(rpc_url) try: lamports = await client.get_balance(pubkey, commitment=commitment) - return {"pubkey": pubkey_str, "sol_balance": lamports / LAMPORTS_PER_SOL} + return {"pubkey": pubkey_str, **amount_fields("sol_balance", lamports, SOL_DECIMALS)} finally: await client.close() @@ -427,6 +427,7 @@ async def drain_wallet( "ata_signature": ata_result.get("signature"), "ata_error": ata_result.get("ata_error"), "sol_transferred": sol_result.get("sol_amount", 0), + "sol_transferred_raw": sol_result.get("sol_amount_raw", 0), "sol_signature": sol_result.get("signature"), "sol_error": sol_result.get("error"), } diff --git a/src/pumpfun_cli/protocol/client.py b/src/pumpfun_cli/protocol/client.py index 71d520f..eb72a2a 100644 --- a/src/pumpfun_cli/protocol/client.py +++ b/src/pumpfun_cli/protocol/client.py @@ -214,7 +214,8 @@ async def get_transaction(self, signature_str: str) -> dict | None: Reads the raw JSON-RPC result rather than a typed response, so a transaction in any version is reported the same way. - Returns a dict with slot, err, fee, block_time — or None if not found. + Returns a dict with slot, err, fee, block_time and the token balances + before and after — or None if not found. """ Signature.from_string(signature_str) # reject a malformed signature before any I/O payload = { @@ -250,6 +251,8 @@ async def get_transaction(self, signature_str: str) -> dict | None: "err": meta.get("err"), "fee": meta.get("fee"), "block_time": result.get("blockTime"), + "pre_token_balances": meta.get("preTokenBalances") or [], + "post_token_balances": meta.get("postTokenBalances") or [], } async def close(self): diff --git a/tests/test_core/helpers.py b/tests/test_core/helpers.py index 64988a5..13371e1 100644 --- a/tests/test_core/helpers.py +++ b/tests/test_core/helpers.py @@ -182,6 +182,8 @@ def __init__(self): self.default_balance = 10_000_000_000 self.sent: list[dict] = [] self.send_error: Exception | None = None + # What getTransaction returns for any signature; None means not found. + self.transaction: dict | None = None def add(self, address: Pubkey, data: bytes, owner: Pubkey = TOKEN_PROGRAM) -> None: self.accounts[address] = SimpleNamespace(data=data, owner=owner, lamports=1) @@ -220,6 +222,9 @@ async def send_tx( ) return "fakesig" + async def get_transaction(self, signature: str) -> dict | None: + return self.transaction + async def close(self) -> None: return None diff --git a/tests/test_core/test_exact_amounts.py b/tests/test_core/test_exact_amounts.py index ecf347e..3f79517 100644 --- a/tests/test_core/test_exact_amounts.py +++ b/tests/test_core/test_exact_amounts.py @@ -46,8 +46,10 @@ async def test_bonding_curve_buy_and_sell_report_raw(tmp_keystore): "http://rpc", tmp_keystore, "testpass", MINT_STR, "all", dry_run=True ) assert bought["quote_spent_raw"] == 130_000 - assert isinstance(bought["tokens_received_raw"], int) + # Unconfirmed, the receipts are pre-trade estimates and carry no `_raw`. + assert "tokens_received" in bought and "tokens_received_raw" not in bought assert sold["tokens_sold_raw"] == LOSSY_RAW + assert "quote_received" in sold and "quote_received_raw" not in sold assert dry["tokens_in_raw"] == LOSSY_RAW assert {"expected_quote_raw", "min_quote_out_raw"} <= set(dry) @@ -71,7 +73,39 @@ async def test_collect_reports_raw(tmp_keystore): client.balances[derive_creator_vault(_user(tmp_keystore))] = 650_240 + 123_456_789 with patch("pumpfun_cli.core.trade.RpcClient", return_value=client): result = await collect_creator_fees("http://rpc", tmp_keystore, "testpass") - assert result["pump_recovered_raw"] == 123_456_789 + assert result["pump_recovered"] == 0.123456789 + assert "pump_recovered_raw" not in result + + +def _balances(owner: Pubkey, amount: int) -> list[dict]: + return [{"owner": str(owner), "mint": MINT_STR, "uiTokenAmount": {"amount": str(amount)}}] + + +async def test_confirmed_buy_reports_the_tokens_it_received(tmp_keystore): + user = _user(tmp_keystore) + client = chain_with_coin() + client.transaction = { + "err": None, + "pre_token_balances": _balances(user, 5), + "post_token_balances": _balances(user, 5 + LOSSY_RAW), + } + with patch("pumpfun_cli.core.trade.RpcClient", return_value=client): + bought = await buy_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, 0.00013, confirm=True + ) + assert bought["tokens_received_raw"] == LOSSY_RAW + + +async def test_confirmed_buy_falls_back_to_the_estimate_when_unreadable(tmp_keystore): + client = chain_with_coin() + with ( + patch("pumpfun_cli.core.trade.RpcClient", return_value=client), + patch("pumpfun_cli.core.amounts.asyncio.sleep"), + ): + bought = await buy_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, 0.00013, confirm=True + ) + assert bought["tokens_received"] > 0 and "tokens_received_raw" not in bought async def test_sol_transfer_sends_the_exact_amount(tmp_keystore): diff --git a/tests/test_protocol/test_client.py b/tests/test_protocol/test_client.py index de742f3..ddead06 100644 --- a/tests/test_protocol/test_client.py +++ b/tests/test_protocol/test_client.py @@ -139,7 +139,14 @@ async def test_get_transaction_reads_v1_result(respx_mock): result = await client.get_transaction(_SIG) finally: await client.close() - assert result == {"slot": 7, "err": None, "fee": 5000, "block_time": 99} + assert result == { + "slot": 7, + "err": None, + "fee": 5000, + "block_time": 99, + "pre_token_balances": [], + "post_token_balances": [], + } body = respx_mock.calls.last.request.content assert b'"maxSupportedTransactionVersion":1' in body.replace(b" ", b"") From 3adf35f6d65439fea686fbed1ec602c2e5c1fedb Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Sun, 1 Nov 2026 19:08:02 +0100 Subject: [PATCH 3/3] fix: fall back to the estimate when a receipt can't be established A confirmed buy reports tokens_received_raw only when the transaction's token balances were recorded, every row for the mint names its owner, and the wallet's balance rose. A non-JSON getTransaction response is a RuntimeError, so the completed buy's result is kept. README documents both fallbacks and what the dry-run _raw fields mean. --- README.md | 6 ++++- src/pumpfun_cli/core/amounts.py | 38 ++++++++++++++++++--------- src/pumpfun_cli/protocol/client.py | 12 ++++++--- tests/test_core/test_exact_amounts.py | 19 +++++++++++++- tests/test_protocol/test_client.py | 17 ++++++++++-- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 98e29ec..22292fd 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,11 @@ In `--json` output an exact amount comes twice: in whole units for reading `_raw` field for anything that has to add up; the float can drop a unit. An amount known only as an estimate has no `_raw` twin: `quote_received`, the `collect-creator-fee` totals, and `tokens_received` unless the buy ran with -`--confirm`, in which case it is read from the confirmed transaction. +`--confirm`, in which case it is read from the confirmed transaction. If that +transaction cannot be read back, a confirmed buy also reports the estimate +without `tokens_received_raw`, so check for the field. Dry-run fields +(`expected_tokens_raw`, `expected_quote_raw`, `min_*_out_raw`) are exact +integers of the quote the transaction would be built from, not executed amounts. ## Commands diff --git a/src/pumpfun_cli/core/amounts.py b/src/pumpfun_cli/core/amounts.py index 807faa8..516018a 100644 --- a/src/pumpfun_cli/core/amounts.py +++ b/src/pumpfun_cli/core/amounts.py @@ -38,31 +38,45 @@ def received_fields(name: str, actual: int | None, estimate: int, decimals: int) return amount_fields(name, actual, decimals) -def token_delta(tx: dict, owner: Pubkey, mint: Pubkey) -> int: - """Raw change in `owner`'s balance of `mint` across a transaction.""" +def token_delta(tx: dict, owner: Pubkey, mint: Pubkey) -> int | None: + """Raw change in `owner`'s balance of `mint` across a transaction. - def total(rows: list) -> int: - return sum( - int(r["uiTokenAmount"]["amount"]) - for r in rows - if r.get("owner") == str(owner) and r.get("mint") == str(mint) - ) + None when it cannot be established: the balances were not recorded, or a + row for the mint does not name its owner. + """ + + def total(rows: list | None) -> int | None: + if rows is None: + return None + amount = 0 + for row in rows: + if row.get("mint") != str(mint): + continue + if not row.get("owner"): + return None + if row["owner"] == str(owner): + amount += int(row["uiTokenAmount"]["amount"]) + return amount - return total(tx["post_token_balances"]) - total(tx["pre_token_balances"]) + post, pre = total(tx["post_token_balances"]), total(tx["pre_token_balances"]) + return None if post is None or pre is None else post - pre async def confirmed_token_delta( client: RpcClient, signature: str, owner: Pubkey, mint: Pubkey ) -> int | None: - """Raw tokens a confirmed transaction moved into `owner`'s account, or None - when the transaction cannot be read back.""" + """Raw tokens a confirmed buy moved into `owner`'s account, or None when + the transaction cannot be read back or shows no tokens received.""" for attempt in range(_TX_READ_ATTEMPTS): try: tx = await client.get_transaction(signature) except RuntimeError: return None if tx is not None: - return None if tx["err"] else token_delta(tx, owner, mint) + if tx["err"]: + return None + delta = token_delta(tx, owner, mint) + return delta if delta is not None and delta > 0 else None if attempt < _TX_READ_ATTEMPTS - 1: await asyncio.sleep(1) return None diff --git a/src/pumpfun_cli/protocol/client.py b/src/pumpfun_cli/protocol/client.py index eb72a2a..f9d20db 100644 --- a/src/pumpfun_cli/protocol/client.py +++ b/src/pumpfun_cli/protocol/client.py @@ -215,7 +215,7 @@ async def get_transaction(self, signature_str: str) -> dict | None: transaction in any version is reported the same way. Returns a dict with slot, err, fee, block_time and the token balances - before and after — or None if not found. + before and after (None if unrecorded) — or None if not found. """ Signature.from_string(signature_str) # reject a malformed signature before any I/O payload = { @@ -239,7 +239,10 @@ async def get_transaction(self, signature_str: str) -> dict | None: raise RuntimeError(f"getTransaction failed: {type(exc).__name__}") from None if resp.is_error: raise RuntimeError(f"getTransaction failed: HTTP {resp.status_code}") - body = resp.json() + try: + body = resp.json() + except ValueError: + raise RuntimeError("getTransaction failed: response is not JSON") from None if body.get("error"): raise RuntimeError(f"getTransaction failed: {body['error'].get('message')}") result = body.get("result") @@ -251,8 +254,9 @@ async def get_transaction(self, signature_str: str) -> dict | None: "err": meta.get("err"), "fee": meta.get("fee"), "block_time": result.get("blockTime"), - "pre_token_balances": meta.get("preTokenBalances") or [], - "post_token_balances": meta.get("postTokenBalances") or [], + # None when the node did not record them, distinct from an empty list. + "pre_token_balances": meta.get("preTokenBalances"), + "post_token_balances": meta.get("postTokenBalances"), } async def close(self): diff --git a/tests/test_core/test_exact_amounts.py b/tests/test_core/test_exact_amounts.py index 3f79517..6c0fc05 100644 --- a/tests/test_core/test_exact_amounts.py +++ b/tests/test_core/test_exact_amounts.py @@ -6,7 +6,7 @@ from solders.pubkey import Pubkey from solders.system_program import decode_transfer -from pumpfun_cli.core.amounts import amount_fields +from pumpfun_cli.core.amounts import amount_fields, token_delta from pumpfun_cli.core.pumpswap import buy_pumpswap, sell_pumpswap from pumpfun_cli.core.trade import buy_token, collect_creator_fees, sell_token from pumpfun_cli.core.wallet import transfer_sol @@ -119,3 +119,20 @@ async def test_sol_transfer_sends_the_exact_amount(tmp_keystore): assert int(0.00013 * 10**9) == 129_999 # what the float conversion sent assert sent["lamports"] == 130_000 assert result["sol_amount_raw"] == 130_000 + + +def test_token_delta_counts_a_newly_created_account(): + owner = Pubkey.new_unique() + tx = {"pre_token_balances": [], "post_token_balances": _balances(owner, LOSSY_RAW)} + assert token_delta(tx, owner, COIN) == LOSSY_RAW + + +def test_token_delta_refuses_to_guess(): + owner = Pubkey.new_unique() + unrecorded = {"pre_token_balances": None, "post_token_balances": _balances(owner, 1)} + ownerless = { + "pre_token_balances": [], + "post_token_balances": [{"mint": MINT_STR, "uiTokenAmount": {"amount": "7"}}], + } + assert token_delta(unrecorded, owner, COIN) is None + assert token_delta(ownerless, owner, COIN) is None diff --git a/tests/test_protocol/test_client.py b/tests/test_protocol/test_client.py index ddead06..fef0235 100644 --- a/tests/test_protocol/test_client.py +++ b/tests/test_protocol/test_client.py @@ -144,8 +144,8 @@ async def test_get_transaction_reads_v1_result(respx_mock): "err": None, "fee": 5000, "block_time": 99, - "pre_token_balances": [], - "post_token_balances": [], + "pre_token_balances": None, + "post_token_balances": None, } body = respx_mock.calls.last.request.content assert b'"maxSupportedTransactionVersion":1' in body.replace(b" ", b"") @@ -195,3 +195,16 @@ async def test_failed_confirmation_reports_the_signature(): client.rpc.confirm_transaction = AsyncMock(side_effect=TimeoutError()) with pytest.raises(RuntimeError, match=str(sig)): await client._send_and_confirm(MagicMock(), confirm=True) + + +@pytest.mark.asyncio +async def test_get_transaction_non_json_is_a_runtime_error(respx_mock): + import httpx + + respx_mock.post("https://rpc.test").mock(return_value=httpx.Response(200, text="")) + client = RpcClient("https://rpc.test") + try: + with pytest.raises(RuntimeError, match="not JSON"): + await client.get_transaction(_SIG) + finally: + await client.close()