diff --git a/README.md b/README.md index 1ab66e4..22292fd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,17 @@ 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 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. 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 685759d..516018a 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. @@ -11,3 +19,64 @@ 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} + + +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 | None: + """Raw change in `owner`'s balance of `mint` across a transaction. + + 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 + + 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 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: + 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/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..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 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 ( @@ -167,12 +172,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: @@ -215,14 +220,15 @@ 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", "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), + **received_fields("tokens_received", received, estimated_tokens, base_decimals), "signature": sig, "explorer": f"https://solscan.io/tx/{sig}", } @@ -306,12 +312,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,7 +351,7 @@ async def sell_pumpswap( "mint": mint_str, "quote_mint": str(quote_mint), "quote_symbol": symbol, - "tokens_sold": token_amount / 10**base_decimals, + **amount_fields("tokens_sold", token_amount, base_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 6a43a6b..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 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 ( @@ -201,12 +206,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: @@ -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, @@ -254,8 +260,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), + **received_fields("tokens_received", received, tokens_out, TOKEN_DECIMALS), ) finally: await client.close() @@ -336,12 +342,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,7 +395,7 @@ async def sell_token( mint=mint_str, quote_mint=str(quote_mint), quote_symbol=symbol, - tokens_sold=token_amount / 10**TOKEN_DECIMALS, + **amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS), quote_received=quote_out / 10**quote_decimals, ) finally: @@ -603,6 +609,7 @@ async def collect_creator_fees( confirm, quote_mint=str(quote_mint), quote_symbol=quote_symbol(quote_mint), + # 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, ) diff --git a/src/pumpfun_cli/core/wallet.py b/src/pumpfun_cli/core/wallet.py index 4511348..4842dae 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 SOL_DECIMALS def create_wallet(password: str, keystore_path: str, force: bool = False) -> dict: @@ -68,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() @@ -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}", } @@ -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..f9d20db 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 (None if unrecorded) — or None if not found. """ Signature.from_string(signature_str) # reject a malformed signature before any I/O payload = { @@ -238,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") @@ -250,6 +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"), + # 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/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/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 new file mode 100644 index 0000000..6c0fc05 --- /dev/null +++ b/tests/test_core/test_exact_amounts.py @@ -0,0 +1,138 @@ +"""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, 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 +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 + # 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) + + +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"] == 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): + 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 + + +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 de742f3..fef0235 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": None, + "post_token_balances": None, + } body = respx_mock.calls.last.request.content assert b'"maxSupportedTransactionVersion":1' in body.replace(b" ", b"") @@ -188,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()