Files
pumpfun-bonkfun-bot_github/learning-examples/verify_tx_status_checks.py
T
Anton Sauchyk 836d873d27 fix: report on-chain trade outcomes correctly, and repair every broken learning example (#177)
* fix(tx-status): read meta.err before reporting a trade succeeded

confirm_transaction answers "did this signature land in a block?", never
"did it succeed". A landed transaction can have reverted, and RPC reports
that only in meta.err. Reading whether a call threw instead of reading
meta.err produced false results in both directions.

False success — a reverted trade reported as confirmed:

- ten examples (pump.fun, pumpswap, letsbonk) printed "Transaction
  confirmed" without checking meta.err
- src/cleanup/manager.py discarded confirm_transaction's boolean and
  logged "Closed successfully" unconditionally, so a reverted close
  reported rent as reclaimed while the account stayed open
- learning-examples/cleanup_accounts.py did the same

False failure — a good trade reported as unconfirmed:

- _get_transaction_result omitted maxSupportedTransactionVersion, so the
  RPC answered -32015 for every versioned (v0) transaction. meta.err was
  unreadable and a successful trade read back as failed. The bot sends
  legacy transactions, which is the only reason this was survivable.
- confirm_transaction raised TypeError on a base58 str (solana-py wants a
  Signature) while _get_transaction_result raised on a Signature (not
  JSON serializable). Both were swallowed by a broad except into "not
  confirmed". The annotations pointed the wrong way too:
  build_and_send_transaction returns Signature, not str.

Changes:

- add learning-examples/tx_status.py — assert_transaction_succeeded and
  confirm_and_assert, replacing the copy duplicated in mint_and_buy{,_v2}
- wire it into the ten examples that confirmed without checking
- read the boolean in both cleanup paths
- normalize str/Signature at the client boundary; correct the annotations
- send maxSupportedTransactionVersion: 0 on getTransaction
- split verify_transaction_succeeded out of confirm_transaction so the
  meta.err check can run against a transaction that landed earlier

Two reporting bugs found while testing the above:

- live_v2_round_trip read balances at solana-py's default (finalized)
  commitment while confirming trades at "confirmed", so the end read saw
  pre-trade state and it printed "net change: +0.000000000 SOL" after a
  real round trip. Verified: on a busy account finalized trails confirmed
  by ~263k lamports.
- cleanup_accounts produced no output at all, success or failure, because
  get_logger attaches no handler and only the bot installs one. httpx is
  pinned to WARNING alongside it — the RPC endpoint carries an API key.

Adds learning-examples/verify_tx_status_checks.py: offline stub checks, a
scan that fails if an example confirms without checking meta.err, an AST
check that nothing in src/ discards the boolean, a guard that
getTransaction opts into v0, and --live, which replays issue #175's three
signatures against mainnet and requires both layers to reject them on
meta.err (Custom: 6062) rather than on a failed fetch. The two src/ guards
were mutation-tested: reintroducing each bug makes them fail.

The BuybackFeeRecipientMissing (6062) half of #175 was already fixed by
the buy_v2/sell_v2 migration in 02343b7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(client): retry RPC timeouts instead of letting them escape post_rpc

aiohttp signals a request timeout with asyncio.TimeoutError, which is not
an aiohttp.ClientError. post_rpc caught only the latter, so every RPC
timeout propagated to the caller unretried — and str() on it is empty, so
whatever logged it printed a blank reason.

Found while running learning-examples/live_listener_matrix.py: three of
the four listeners died mid-run with "CRASHED: " and no message. The
endpoint was answering getHealth in ~100ms while getAccountInfo hung past
60s, and every caller that touches it (sol_balance, ata_is_closed,
AccountCleanupManager.cleanup_ata) went down with it. With the retry in
place the same run degrades to a logged failure and completes.

Also makes the verifier report a raising check as a failure rather than
aborting the run — several checks assert that a call does NOT raise, so
the raise is the finding and the remaining checks still need to report.

Covered by "RPC timeouts are retried, not raised" in
verify_tx_status_checks.py, mutation-tested against the one-exception
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(examples): raise websocket frame limit, load .env, drop dead code

Running every learning example turned up three separate reasons a shipped
script could not work at all.

blockSubscribe examples were completely non-functional. websockets
defaults max_size to 1 MiB and Solana's blockSubscribe frames run well
past that, so the connection died with a 1009 close on the first real
block and the retry loop spun. listen_blocksubscribe.py logged 158,613
"message too big" errors in 35s and decoded zero tokens; compare_listeners
produced 10.1M error lines and its block column never reported anything.
Pass max_size=WEBSOCKET_MAX_MESSAGE_BYTES (32 MiB, the value the bot's own
listeners already use) at all 12 example connect sites — logsSubscribe and
programSubscribe included, since they have the same latent ceiling.

After: listen_blocksubscribe decodes tokens with 0 errors, and
compare_listeners reports provider_1_block alongside geyser and logs.

Seven examples read SOLANA_* from the environment but never called
load_dotenv(), so they only ran with variables already exported —
manual_buy, manual_buy_cu_optimized, manual_buy_geyser, manual_sell,
fetch_price, blockSubscribe_extract_transactions and
sample_cashback_pumpswap. manual_buy died on
"None isn't a valid URI: scheme isn't ws or wss" against a normal .env
checkout.

Dead code and artifacts:

- unused datetime import in listen_pumpportal
- initial_real_token_reserves computed and never read in mint_and_buy and
  mint_and_buy_v2
- gitignore blockSubscribe-transactions/, which a shipped example writes
  into 1200+ files deep and which nothing ignored
- stop tracking trades/trades.log; .gitignore has listed it all along, but
  a tracked file ignores .gitignore

ruff over src/ and learning-examples/ goes 2396 -> 2393 findings: the three
removals, nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix: read balances at confirmed, derive letsbonk platform_config per pool

Running every example against mainnet surfaced four more bugs, three of
them the same root cause as the commitment bug already in this branch:
solana-py defaults to "finalized", but trades confirm at "confirmed", and
finalization lags far enough behind that anything read in between is
pre-trade state.

get_token_account_balance defaulted to finalized. Cleanup reads it to
decide whether to burn before closing, so right after a sell it saw the
pre-sell amount and built a burn for tokens the account no longer held —
the burn + close reverted with InsufficientFunds and the rent stayed
locked. Observed live: "Burning 35766666 tokens" on an account the sell
had already emptied, then Custom(1). Defaults to confirmed now, which is
also what confirm_transaction uses.

manual_sell_pumpswap read the user's base balance at finalized, which
failed two ways in one session: "could not find account" when the ATA had
been created by a buy moments earlier, and a stale non-zero balance whose
transfer then reverted with insufficient funds. Pool vault reads move to
confirmed too, so quotes are not priced off stale reserves.

The letsbonk examples hardcoded platform_config. LaunchLab pools do not
share one — partner launches carry their own — so every buy/sell against
such a pool failed with ConstraintAddress (2012):

  AnchorError caused by account: platform_config
  Left:  5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA
  Right: FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1

PoolState carries platform_config at field 18 and these scripts already
parse the pool, so they now take the pool's own value and keep the
constant only as a fallback. src/platforms/letsbonk already documented
this; only the examples were stale. All four scripts now simulate and land
(buy_exact_in, sell_exact_in, buy_exact_out, sell_exact_out).

They also discarded simulation logs on failure, printing an error number
with no indication of which account or constraint broke. They print the
program logs now — that is how the above was diagnosed.

Runnability and credentials:

- fetch_price, cleanup_accounts and the four letsbonk scripts hardcoded a
  placeholder ("...", "YOUR_TOKEN_MINT_ADDRESS_HERE") and could not run at
  all without editing the source. They take argv[1] now, matching
  manual_sell and the pumpswap scripts. cleanup_accounts takes "2022" as
  argv[2] for Token-2022 mints, which every pump.fun coin is.
- six scripts printed the RPC/WSS endpoint, which carries an API key, into
  stdout. They print only the host now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(cleanup): never burn wrapped SOL, and label what actually happened

learning-examples/cleanup_accounts.py burned any non-zero balance before
closing. The token program rejects a burn of native (wrapped) SOL with
NativeNotSupported (error 10), so the burn + close transaction reverted and
a WSOL account could never be cleaned up — the pumpswap examples leave one
behind on every sell. Closing a WSOL account already returns both the
wrapped lamports and the rent, so there is nothing to burn first.

src/cleanup/manager.py already had this guard; only the example was
missing it. Observed live: "Burning 2206381 tokens" then Custom(10), with
2.2m lamports stranded until the guard went in.

The success line also claimed "Burned and closed" for the unwrap path,
which burns nothing. It now reports Unwrapped/Burned/Closed to match the
instructions actually built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* chore: drop captured-output artifacts and a spent one-off investigation script

learning-examples/decoded_buy_tx_from_getTransaction.json and
decoded_create_tx_from_getTransaction.json are not JSON — they are captured
stdout from the decode scripts, several objects concatenated, so json.load
raises "Extra data" on both. Nothing reads them and nothing can. The
raw_*.json fixtures stay: all three are valid input and were re-checked
through decode_from_getTransaction / decode_from_blockSubscribe /
decode_from_getAccountInfo.

learning-examples/pumpswap/sample_cashback_pumpswap.py was added by #168 to
find the position of the extra account that cashback PumpSwap pools
require. That question is answered — the layouts are in
platforms/pumpfun/instruction_builder.py and machine-checked against the
IDL by verify_v2_account_layout.py. It is undocumented, unlike every other
pumpswap example in the README, and nothing imports it.

Not touched: logs/ (run history), .cursor/.kiro/.windsurf (deliberate
mirrors of the same rules for other editors), and the raw_*.json fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(tx-status): fail closed on missing metadata, stop retrying landed reverts

Addresses the must-fix findings from review. Two of them were wrong in code
this branch added.

assert_transaction_succeeded folded meta=None into err=None and returned
success. Missing execution metadata means the outcome is unknown, not that
it succeeded — which is the exact mistake this module exists to prevent. It
raises now.

It also always read at "confirmed" while confirm_and_assert accepted a
commitment argument, so confirming at "finalized" and then reading status at
"confirmed" could report success before the finalization the caller asked
for. The commitment is threaded through.

The manual buy/sell retry loops built one message and blockhash before the
loop, so a landed revert was retried by resubmitting byte-identical signed
bytes — three attempts with backoff that could never succeed. Reverts now
raise TransactionRevertedError (a RuntimeError subclass, so existing
handlers keep working) and the loops treat it as terminal. Rebuilding and
re-signing per attempt is the fuller fix but a bigger change to these
scripts than this branch should carry.

cleanup_accounts guessed the mint's token program, defaulting to legacy SPL
unless argv[2] was "2022". The ATA address differs between programs, so a
wrong guess derives an address that does not exist and the script reports
"already closed" for an account it never looked at. It reads the owner off
the mint account instead, which is authoritative, and rejects anything not
owned by a token program. Verified live: pump.fun coins resolve to
Token-2022, letsbonk and USDC to legacy. argv[2] is gone — nothing to guess.

urlsplit(...).netloc keeps any user:pass@ userinfo, so the endpoint
redaction added earlier still printed credentials for providers that put the
key there. Uses .hostname in all five sites.

Four checks added to verify_tx_status_checks.py, each mutation-tested:
missing metadata, revert-is-terminal, commitment propagation, and a scan
that fails if any example goes back to netloc.

Skipped, both minor and pre-existing repo-wide rather than introduced here:
validating that RPC/WSS env vars are non-None before connecting (every
example has this shape; fixing two of them would just make it inconsistent),
and the cryptic base58 error when a letsbonk script runs with no argument,
which matches the "..." placeholder convention the pumpswap and manual_sell
examples already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:44:35 +02:00

493 lines
19 KiB
Python

"""Verify that a landed-but-reverted transaction is never reported as a success.
`AsyncClient.confirm_transaction` only waits for a signature to land in a block.
A landed transaction can still have reverted, so every trade path has to read
`meta.err` before it prints or returns success. Skipping that check is how issue
#175 happened: buys reverting with `BuybackFeeRecipientMissing` (6062) were
reported as confirmed, and the only way to notice was to inspect the signatures
by hand.
Two layers are checked:
* `tx_status.assert_transaction_succeeded` — the helper the learning examples use
* `SolanaClient.verify_transaction_succeeded` — the check the bot itself runs
Offline stub checks run always. Pass `--live` to additionally replay the three
reverted signatures from issue #175 against mainnet: both layers must fetch them
successfully and reject them on the strength of `meta.err`, not because the fetch
failed.
Usage:
uv run learning-examples/verify_tx_status_checks.py
uv run learning-examples/verify_tx_status_checks.py --live
"""
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "learning-examples"))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
import tx_status # noqa: E402
from core.client import SolanaClient # noqa: E402
# Signatures from issue #175: reported as confirmed buys, actually reverted with
# BuybackFeeRecipientMissing (6062). They are permanent mainnet history, so they
# make a stable regression fixture for "landed but failed".
REVERTED_SIGNATURES = (
"2bHRaovWyYNTX3K1KfMbMBSvefuT34ZExL2J2N83DDh9aB3Fynjyae5bcaCdRuQoihHAMHK4PzcRYoqc9hYypWb2",
"2Quu1uNZB7oSFstpKHv8KR1aKweizyvGaoGeXXKFLbMro4aqWykqZoeq7hSrHVZ8xmi6GX9J5nQVC5Ut3VV88S7T",
"4Bu9LrFK7QmUiCuLcjvejedJZRLfpKPZDiZgNqJRmw2EFjAAutmukGTErjzzJuA68emxpPQ8Pztu3NLkYhuvXKsd",
)
class _FakeMeta:
def __init__(self, err: object) -> None:
self.err = err
class _FakeTransaction:
def __init__(self, meta: _FakeMeta | None) -> None:
self.meta = meta
class _FakeValue:
def __init__(self, transaction: _FakeTransaction) -> None:
self.transaction = transaction
class _FakeResponse:
def __init__(self, value: _FakeValue | None) -> None:
self.value = value
class _StubClient:
"""Minimal stand-in for `AsyncClient.get_transaction`."""
def __init__(self, response: _FakeResponse) -> None:
self._response = response
async def get_transaction(self, *_args: Any, **_kwargs: Any) -> _FakeResponse:
return self._response
def _reverted_response() -> _FakeResponse:
# Shape of a real revert: {"InstructionError": [2, {"Custom": 6062}]}
err = {"InstructionError": [2, {"Custom": 6062}]}
return _FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(err))))
async def check_helper_rejects_revert() -> None:
client = _StubClient(_reverted_response())
try:
await tx_status.assert_transaction_succeeded(client, "SIG")
except RuntimeError as exc:
assert "6062" in str(exc), f"error should name the program error: {exc}"
return
raise AssertionError("assert_transaction_succeeded accepted a reverted transaction")
async def check_helper_rejects_missing() -> None:
client = _StubClient(_FakeResponse(None))
try:
await tx_status.assert_transaction_succeeded(client, "SIG")
except RuntimeError:
return
raise AssertionError("assert_transaction_succeeded accepted a missing transaction")
async def check_helper_accepts_success() -> None:
client = _StubClient(_FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(None)))))
await tx_status.assert_transaction_succeeded(client, "SIG")
async def check_confirm_wrapper_rejects_revert() -> None:
"""`confirm_and_assert` must surface a revert, not just a landing."""
confirmed: list[str] = []
class _ConfirmStub(_StubClient):
async def confirm_transaction(self, signature: str, **_kwargs: Any) -> None:
confirmed.append(signature)
client = _ConfirmStub(_reverted_response())
try:
await tx_status.confirm_and_assert(client, "SIG")
except RuntimeError:
assert confirmed == ["SIG"], "confirm_transaction should still be awaited"
return
raise AssertionError("confirm_and_assert accepted a reverted transaction")
async def check_examples_call_a_status_check() -> None:
"""Every example that confirms a trade must also verify it succeeded.
Guards against a new example (or an edit to an existing one) reintroducing a
bare `confirm_transaction` that prints success unconditionally.
"""
examples_dir = PROJECT_ROOT / "learning-examples"
# Files that confirm transactions without calling the helper, each for a
# reason. Adding an entry here is a deliberate act; forgetting the check in a
# new example is not.
exempt = {
# defines the helper
"tx_status.py",
# this file
Path(__file__).name,
# stubs confirm_transaction out; never sends a transaction
"simulate_bot_buy_path.py",
# uses the bot's SolanaClient wrapper, which folds meta.err into its
# return value; the boolean is read at the call site
"cleanup_accounts.py",
}
offenders = []
for path in sorted(examples_dir.rglob("*.py")):
if path.name in exempt or "__pycache__" in path.parts:
continue
source = path.read_text()
if "confirm_transaction" not in source:
continue
if not (
"assert_transaction_succeeded" in source or "confirm_and_assert" in source
):
offenders.append(str(path.relative_to(PROJECT_ROOT)))
assert not offenders, "examples confirm without checking meta.err: " + ", ".join(
offenders
)
async def check_helper_rejects_missing_meta() -> None:
"""Absent execution metadata must fail closed, not read as success.
`meta=None` means the outcome is unknown. Folding that into "no error" is the
exact mistake this module exists to prevent.
"""
client = _StubClient(_FakeResponse(_FakeValue(_FakeTransaction(None))))
try:
await tx_status.assert_transaction_succeeded(client, "SIG")
except RuntimeError as exc:
assert "metadata" in str(exc), f"unclear reason: {exc}"
return
raise AssertionError("a transaction with no execution metadata was accepted")
async def check_revert_is_a_distinct_terminal_error() -> None:
"""A landed revert must be distinguishable from a transient failure.
The retry loops in the manual buy/sell examples resubmit identical signed
bytes, so a revert can never be repaired by retrying — they need to tell it
apart from a fetch error.
"""
client = _StubClient(_reverted_response())
try:
await tx_status.assert_transaction_succeeded(client, "SIG")
except tx_status.TransactionRevertedError:
pass
else:
raise AssertionError("revert did not raise TransactionRevertedError")
assert issubclass(tx_status.TransactionRevertedError, RuntimeError), (
"TransactionRevertedError must stay a RuntimeError so existing "
"except RuntimeError handlers keep working"
)
# not-found is transient, so it must NOT be the terminal type
missing = _StubClient(_FakeResponse(None))
try:
await tx_status.assert_transaction_succeeded(missing, "SIG")
except tx_status.TransactionRevertedError:
raise AssertionError("not-found was reported as a terminal revert") from None
except RuntimeError:
pass
async def check_commitment_is_propagated() -> None:
"""The status read must use the commitment the caller asked for.
Confirming at "finalized" but reading status at "confirmed" reports success
before the finalization the caller requested.
"""
seen: dict[str, Any] = {}
class _Recorder(_StubClient):
async def get_transaction(self, *_args: Any, **kwargs: Any) -> _FakeResponse:
seen["commitment"] = kwargs.get("commitment")
return self._response
async def confirm_transaction(self, *_args: Any, **_kwargs: Any) -> None:
return None
client = _Recorder(_FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(None)))))
await tx_status.confirm_and_assert(client, "SIG", commitment="finalized")
assert seen["commitment"] == "finalized", (
f"status read at {seen['commitment']!r}, not the requested 'finalized'"
)
async def check_endpoint_logging_hides_userinfo() -> None:
"""Endpoint logging must use hostname, not netloc.
netloc keeps any `user:pass@` userinfo, so redacting with it still prints the
credential for providers that put the key there.
"""
offenders = []
for path in sorted((PROJECT_ROOT / "learning-examples").rglob("*.py")):
if "__pycache__" in path.parts or path.name == Path(__file__).name:
continue
for n, line in enumerate(path.read_text().splitlines(), 1):
if "urlsplit" in line and ".netloc" in line:
offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{n}")
assert not offenders, "urlsplit(...).netloc leaks userinfo at: " + ", ".join(
offenders
)
async def check_bot_reads_the_confirmation_result() -> None:
"""No caller in `src/` may throw away `confirm_transaction`'s boolean.
Inside `src/` the only client is `SolanaClient`, whose return value carries
the `meta.err` verdict. Calling it as a bare statement discards that verdict
and whatever gets logged next is unconditional — which is how cleanup
reported "Closed successfully" for reverted close transactions.
"""
import ast
offenders = []
for path in sorted((PROJECT_ROOT / "src").rglob("*.py")):
if "__pycache__" in path.parts:
continue
# client.py holds the wrapper itself; its inner call is solana-py's,
# which signals failure by raising rather than by returning False.
if path.name == "client.py" and path.parent.name == "core":
continue
for node in ast.walk(ast.parse(path.read_text())):
if not isinstance(node, ast.Expr):
continue
call = node.value.value if isinstance(node.value, ast.Await) else node.value
if (
isinstance(call, ast.Call)
and getattr(call.func, "attr", None) == "confirm_transaction"
):
offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}")
assert not offenders, "confirm_transaction result discarded at: " + ", ".join(
offenders
)
async def check_client_accepts_both_signature_types() -> None:
"""`SolanaClient` must handle a base58 string and a `Signature` alike.
The RPC client rejects a string and `json.dumps` rejects a `Signature`, so
whichever form a caller has, one of the two layers used to break. Both
failures surfaced as "not confirmed" for a transaction that in fact landed.
"""
from solders.signature import Signature
sig_str = REVERTED_SIGNATURES[0]
sig_obj = Signature.from_string(sig_str)
client = SolanaClient("http://127.0.0.1:1") # never contacted
bodies: list[dict[str, Any]] = []
async def capture_rpc(body: dict[str, Any], **_kwargs: Any) -> None:
bodies.append(body)
client.post_rpc = capture_rpc
for form in (sig_str, sig_obj):
bodies.clear()
await client._get_transaction_result(form) # noqa: SLF001
assert len(bodies) == 1, f"no RPC issued for {type(form).__name__}"
param = bodies[0]["params"][0]
assert param == sig_str, f"signature not normalized: {param!r}"
# aiohttp serializes the body with json.dumps; a Signature would raise.
json.dumps(bodies[0])
# Malformed strings must be reported, not raised through.
assert not await client.confirm_transaction("not-a-signature")
await client.close()
async def check_rpc_timeouts_are_retried_not_raised() -> None:
"""An RPC timeout must be retried and reported, never raised at the caller.
aiohttp signals a request timeout with `asyncio.TimeoutError`, which is not
an `aiohttp.ClientError`. While `post_rpc` caught only the latter, every
timeout escaped un-retried — and `str()` on it is empty, so callers logged a
blank reason. This is what broke three of the four listeners in
live_listener_matrix mid-run.
"""
client = SolanaClient("http://127.0.0.1:1")
attempts = 0
class _TimingOutSession:
def post(self, *_args: Any, **_kwargs: Any) -> Any:
nonlocal attempts
attempts += 1
class _Ctx:
async def __aenter__(_self) -> Any:
raise TimeoutError
async def __aexit__(_self, *_exc: Any) -> bool:
return False
return _Ctx()
async def _session() -> Any:
return _TimingOutSession()
client._get_session = _session # noqa: SLF001
client._rate_limiter.acquire = lambda: asyncio.sleep(0) # noqa: SLF001
result = await client.post_rpc({"method": "getTransaction"}, max_retries=2)
await client.close()
assert result is None, f"expected None on repeated timeout, got {result!r}"
assert attempts == 2, f"timeout should be retried; saw {attempts} attempt(s)"
async def check_versioned_transactions_are_requested() -> None:
"""getTransaction must opt in to versioned transactions.
Without `maxSupportedTransactionVersion` the RPC answers -32015 for every v0
transaction, so `meta.err` is unreadable and a successful trade reads back as
unconfirmed. The bot currently sends legacy transactions, which is the only
reason this was survivable.
"""
client = SolanaClient("http://127.0.0.1:1") # never contacted
bodies: list[dict[str, Any]] = []
async def capture_rpc(body: dict[str, Any], **_kwargs: Any) -> None:
bodies.append(body)
client.post_rpc = capture_rpc
await client._get_transaction_result(REVERTED_SIGNATURES[0]) # noqa: SLF001
await client.close()
assert bodies, "no RPC issued"
config = bodies[0]["params"][1]
assert config.get("maxSupportedTransactionVersion") == 0, (
f"getTransaction omits maxSupportedTransactionVersion: {config}"
)
async def check_live_signatures() -> None:
load_dotenv()
rpc_endpoint = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
if not rpc_endpoint:
raise RuntimeError("SOLANA_NODE_RPC_ENDPOINT is required for --live")
from solana.rpc.async_api import AsyncClient
from solders.signature import Signature
bot_client = SolanaClient(rpc_endpoint)
raw_client = AsyncClient(rpc_endpoint)
try:
for signature in REVERTED_SIGNATURES:
sig = Signature.from_string(signature)
try:
await tx_status.assert_transaction_succeeded(raw_client, sig)
except RuntimeError as exc:
print(f" examples helper rejected {signature[:16]}...: {exc}")
else:
raise AssertionError(f"examples helper accepted reverted {signature}")
# The transaction must be readable at all — a rejection because the
# fetch failed would pass the assertion below for the wrong reason,
# which is how the missing maxSupportedTransactionVersion hid.
fetched = await bot_client._get_transaction_result(signature) # noqa: SLF001
assert fetched, f"SolanaClient could not fetch {signature}"
assert fetched["meta"]["err"], f"expected a revert on {signature}"
# verify_transaction_succeeded rather than confirm_transaction: these
# signatures are old, and the landing-wait polls signature statuses,
# which the RPC only keeps for recent history.
succeeded = await bot_client.verify_transaction_succeeded(signature)
assert not succeeded, f"SolanaClient accepted reverted {signature}"
print(
f" SolanaClient rejected {signature[:16]}...: "
f"{fetched['meta']['err']}"
)
finally:
await raw_client.close()
await bot_client.close()
CHECKS = (
("helper rejects a reverted transaction", check_helper_rejects_revert),
("helper rejects a missing transaction", check_helper_rejects_missing),
("helper accepts a successful transaction", check_helper_accepts_success),
("confirm_and_assert surfaces a revert", check_confirm_wrapper_rejects_revert),
("helper rejects missing execution metadata", check_helper_rejects_missing_meta),
("revert is a distinct terminal error", check_revert_is_a_distinct_terminal_error),
("requested commitment is propagated", check_commitment_is_propagated),
("endpoint logging hides userinfo", check_endpoint_logging_hides_userinfo),
(
"SolanaClient accepts str and Signature",
check_client_accepts_both_signature_types,
),
(
"getTransaction requests versioned transactions",
check_versioned_transactions_are_requested,
),
("RPC timeouts are retried, not raised", check_rpc_timeouts_are_retried_not_raised),
("every example checks meta.err", check_examples_call_a_status_check),
("src/ reads the confirmation result", check_bot_reads_the_confirmation_result),
)
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--live",
action="store_true",
help="also replay issue #175's reverted signatures against mainnet",
)
args = parser.parse_args()
failures = 0
for label, check in CHECKS:
# Any exception is a failure of that check, not of the run: a check that
# raises must not stop the remaining ones from reporting. Some of these
# verify that a call does NOT raise, so the raise IS the finding.
try:
await check()
except Exception as exc: # noqa: BLE001
failures += 1
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
print(f"{label} -> FAIL\n {reason}")
else:
print(f"{label} -> OK")
if args.live:
print("\nreplaying issue #175 signatures against mainnet...")
try:
await check_live_signatures()
except Exception as exc: # noqa: BLE001
failures += 1
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
print(f"live signature replay -> FAIL\n {reason}")
else:
print("live signature replay -> OK")
if failures:
print(f"\n{failures} check(s) failed.")
return 1
print("\nAll transaction-status checks passed.")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))