Files
c4c5eb8410 fix(monitoring): detect block creations from logs alone (#227)
The envelope routes ended in the same parser call as the log route, so they
only widened which transactions were offered, never how one was read. The
envelope cannot see a create reached by CPI, and the version-fragility reason
the comment gave for preferring logs expired with solders 0.29.

The v1 envelope check loses its subject with the fallback gone, so it now
asserts against solders directly; the decode scripts still open envelopes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 11:14:18 +02:00

474 lines
19 KiB
Python

"""Verify the bot still detects coins after the Solana transaction v1 cutover.
Transaction v1 (SIMD-0296 size, SIMD-0385 format) is not an opt-in: other
people's creates and trades arrive in whatever version they chose, and both
pump.fun and letsbonk.fun 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 a block's v1 transactions, it
nulls `value.block` for the entire notification — which looks exactly like a
skipped slot, so the blocks listener goes almost entirely blind, silently.
solders could not deserialize a v1 envelope (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
parsers prefer the CreateEvent in those logs anyway; it is fatal only for a
listener that insists on decoding the bytes *before* handing a transaction to a
parser.
solders 0.29 reads a v1 envelope, but the byte decode stays the fallback, not
the route: the next version byte will be unreadable in its turn, and a listener
that has come to depend on the decode goes blind 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.
The geyser route decodes protobuf rather than transaction bytes, so it goes
blind differently and is pinned on its own frame:
8. The committed geyser frame really is a v1 create_v2. Over geyser there is
no version number, so `Message.config` is the test; it exists only in the
stubs from yellowstone-grpc-proto 13.0.0-rc4, so a rollback fails here.
9. The pump.fun parser reads that frame into a usable TokenInfo. A missing
proto field is skipped rather than raised on, so a stale proto degrades in
silence — which is why it is pinned.
10. With the logs stripped from the frame, the instruction decode still finds
the coin, and still leaves state_from_event False: `args.creator` is
user-supplied, so the curve must be read.
11. The inline v1 budget is readable as a known field. A v1 coin carries no
ComputeBudget instructions at all, so anything reading a budget from the
instruction list reads nothing for it.
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 geyser.generated import geyser_pb2 # 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
from solders.transaction import VersionedTransaction # 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"
)
# One geyser SubscribeUpdate carrying a v1 create_v2. The
# geyser route has its own decode path and its own stubs, so it needs its own
# fixture: the getTransaction JSON above cannot exercise a protobuf frame.
GEYSER_V1_FIXTURE = (
PROJECT_ROOT
/ "cookbook"
/ "pumpfun"
/ "decode"
/ "raw_create_v2_v1_from_geyser.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_solders_reads_a_v1_envelope() -> bool:
"""The installed solders must still deserialize a v1 envelope.
No detection path depends on this: the block listener routes on the logs
alone. It stays as a dependency canary, because the cookbook decode scripts
open envelopes and 0.26, 0.27.1 and 0.28 all raise `ValueError: io error:
unexpected end of file` on this same fixture.
"""
result = _load(V1_FIXTURE)
try:
VersionedTransaction.from_bytes(base64.b64decode(result["transaction"][0]))
except Exception as error: # noqa: BLE001 - any failure is the regression
print(f" the installed solders cannot decode a v1 envelope: {error}")
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 _geyser_update() -> "geyser_pb2.SubscribeUpdate":
"""Rebuild the captured geyser frame from its committed bytes.
Returns:
The `SubscribeUpdate` exactly as it came off the wire
"""
update = geyser_pb2.SubscribeUpdate()
update.ParseFromString(
base64.b64decode(_load(GEYSER_V1_FIXTURE)["subscribe_update_base64"])
)
return update
def check_geyser_fixture_is_a_v1_create() -> bool:
"""The geyser fixture has to be a v1 create or the rest proves nothing.
Over geyser there is no version number to read: `Message.config` is set for
v1 and absent otherwise, and `versioned` is true for v0 and v1 alike. The
vendored proto carried no `config` field until the 13.0.0-rc4 regeneration,
so this check also fails if the stubs are ever rolled back.
"""
update = _geyser_update()
message = update.transaction.transaction.transaction.message
if not message.HasField("config"):
print(" fixture message has no config field — not a v1, or stale stubs")
return False
if update.transaction.transaction.meta.err.ByteSize():
print(" fixture create failed on chain; a failed create has no mint")
return False
logs = list(update.transaction.transaction.meta.log_messages)
if not any("Instruction: CreateV2" in line for line in logs):
print(" fixture carries no CreateV2 instruction")
return False
return True
def check_geyser_parser_reads_the_v1_create() -> bool:
"""The geyser route must reach a usable TokenInfo on a v1 coin.
An unknown field is skipped by protobuf rather than raised on, so a stale
proto degrades silently here — the frame still parses and only the budget
goes missing. That is precisely why this is pinned.
"""
parser = get_platform_implementations(
Platform.PUMP_FUN, _OfflineClient()
).event_parser
token_info = parser.parse_token_creation_from_geyser(_geyser_update())
if token_info is None:
print(" geyser 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_geyser_instruction_fallback_reads_v1() -> bool:
"""With the logs gone, the instruction decode still has to find the coin.
Same split as the block listener: logs are the route, instruction decoding
is the fallback, and each is pinned separately so neither can quietly start
carrying the other's load. The fallback must *not* set state_from_event —
`args.creator` is user-supplied and may differ from the
canonical `BondingCurve.creator`, so the curve still has to be read.
"""
update = _geyser_update()
del update.transaction.transaction.meta.log_messages[:]
parser = get_platform_implementations(
Platform.PUMP_FUN, _OfflineClient()
).event_parser
token_info = parser.parse_token_creation_from_geyser(update)
if token_info is None:
print(" instruction fallback returned None for a v1 create_v2")
return False
if token_info.mint is None:
print(" instruction fallback produced no mint")
return False
if token_info.state_from_event:
print(" fallback set state_from_event off an instruction-parsed creator")
return False
return True
def check_geyser_exposes_the_v1_budget() -> bool:
"""The inline v1 budget must be readable as a known field, not guesswork.
Transaction v1 moved the compute budget off ComputeBudget instructions and
onto the message, so anything reading a coin's budget from the instruction
list now reads nothing at all for a v1 coin. The listener reports this at
debug; the point of the check is that the field survives regeneration.
"""
config = _geyser_update().transaction.transaction.transaction.message.config
if not config.HasField("compute_unit_limit"):
print(" v1 fixture carries no compute unit limit")
return False
if config.compute_unit_limit <= 0:
print(f" compute unit limit is {config.compute_unit_limit}")
return False
# Unset means zero in v1, never a runtime default, so a real coin that
# reaches execution has to have set this explicitly.
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,
),
("solders still reads a v1 envelope", check_solders_reads_a_v1_envelope),
(
"geyser fixture is a successful v1 create_v2",
check_geyser_fixture_is_a_v1_create,
),
(
"geyser parser reads the v1 create",
check_geyser_parser_reads_the_v1_create,
),
(
"geyser instruction fallback reads v1 without logs",
check_geyser_instruction_fallback_reads_v1,
),
("geyser exposes the inline v1 budget", check_geyser_exposes_the_v1_budget),
("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())