Files
pumpfun-bonkfun-bot_github/tests/regression/verify_transaction_v1.py
T
ebbfa9f124 chore(deps): upgrade to solders 0.29 / solana 0.40 and bring the rest of the dependencies current (#217)
* chore(deps): upgrade solders to 0.29 and solana to 0.40 in cookbook and tools

solana 0.40 moves TxOpts (now TxOptsModel), MemcmpOpts and TokenAccountOpts
from solana.rpc.types to solana.rpc.core, and spl.token's *Params dataclasses
to spl.token.models as pydantic models — validated and keyword-only. Three
call sites only failed against mainnet: MemcmpOpts.bytes now takes a base58
str, SyncNativeParams was built positionally, and confirm_transaction was
passed a bare "confirmed".

Also moves every cookbook and tools send path off the legacy Transaction onto
VersionedTransaction + MessageV0, and teaches the getTransaction decoder the
json and base64 encodings alongside jsonParsed — the base64 v1 fixture needs
solders 0.29 to deserialize at all.

src/ is untouched and still on the old imports; the offline suite is 3/19
until that lands.

Verified on mainnet: a 0.003 SOL buy_v2/sell_v2 round trip confirmed and the
wallet returned to SOL only, all six listeners and every read-only script run
live, five trade paths simulated with error=None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): move the bot onto solders 0.29 and solana 0.40

Same three API moves as the cookbook commit, now in src: TxOpts is
TxOptsModel in solana.rpc.core, and spl.token's BurnParams/CloseAccountParams
come from spl.token.models. build_and_send_transaction builds a
VersionedTransaction from MessageV0 with no lookup tables, so the bot no
longer sends a legacy transaction anywhere.

verify_transaction_v1 proved the log route by stripping the logs and expecting
nothing back, which only held while solders could not read a v1 envelope. On
0.29 it can, so the check is inverted: make the envelope unreadable, keep the
logs, and the create must still arrive. A second check pins the envelope
fallback, so a decode regression is reported rather than silently absorbed by
the log route.

Offline: 19/19. Live on mainnet, funds returned:
- tools/simulate_bot_buy_path: zero-RPC buy path, err=None, 107,015 CU
- tools/simulate_v2_trades: buy_v2 95,573 CU, buy+sell 174,190 — both inside
  the existing 180k/120k limits, so no retune
- tools/live_v2_round_trip: buy_v2 + sell_v2 both landed
- tools/live_listener_matrix: all four listeners detected; logs and pumpportal
  ran full round trips; geyser and blocks both drew a coin on an unconfigured
  quote mint and correctly refused to size a buy. Every ATA closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bring the rest of the dependencies to current

Every remaining dependency is now at its latest release, with the floors
raised to the versions actually tested rather than the oldest that resolves:

  grpcio        1.73.1 -> 1.84.0    grpcio-tools 1.73.1 -> 1.84.0
  protobuf      6.33.5 -> 7.36.2    uvloop       0.21.0 -> 0.22.1
  pyyaml        6.0.2  -> 6.0.3     python-dotenv 1.2.2 -> 1.2.3
  construct     2.10.70 (floor)     websockets   17.1 (floor)
  ruff          0.12.4 -> 0.16.8

aiohttp and base58 were already current. winloop is left at its old floor:
it is Windows-only and cannot be installed, let alone tested, here.

protobuf 7 arrives with grpcio-tools 1.84 and is the only major. The
committed geyser stubs are gencode 6.31.1 and still pass
ValidateProtobufRuntimeVersion against it; they are not regenerated, because
the committed files import each other absolutely and protoc does not emit
that, so the output would need rewriting. Noted in CLAUDE.md with the
verification date.

ruff 0.16 needs no config change. The repo-wide baseline moves 1680 -> 1713
because the rules moved, not the code; CLAUDE.md carries the new figure so it
is not misread as a regression.

Verified per dependency, not just in aggregate: all four bot configs load
under the new pyyaml/dotenv; the logs, blocks and pumpportal listeners run
under the uvloop policy bot_runner installs; both geyser paths (token
listener and the account-filtered graduation watcher) run under grpcio 1.84 /
protobuf 7. Offline 19/19, including verify_listener_cancellation for
websockets. One more live buy_v2/sell_v2 round trip on the final set, both
landed, ATA closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(utils): redact RPC credentials from every log record

The provider endpoints carry their API key in the URL and the HTTP clients log
a full request URL at INFO, so anything that raises the root logger to INFO
prints the key. tools/cleanup_accounts.py did, four times, in a terminal.

It was already silencing httpx and httpcore by name. That stopped working the
moment solana-py 0.40 switched to httpx2: the rename took the guard with it
and nothing said so. Naming the clients is now a convenience on top of the
real control — install_secret_redaction masks the value itself, at log-record
creation, whichever logger emits it.

It redacts the *rendered* message rather than record.msg plus the string
arguments. The first version of this did the latter and still leaked: httpx2
passes its URL as a URL object, so the type check skipped the only argument
carrying the key. Confirmed by forcing httpx2 back to INFO and watching the
key print anyway. The host is kept, so a log line still says which provider
answered.

Installed from get_logger, from both logging.basicConfig sites, and from
setup_file_logging before it attaches the handler — logs/ outlives a terminal.

verify_no_rpc_credentials_logged covers all of it, including the non-str
argument. Checked against the defeated version first: it fails there and
passes here. Suite is 20/20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): make a real bot run the verification, not the simulations

The ground rule said never to run a bot with real funds, which points at the
tools/ harnesses instead. Those call the buyer, seller and listeners directly:
they skip bot_runner, config loading, the trader loop, the exit strategy and
cleanup, so passing there says nothing about the paths only a full run reaches.
Verification is a real run, with explicit approval for that session, covering
every mode the change touches and ending with the wallet holding SOL only.

Also records why a run can look clean when nothing happened: pump_bot runs every
bots/*.yaml, three of the four committed configs ship enabled: false, and with
all of them disabled it prints nothing and exits 0. The log file is the tell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(utils): keep the redaction comment provider-neutral

The parameter list is what matters, not which provider uses which spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 13:28:43 +02:00

329 lines
13 KiB
Python

"""Verify the bot still detects coins after the Solana transaction v1 cutover.
Transaction v1 (SIMD-0296 size, SIMD-0385 format) activated at epoch 1035 on
2026-09-15. It is not an opt-in: other people's creates and trades arrive in
whatever version they chose, and both pump.fun and letsbonk.fun already carry
v1 traffic. Two things break a reader that ignores it.
`maxSupportedTransactionVersion` is a whole-frame setting, not a per-transaction
one. Asking `blockSubscribe` for 0 does not skip the v1 transactions in a block,
it nulls out `value.block` for the entire notification. Measured against mainnet
on 2026-09-16, 60s per run: `0` delivered 1 block and 177 nulls, `1` delivered
78 blocks and no nulls — the blocks listener was roughly 99% blind, silently,
because a null frame looks exactly like a skipped slot.
solders could not deserialize a v1 transaction (they begin with byte 129 and
put the signatures at the tail) until 0.29. That was survivable, because the RPC
has already decoded the envelope by the time it emits `meta.logMessages`, and
the platform parsers prefer the CreateEvent in those logs anyway — for the
canonical creator. It is only fatal if the listener insists on decoding the
bytes *before* it will hand a transaction to a parser, which is what
`_process_block_transactions` used to do.
solders 0.29 reads a v1 envelope, so the byte decode works again — but it stays
the fallback, not the route. The next version byte will be unreadable in its
turn, and a listener that has quietly come to depend on the decode goes blind
again on the day it lands. Both routes are pinned below, separately.
Offline machine checks, no network and no funds moved:
1. Every blockSubscribe/getBlock/getTransaction call site in `src/` and
`cookbook/` asks for maxSupportedTransactionVersion >= 1 —
both the raw JSON key and solana-py's snake_case keyword argument.
2. The committed fixture really is a v1 transaction (version 1, byte 129)
carrying a successful create_v2.
3. The pump.fun event parser reads it into a TokenInfo — mint, creator and
state_from_event, so extreme_fast_mode keeps its zero-RPC contract.
4. The block listener returns that TokenInfo for the fixture, which is the
regression that matters: it must not depend on the envelope decoding.
5. The listener still reads a pre-v1 create, so nothing regressed for the
coins that already worked.
6. With the envelope replaced by bytes solders cannot read, the v1 create is
still detected — which is what makes the log route load-bearing rather
than decorative.
7. With the logs removed, the installed solders reads the v1 create straight
off the envelope. This is the fallback, and it only works from solders
0.29; a failure here means the decode has regressed and the log route is
carrying the whole load again.
Usage:
uv run tests/regression/verify_transaction_v1.py
"""
import base64
import json
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from core.client import SolanaClient # noqa: E402
from interfaces.core import Platform # noqa: E402
from monitoring.universal_block_listener import UniversalBlockListener # noqa: E402
from platforms import get_platform_implementations # noqa: E402
V1_FIXTURE = (
PROJECT_ROOT
/ "cookbook"
/ "pumpfun"
/ "decode"
/ "raw_create_v2_v1_from_gettransaction.json"
)
# A pre-v1 create, already committed for the optional-args verifier. Reused
# rather than capturing another one: it only has to be a create the listener
# still detects.
V0_FIXTURE = (
PROJECT_ROOT
/ "cookbook"
/ "pumpfun"
/ "decode"
/ "raw_create_v2_with_fee_bps_from_gettransaction.json"
)
# Solana tags a v1 transaction with this first byte (SIMD-0385 VersionByte).
V1_VERSION_BYTE = 129
MIN_SUPPORTED_VERSION = 1
SCAN_ROOTS = ("src", "cookbook", "tools")
# Two spellings reach the same RPC field: the raw JSON key used by hand-built
# request bodies, and solana-py's snake_case keyword argument. A scan that knows
# only the first one misses solana_transaction_status.py, which every example confirms through.
MSV_PATTERN = re.compile(
r"[\"']maxSupportedTransactionVersion[\"']\s*:\s*(\d+)"
r"|max_supported_transaction_version\s*=\s*(\d+)"
)
class _OfflineClient(SolanaClient):
"""A SolanaClient that never opens a connection.
The parsers only need address derivation here, and SolanaClient.__init__
starts a blockhash updater task.
"""
def __init__(self) -> None:
self.rpc_endpoint = "http://offline.invalid"
self._client = None
self._cached_blockhash = None
self._blockhash_lock = None
self._blockhash_updater_task = None
def _load(path: Path) -> dict:
with path.open() as handle:
return json.load(handle)
def _as_block_transaction(result: dict) -> dict:
"""Shape a getTransaction result like one blockSubscribe transaction."""
return {"transaction": result["transaction"], "meta": result["meta"]}
def check_every_call_site_accepts_v1() -> bool:
"""No reader may ask for a version below v1.
A call site left at 0 does not degrade gracefully — it loses whole blocks.
"""
stale: list[str] = []
for root in SCAN_ROOTS:
for path in sorted((PROJECT_ROOT / root).rglob("*.py")):
if "generated" in path.parts:
continue
text = path.read_text()
for match in MSV_PATTERN.finditer(text):
value = match.group(1) or match.group(2)
if int(value) < MIN_SUPPORTED_VERSION:
line = text[: match.start()].count("\n") + 1
stale.append(f"{path.relative_to(PROJECT_ROOT)}:{line}")
if stale:
print(" call sites still below v1: " + ", ".join(stale))
return not stale
def check_fixture_is_a_v1_create() -> bool:
"""The fixture has to be the real thing or the rest proves nothing."""
result = _load(V1_FIXTURE)
if result.get("version") != 1:
print(f" fixture version is {result.get('version')!r}, expected 1")
return False
if result["meta"].get("err") is not None:
print(" fixture create failed on chain; a failed create has no mint")
return False
raw = base64.b64decode(result["transaction"][0])
if raw[0] != V1_VERSION_BYTE:
print(f" first byte is {raw[0]}, expected {V1_VERSION_BYTE}")
return False
logs = result["meta"].get("logMessages") or []
if not any("Instruction: CreateV2" in line for line in logs):
print(" fixture carries no CreateV2 instruction")
return False
return True
def check_parser_reads_the_v1_create() -> bool:
"""The platform parser works off logs, so the version is irrelevant to it."""
parser = get_platform_implementations(
Platform.PUMP_FUN, _OfflineClient()
).event_parser
token_info = parser.parse_token_creation_from_block(
{"transactions": [_as_block_transaction(_load(V1_FIXTURE))]}
)
if token_info is None:
print(" parser returned None for a v1 create_v2")
return False
if not token_info.state_from_event:
print(" state_from_event is False — extreme_fast_mode would refresh")
return False
if token_info.mint is None or token_info.creator is None:
print(" parsed TokenInfo is missing mint or creator")
return False
return True
def check_listener_detects_the_v1_create() -> bool:
"""The regression under test: dispatch must not require a byte decode.
solders cannot deserialize a v1 envelope, so a listener that decodes first
and dispatches second drops the coin without raising anything.
"""
listener = UniversalBlockListener("wss://offline.invalid", [Platform.PUMP_FUN])
result = _load(V1_FIXTURE)
token_info = listener._process_block_transactions( # noqa: SLF001
[_as_block_transaction(result)]
)
if token_info is None:
print(" block listener returned None for a v1 create_v2")
return False
return True
def check_log_route_detects_without_the_envelope() -> bool:
"""Make the envelope unreadable and the v1 create must still arrive.
Proves the log route is doing the work on its own, rather than passing
because the byte decode happens to cope. Until solders 0.29 that was proved
the other way round — strip the logs and nothing was left to parse — but a
solders that reads v1 makes the absence of a result prove nothing. Breaking
the envelope instead pins the invariant whether or not the installed solders
can decode the version in hand.
"""
listener = UniversalBlockListener("wss://offline.invalid", [Platform.PUMP_FUN])
result = _load(V1_FIXTURE)
tx = _as_block_transaction(result)
# Valid base64, not a transaction: the decode raises and the fallback has
# nothing to offer, so only the logs can answer.
tx["transaction"] = [base64.b64encode(b"not a transaction").decode(), "base64"]
token_info = listener._process_block_transactions([tx]) # noqa: SLF001
if token_info is None:
print(" a v1 create with unreadable bytes was missed — the logs are not the route")
return False
return True
def check_envelope_fallback_reads_v1() -> bool:
"""Strip the logs and the installed solders must read the v1 envelope.
The fallback, not the route. It only works from solders 0.29 — 0.26, 0.27.1
and 0.28 all raise `ValueError: io error: unexpected end of file` on this
same fixture. A failure here is a decode regression, not a routing bug: the
bot keeps detecting v1 coins through the logs either way.
"""
listener = UniversalBlockListener("wss://offline.invalid", [Platform.PUMP_FUN])
result = _load(V1_FIXTURE)
tx = _as_block_transaction(result)
tx["meta"] = {**tx["meta"], "logMessages": []}
token_info = listener._process_block_transactions([tx]) # noqa: SLF001
if token_info is None:
print(" the installed solders cannot decode a v1 envelope")
return False
return True
def check_listener_still_detects_a_pre_v1_create() -> bool:
"""The log route is an addition, not a replacement."""
listener = UniversalBlockListener("wss://offline.invalid", [Platform.PUMP_FUN])
result = _load(V0_FIXTURE)
if result.get("version") == 1:
print(" the legacy/v0 fixture is itself v1; the check proves nothing")
return False
token_info = listener._process_block_transactions( # noqa: SLF001
[_as_block_transaction(result)]
)
if token_info is None:
print(" block listener returned None for a pre-v1 create")
return False
return True
def check_examples_route_on_logs() -> bool:
"""No cookbook or tools listener may detect by opening the envelope.
The envelope is the one part of a transaction whose format changes
underneath you, and the installed solders cannot read a v1 one. A script
that deserializes it is free to do so for enrichment, but it must also read
`logMessages`, or it goes blind the moment a version it cannot parse
arrives. The blocks example printed 30,080 "skipping a v1 transaction"
lines in 150 seconds before this rule existed, and compare_listeners
reported 601 decode errors as if they were a transport difference.
"""
offenders = []
for directory in ("cookbook", "tools"):
for path in sorted((PROJECT_ROOT / directory).rglob("*.py")):
# The decoders are handed one saved payload and asked to take it
# apart. Opening the envelope is the exercise, and there is no live
# stream for them to go blind on.
if path.parent.name == "decode":
continue
source = path.read_text()
if "VersionedTransaction.from_bytes" not in source:
continue
if "logMessages" not in source:
offenders.append(str(path.relative_to(PROJECT_ROOT)))
if offenders:
print(
" these decode the envelope without a log route: "
+ ", ".join(offenders)
)
return False
return True
def main() -> int:
checks = [
("every call site accepts v1", check_every_call_site_accepts_v1),
("fixture is a successful v1 create_v2", check_fixture_is_a_v1_create),
("event parser reads the v1 create", check_parser_reads_the_v1_create),
("block listener detects the v1 create", check_listener_detects_the_v1_create),
(
"block listener still detects a pre-v1 create",
check_listener_still_detects_a_pre_v1_create,
),
(
"the log route detects v1 without the envelope",
check_log_route_detects_without_the_envelope,
),
("the envelope fallback reads v1 too", check_envelope_fallback_reads_v1),
("examples route on logs too", check_examples_route_on_logs),
]
failed = 0
for label, check in checks:
try:
ok = check()
except Exception as error: # noqa: BLE001 - report and continue
print(f"FAIL {label}: {type(error).__name__}: {error}")
failed += 1
continue
print(f"{'PASS' if ok else 'FAIL'} {label}")
failed += 0 if ok else 1
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())