mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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>
474 lines
19 KiB
Python
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())
|