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>
This commit is contained in:
Anton Sauchyk
2026-09-23 13:28:43 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 41b971147a
commit ebbfa9f124
34 changed files with 1583 additions and 476 deletions
+60 -18
View File
@@ -6,7 +6,14 @@ Solana trading bot for pump.fun and letsbonk.fun. Snipes newly created tokens an
## Ground rules
- **Never run a bot with real funds** to test a change. Use `cookbook/`, or the simulation scripts below, which move no funds.
- **Running a bot with real funds is how a change gets verified** — but only with
explicit approval for that session, and only after saying what will run and what
it costs. Iterate with `cookbook/` and the simulation scripts below, which move
no funds; they exercise the builders and listeners but skip `bot_runner`, config
loading, the trader loop, the exit strategy and cleanup, so they cannot close the
question on their own. A verification run covers every mode the change reaches —
each listener, `extreme_fast_mode` on *and* off, each `exit_strategy` — and ends
with the wallet holding SOL only.
- **Never** touch `.env` or print its contents. `SOLANA_PRIVATE_KEY` is a live key.
- Don't commit anything from `logs/`.
- Test with a cookbook script before touching `src/`.
@@ -126,7 +133,8 @@ Lint and format **the files you touched**, not the whole tree:
uv run ruff check --fix <paths> && uv run ruff format <paths>
```
A bare `uv run ruff check` reports ~1660 pre-existing errors across the repo.
A bare `uv run ruff check` reports ~1710 pre-existing errors across the repo
(ruff 0.16.8; 0.12.4 counted ~1680, the rules moved, not the code).
That is the known baseline, not something your change caused — don't try to fix
it wholesale, and don't read it as a failing build. Just don't add new ones in
the files you edit.
@@ -146,6 +154,28 @@ is protoc — needed only to regenerate the `geyser_pb2` stubs in
`sys.path` and importing `src.geyser.generated`. Don't add a second copy under
`cookbook/` — the last one drifted out of sync with the protos.
The committed stubs are protobuf gencode **6.31.1** running against runtime
7.36.2, which `ValidateProtobufRuntimeVersion` still accepts. Regenerating is
not a plain `protoc` run: the committed files import each other absolutely
(`from geyser.generated.solana_storage_pb2 import *`), which the compiler does
not emit on its own, so the output needs rewriting. Verified live under
grpcio 1.84 / protobuf 7.36.2 on 2026-09-23.
Where the Solana libraries live, since solana-py 0.40 moved most of them
(upgraded 2026-09-23 from solders 0.26 / solana 0.36.6):
- `TxOpts` is **`TxOptsModel`**, and it, `MemcmpOpts` and `TokenAccountOpts` are
in `solana.rpc.core`, not `solana.rpc.types`.
- spl-token's `*Params` (`BurnParams`, `CloseAccountParams`, `SyncNativeParams`,
…) are in `spl.token.models`, not `spl.token.instructions`, which still holds
the instruction builders.
- All of those are **pydantic models now**, so they are keyword-only and they
validate: `MemcmpOpts(bytes=...)` wants the base58 **str** a Pubkey's `str()`
already gives, and raw `bytes` raises `ValidationError`. Nothing in this class
of break shows up offline — an import sweep passes and the call fails against
mainnet.
- A bare `commitment="processed"` string still works; `Commitment` is a str enum.
### Verifying a change
Every fix here ships with an offline verifier under `tests/regression/`. Each
@@ -159,7 +189,7 @@ name individual scripts to run a subset.
| `verify_v2_account_layout.py` | buy_v2/sell_v2 account layouts, PDA/ATA derivations, encoding — against `idl/pump_fun_idl.json` |
| `verify_curve_account_sizes.py` | 125/151/256-byte curves all decode, and nothing filters on account length |
| `verify_create_v2_optional_args.py` | omitted trailing option-typed `create_v2` args decode as unset; mandatory args still fail |
| `verify_transaction_v1.py` | every reader asks `maxSupportedTransactionVersion: 1`, a v1 `create_v2` is detected without decoding its envelope, and no cookbook/tools listener detects by opening the envelope |
| `verify_transaction_v1.py` | every reader asks `maxSupportedTransactionVersion: 1`, a v1 `create_v2` is detected from its logs alone with the envelope made unreadable, the envelope decode still works as the fallback, and no cookbook/tools listener detects by opening the envelope |
| `verify_block_null_guard.py` | a `blockSubscribe` frame with `value.block: null` is skipped, not logged as an error |
| `verify_listener_cancellation.py` | a cancelled WebSocket listener stops, even when `websockets` reports cancellation as `AssertionError` |
| `verify_pumpportal_buy_path.py` | curve derived from the mint, unreadable curve skips the buy, curve+mint read in one slot-consistent batch |
@@ -175,6 +205,7 @@ name individual scripts to run a subset.
| `verify_quote_decimals_resolved.py` | no trade path prices a coin before resolving its quote mint's decimals |
| `verify_cookbook_arguments.py` | every cookbook script takes its input as a command-line argument |
| `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx |
| `verify_no_rpc_credentials_logged.py` | credentials are masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first |
Two mainnet simulations, also no funds moved:
@@ -199,19 +230,17 @@ The reasoning behind each lives in the verifier named beside it.
`blockSubscribe` for `0` does not skip the v1 transactions in a block, it nulls
`value.block` for the entire notification — indistinguishable from a skipped
slot, and a near-total outage of the blocks listener. Send `1` everywhere.
- solders cannot deserialize a v1 transaction until **0.29**, and a listener
should not depend on that either way: route on `meta.logMessages`, which the
RPC has already decoded and which reads the same for every version, and keep
the byte decode as a fallback. Measured across versions on 2026-09-22 with the
committed v1 fixture: 0.26, 0.27.1 and 0.28 all raise `ValueError: io error:
unexpected end of file`; 0.29 decodes it.
- **Upgrading to solders 0.29 is a solana-py migration, not a bump.** It needs
`solana>=0.40`, which moved `TxOpts`, `MemcmpOpts` and `TokenAccountOpts` out
of `solana.rpc.types` (`TxOpts` is now `TxOptsModel` in `solana.rpc.core`) —
22 files here import them, including the trade path. Tried and reverted:
16 of 19 verifiers failed on the import alone. Route on logs instead.
- The bot still **sends** legacy transactions. Everything above is about reading
other people's, so the v1 cutover changed nothing on the trade path.
- **A listener routes on `meta.logMessages`, never on the envelope decode.**
The RPC has already decoded the envelope by the time it emits the logs, and
they read the same for every transaction version; the byte decode is the
fallback. The repo now runs solders 0.29, which *does* read a v1 envelope
(0.26, 0.27.1 and 0.28 all raise `ValueError: io error: unexpected end of
file` on the committed v1 fixture, measured 2026-09-22) — that does not make
the log route redundant, because the next version byte will be unreadable in
its turn. `verify_transaction_v1.py` pins both routes separately.
- The bot **sends** v0 transactions (`MessageV0.try_compile` +
`VersionedTransaction`, no lookup tables), as of the 2026-09-23 dependency
upgrade. Nothing else builds a legacy `Message`.
- `post_rpc` must catch `asyncio.TimeoutError` alongside `aiohttp.ClientError` —
aiohttp raises the former on a request timeout, it is not a `ClientError`, and
`str()` on it is empty, so the caller logs a blank reason.
@@ -219,6 +248,14 @@ The reasoning behind each lives in the verifier named beside it.
behaviour) bounds wall time. Don't wrap a lookup in `asyncio.timeout` instead —
cutting off an in-flight `getTransaction` and returning None is the "can't see
it, so call it failed" conflation #206 removed.
- **The endpoints carry their API key in the URL, so any log line holding one
is a leak.** `install_secret_redaction` (`utils/logger.py`) masks the value at
log-record creation and every entry point calls it before attaching a handler.
Silencing the HTTP clients by name is a convenience on top, not the control:
`tools/cleanup_accounts.py` silenced `httpx` and `httpcore` and leaked anyway
once solana-py 0.40 switched to httpx2. Redact the **rendered** message, not
`record.msg` plus the string arguments — httpx2 passes its URL as a `URL`
object, so a type check skips the one argument that matters.
- `build_and_send_transaction` returns a solders `Signature`, not a `str`.
Normalize at the boundary: a `Signature` is neither JSON serializable nor
sliceable, and solana-py's `confirm_transaction` rejects a `str`.
@@ -284,8 +321,9 @@ offline and only visible after a couple of minutes against mainnet.
`except TimeoutError` or `except json.JSONDecodeError` is fine to swallow —
those are per-message, not per-connection.
- **Never gate a listener's dispatch on decoding the transaction envelope.**
The envelope is the one part of a transaction whose format changes under you —
solders cannot read a v1 one at all. `meta.logMessages` is already decoded by
The envelope is the one part of a transaction whose format changes under you,
and the installed solders is only ever one version behind that.
`meta.logMessages` is already decoded by
the RPC and is version-agnostic, so route on it and keep the byte decode as a
fallback. See the Invariants above.
- **Resolve v0 lookup-table accounts before indexing them.** An instruction's
@@ -543,3 +581,7 @@ interface pump.fun maintains.
means updating `PLATFORM_LISTENER_COMPATIBILITY` there too.
- Bots with `separate_process: true` run in their own process. One log file per
bot instance.
- `pump_bot` runs **every** `bots/*.yaml`, and three of the four committed configs
ship `enabled: false`. With all of them disabled it prints nothing and exits 0,
which is indistinguishable from a clean run — check that
`logs/<name>_<timestamp>.log` appeared before reading anything into a run.
@@ -34,13 +34,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
@@ -379,13 +379,13 @@ async def run(
# Send transaction
async with AsyncClient(RPC_ENDPOINT) as client:
recent_blockhash = await client.get_latest_blockhash()
message = Message(instructions, payer.pubkey())
transaction = Transaction(
[payer, mint_keypair], message, recent_blockhash.value.blockhash
message = MessageV0.try_compile(
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
)
transaction = VersionedTransaction(message, [payer, mint_keypair])
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
@@ -394,21 +394,22 @@ async def run(
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
await client.confirm_transaction(tx_hash, commitment=Confirmed)
await tx_status.assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
buy_tx = VersionedTransaction(
MessageV0.try_compile(
payer.pubkey(), buy_instructions, [], buy_blockhash.value.blockhash
),
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await client.confirm_transaction(buy_hash, commitment=Confirmed)
await tx_status.assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
@@ -28,11 +28,11 @@ from dotenv import load_dotenv
from letsbonk_idl_parser import load_idl_parser
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
@@ -660,8 +660,8 @@ async def buy_exact_in(
blockhash_resp = await client.get_latest_blockhash()
recent_blockhash = blockhash_resp.value.blockhash
message = Message.new_with_blockhash(
instructions, PAYER.pubkey(), recent_blockhash
message = MessageV0.try_compile(
PAYER.pubkey(), instructions, [], recent_blockhash
)
transaction = VersionedTransaction(message, [PAYER])
@@ -684,7 +684,7 @@ async def buy_exact_in(
print("Sending transaction...")
result = await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
tx_signature = result.value
@@ -28,11 +28,11 @@ from dotenv import load_dotenv
from letsbonk_idl_parser import load_idl_parser
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
@@ -669,8 +669,8 @@ async def buy_exact_out(
blockhash_resp = await client.get_latest_blockhash()
recent_blockhash = blockhash_resp.value.blockhash
message = Message.new_with_blockhash(
instructions, PAYER.pubkey(), recent_blockhash
message = MessageV0.try_compile(
PAYER.pubkey(), instructions, [], recent_blockhash
)
transaction = VersionedTransaction(message, [PAYER])
@@ -693,7 +693,7 @@ async def buy_exact_out(
print("Sending transaction...")
result = await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
tx_signature = result.value
@@ -28,11 +28,11 @@ from dotenv import load_dotenv
from letsbonk_idl_parser import load_idl_parser
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
@@ -660,8 +660,8 @@ async def sell_exact_in(
blockhash_resp = await client.get_latest_blockhash()
recent_blockhash = blockhash_resp.value.blockhash
message = Message.new_with_blockhash(
instructions, PAYER.pubkey(), recent_blockhash
message = MessageV0.try_compile(
PAYER.pubkey(), instructions, [], recent_blockhash
)
transaction = VersionedTransaction(message, [PAYER])
@@ -684,7 +684,7 @@ async def sell_exact_in(
print("Sending transaction...")
result = await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
tx_signature = result.value
@@ -28,11 +28,11 @@ from dotenv import load_dotenv
from letsbonk_idl_parser import load_idl_parser
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
@@ -662,8 +662,8 @@ async def sell_exact_out(
blockhash_resp = await client.get_latest_blockhash()
recent_blockhash = blockhash_resp.value.blockhash
message = Message.new_with_blockhash(
instructions, PAYER.pubkey(), recent_blockhash
message = MessageV0.try_compile(
PAYER.pubkey(), instructions, [], recent_blockhash
)
transaction = VersionedTransaction(message, [PAYER])
@@ -686,7 +686,7 @@ async def sell_exact_out(
print("Sending transaction...")
result = await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
tx_signature = result.value
@@ -14,15 +14,25 @@ Two things this example exists to show, because both are easy to get wrong:
trades reach the program as a CPI from an aggregator or router, so a decoder
that only walks the top level sees almost nothing — in a sample of 40 consecutive
pump.fun transactions there was 1 top-level pump instruction against 8 inner ones.
3. Accept whichever encoding the response was captured in. `getTransaction`
answers in `jsonParsed`, `json` or `base64` depending on what was asked for,
and the three look nothing alike: `json` gives account *indices* where
`jsonParsed` gives addresses, and `base64` gives the raw envelope and nothing
else. `normalize_result` folds all three into the jsonParsed shape, so the
rest of the file only ever sees one layout. The fixtures next to this script
cover all three on purpose.
"""
import argparse
import base64
import hashlib
import json
import struct
from collections.abc import Iterator
import base58
from solders.transaction import VersionedTransaction
PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
DEFAULT_TX = "cookbook/pumpfun/decode/raw_buy_tx_from_gettransaction.json"
@@ -184,6 +194,118 @@ DECODERS = {
}
def _account_keys(result: dict) -> list[str]:
"""Return every account the transaction can index, in index order.
An instruction's account indices run past `message.accountKeys` into the
address lookup tables, and the writable ones come before the readonly ones.
Ignoring them makes a v0 transaction index out of range. getTransaction
reports them as `meta.loadedAddresses.{writable,readonly}`; geyser splits
the same two lists into `meta.loadedWritableAddresses` and
`meta.loadedReadonlyAddresses`, so both spellings are read here.
Args:
result: A getTransaction result, jsonParsed or json encoded
Returns:
Static keys followed by the writable then readonly loaded addresses
"""
keys = result["transaction"]["message"]["accountKeys"]
static = [k["pubkey"] if isinstance(k, dict) else k for k in keys]
meta = result.get("meta") or {}
loaded = meta.get("loadedAddresses") or {}
return [
*static,
*(loaded.get("writable") or meta.get("loadedWritableAddresses") or []),
*(loaded.get("readonly") or meta.get("loadedReadonlyAddresses") or []),
]
def _resolve_indices(instructions: list[dict], keys: list[str]) -> list[dict]:
"""Rewrite json-encoded instructions into the jsonParsed shape.
Args:
instructions: Instructions carrying `programIdIndex` and index accounts
keys: Account addresses in index order, from `_account_keys`
Returns:
The same instructions, with `programId` and address accounts
"""
return [
{
**ix,
"programId": keys[ix["programIdIndex"]],
"accounts": [keys[i] for i in ix.get("accounts", [])],
}
if "programIdIndex" in ix
else ix
for ix in instructions
]
def normalize_result(payload: dict) -> dict:
"""Fold any getTransaction response into the jsonParsed shape.
Handles the JSON-RPC envelope or a bare result, and all three encodings:
`jsonParsed` passes through, `json` has its account indices resolved, and
`base64` is deserialized. solders can only read a base64 **v1** transaction
from 0.29 on; earlier versions raise `ValueError: io error: unexpected end
of file`.
Args:
payload: A parsed getTransaction response, with or without the envelope
Returns:
A result whose instructions all carry `programId` and address accounts
"""
result = payload["result"] if "result" in payload else payload
transaction = result["transaction"]
# base64: ["<encoded>", "base64"], and the envelope is all there is.
if isinstance(transaction, list):
decoded = VersionedTransaction.from_bytes(base64.b64decode(transaction[0]))
message = decoded.message
result = {
**result,
"transaction": {
"message": {
"accountKeys": [str(key) for key in message.account_keys],
"recentBlockhash": str(message.recent_blockhash),
"instructions": [
{
"programIdIndex": ix.program_id_index,
"accounts": list(ix.accounts),
"data": base58.b58encode(bytes(ix.data)).decode(),
}
for ix in message.instructions
],
},
"signatures": [str(sig) for sig in decoded.signatures],
},
}
keys = _account_keys(result)
message = result["transaction"]["message"]
meta = result.get("meta") or {}
return {
**result,
"transaction": {
**result["transaction"],
"message": {
**message,
"instructions": _resolve_indices(message.get("instructions", []), keys),
},
},
"meta": {
**meta,
"innerInstructions": [
{**group, "instructions": _resolve_indices(group["instructions"], keys)}
for group in meta.get("innerInstructions") or []
],
},
}
def iter_pump_instructions(result: dict) -> Iterator[tuple[str, dict]]:
"""Yield every pump.fun instruction in a getTransaction result.
@@ -267,7 +389,7 @@ def main() -> None:
events = build_event_index(idl)
with open(tx_file_path) as f:
result = json.load(f)["result"]
result = normalize_result(json.load(f))
found = 0
for location, ix in iter_pump_instructions(result):
@@ -283,9 +405,10 @@ def main() -> None:
message = result["transaction"]["message"]
print("\nTransaction Information:")
print(f"Blockhash: {message['recentBlockhash']}")
print(f"Fee payer: {message['accountKeys'][0]['pubkey']}")
print(f"Fee payer: {_account_keys(result)[0]}")
print(f"Signature: {result['transaction']['signatures'][0]}")
print(f"Slot: {result.get('slot')}")
print(f"Transaction version: {result.get('version', 'legacy')}")
if __name__ == "__main__":
@@ -98,7 +98,7 @@ from typing import Any, Final
import grpc
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.types import TokenAccountOpts
from solana.rpc.core import TokenAccountOpts
from solders.pubkey import Pubkey
from solders.signature import Signature
@@ -119,7 +119,7 @@ from typing import Any, Final
import websockets
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.types import TokenAccountOpts
from solana.rpc.core import TokenAccountOpts
from solders.pubkey import Pubkey
load_dotenv()
@@ -39,13 +39,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import create_idempotent_associated_token_account
load_dotenv()
@@ -185,8 +185,8 @@ async def buy_exact_quote(
)
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = Transaction(
[payer], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash), [payer]
)
if dry_run:
@@ -198,7 +198,7 @@ async def buy_exact_quote(
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
@@ -51,13 +51,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import create_idempotent_associated_token_account
load_dotenv()
@@ -160,8 +160,8 @@ async def buy_exact_sol(
]
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = Transaction(
[payer], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash), [payer]
)
if dry_run:
@@ -173,7 +173,7 @@ async def buy_exact_sol(
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
@@ -32,13 +32,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import create_idempotent_associated_token_account
load_dotenv()
@@ -185,8 +185,8 @@ async def buy(
)
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = Transaction(
[payer], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash), [payer]
)
if dry_run:
@@ -198,7 +198,7 @@ async def buy(
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
@@ -39,12 +39,12 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
load_dotenv()
@@ -97,8 +97,8 @@ async def claim(user: Pubkey, quote_mint: Pubkey, *, dry_run: bool) -> None:
),
]
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = Transaction(
[payer], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash), [payer]
)
if dry_run:
@@ -110,7 +110,7 @@ async def claim(user: Pubkey, quote_mint: Pubkey, *, dry_run: bool) -> None:
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
@@ -42,12 +42,12 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
load_dotenv()
@@ -99,8 +99,8 @@ async def collect(creator: Pubkey, quote_mint: Pubkey, *, dry_run: bool) -> None
),
]
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = Transaction(
[payer], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash), [payer]
)
if dry_run:
@@ -112,7 +112,7 @@ async def collect(creator: Pubkey, quote_mint: Pubkey, *, dry_run: bool) -> None
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
@@ -33,13 +33,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
@@ -513,34 +513,35 @@ async def run( # noqa: PLR0913
]
recent_blockhash = await client.get_latest_blockhash()
message = Message(instructions, payer.pubkey())
transaction = Transaction(
[payer, mint_keypair], message, recent_blockhash.value.blockhash
message = MessageV0.try_compile(
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
)
transaction = VersionedTransaction(message, [payer, mint_keypair])
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
tx_hash = response.value
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
await client.confirm_transaction(tx_hash, commitment=Confirmed)
await tx_status.assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
buy_tx = VersionedTransaction(
MessageV0.try_compile(
payer.pubkey(), buy_instructions, [], buy_blockhash.value.blockhash
),
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await client.confirm_transaction(buy_hash, commitment=Confirmed)
await tx_status.assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
@@ -41,13 +41,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import get_associated_token_address
load_dotenv()
@@ -281,13 +281,14 @@ async def create(
async with AsyncClient(RPC_ENDPOINT) as client:
blockhash = (await client.get_latest_blockhash()).value.blockhash
# The mint signs too — it is a brand-new account being created.
transaction = Transaction(
[payer, mint_keypair], Message(instructions, payer.pubkey()), blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(payer.pubkey(), instructions, [], blockhash),
[payer, mint_keypair],
)
signature = (
await client.send_transaction(
transaction,
opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed),
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
+17 -16
View File
@@ -33,13 +33,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
@@ -269,29 +269,30 @@ async def sell_token(
)
instructions.append(sell_ix)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
# The blockhash is fixed for every attempt, so the transaction is built
# once: each retry resubmits identical bytes.
transaction = VersionedTransaction(
MessageV0.try_compile(
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
),
[payer],
)
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
# Continue with the sell transaction
for attempt in range(max_retries):
try:
tx = await client.send_transaction(
Transaction(
[payer],
msg,
recent_blockhash.value.blockhash,
),
opts=opts,
)
tx = await client.send_transaction(transaction, opts=opts)
tx_hash = tx.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
await tx_status.confirm_and_assert(client, tx_hash)
print("Transaction confirmed")
return # Success, exit the function
except tx_status.TransactionRevertedError as e:
# The signature is already on chain and reverted. The message and
# blockhash below are fixed, so a retry would resubmit identical
# bytes and revert identically — stop instead of burning attempts.
# The signature is already on chain and reverted. The
# transaction above is fixed, so a retry would resubmit
# identical bytes and revert identically — stop instead of
# burning attempts.
print(f"Transaction reverted on-chain, not retrying: {e}")
return
except Exception as e:
@@ -38,14 +38,14 @@ import websockets
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.instruction import Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
)
@@ -258,18 +258,21 @@ async def buy_token(
)
instructions.append(buy_ix)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
# The blockhash is fixed for every attempt, so the transaction is built
# once: each retry resubmits identical bytes.
transaction = VersionedTransaction(
MessageV0.try_compile(
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
),
[payer],
)
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
for attempt in range(max_retries):
try:
tx_buy = await client.send_transaction(
Transaction(
[payer],
msg,
recent_blockhash.value.blockhash,
),
transaction,
opts=opts,
)
tx_hash = tx_buy.value
@@ -278,9 +281,10 @@ async def buy_token(
print("Transaction confirmed")
return # Success, exit the function
except tx_status.TransactionRevertedError as e:
# The signature is already on chain and reverted. The message and
# blockhash below are fixed, so a retry would resubmit identical
# bytes and revert identically — stop instead of burning attempts.
# The signature is already on chain and reverted. The
# transaction above is fixed, so a retry would resubmit
# identical bytes and revert identically — stop instead of
# burning attempts.
print(f"Transaction reverted on-chain, not retrying: {e}")
return
except Exception as e:
@@ -33,13 +33,13 @@ import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
)
@@ -336,19 +336,20 @@ async def buy_token(
)
)
instructions.append(buy_ix)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
# The blockhash is fixed for every attempt, so the transaction is built
# once: each retry resubmits identical bytes.
transaction = VersionedTransaction(
MessageV0.try_compile(
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
),
[payer],
)
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
print("Simulating transaction...")
try:
sim_result = await client.simulate_transaction(
Transaction(
[payer],
msg,
recent_blockhash.value.blockhash,
),
)
sim_result = await client.simulate_transaction(transaction)
print(f"Simulation result: {sim_result}")
if sim_result.value.err:
print(f"Simulation error: {sim_result.value.err}")
@@ -357,23 +358,17 @@ async def buy_token(
for attempt in range(max_retries):
try:
tx_buy = await client.send_transaction(
Transaction(
[payer],
msg,
recent_blockhash.value.blockhash,
),
opts=opts,
)
tx_buy = await client.send_transaction(transaction, opts=opts)
tx_hash = tx_buy.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
await tx_status.confirm_and_assert(client, tx_hash)
print("Transaction confirmed")
return # Success, exit the function
except tx_status.TransactionRevertedError as e:
# The signature is already on chain and reverted. The message and
# blockhash below are fixed, so a retry would resubmit identical
# bytes and revert identically — stop instead of burning attempts.
# The signature is already on chain and reverted. The
# transaction above is fixed, so a retry would resubmit
# identical bytes and revert identically — stop instead of
# burning attempts.
print(f"Transaction reverted on-chain, not retrying: {e}")
return
except Exception as e:
+12 -9
View File
@@ -26,20 +26,20 @@ import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import MemcmpOpts, TxOpts
from solana.rpc.core import MemcmpOpts, TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import TransferParams, transfer
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
SyncNativeParams,
create_idempotent_associated_token_account,
get_associated_token_address,
sync_native,
)
from spl.token.models import SyncNativeParams
sys.path.append(
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "solana")
@@ -164,7 +164,8 @@ async def get_market_address_by_base_mint(
Returns:
Address of the AMM pool (market) for the token
"""
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=bytes(base_mint_address))]
# MemcmpOpts takes the bytes base58-encoded, which a Pubkey's str already is.
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(base_mint_address))]
response = await client.get_program_accounts(
amm_program_id, encoding="base64", filters=filters
)
@@ -654,9 +655,10 @@ async def buy_pump_swap(
)
)
sync_native_ix = sync_native(
# WSOL always uses the standard Token program
SyncNativeParams(
SYSTEM_TOKEN_PROGRAM, user_quote_token_account
) # WSOL always uses standard Token program
program_id=SYSTEM_TOKEN_PROGRAM, account=user_quote_token_account
)
)
# Create token account for receiving purchased tokens
@@ -671,7 +673,8 @@ async def buy_pump_swap(
# Build and sign transaction
blockhash_resp = await client.get_latest_blockhash()
msg = Message.new_with_blockhash(
msg = MessageV0.try_compile(
payer.pubkey(),
[
compute_limit_ix,
compute_price_ix,
@@ -681,7 +684,7 @@ async def buy_pump_swap(
create_token_ata_ix,
buy_ix,
],
payer.pubkey(),
[],
blockhash_resp.value.blockhash,
)
tx = VersionedTransaction(message=msg, keypairs=[payer])
@@ -705,7 +708,7 @@ async def buy_pump_swap(
try:
# Skip preflight since we already simulated (faster execution)
tx_sig = await client.send_transaction(
tx, opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
tx, opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
)
tx_hash = tx_sig.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
+4 -5
View File
@@ -14,7 +14,7 @@ import struct
import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.types import MemcmpOpts
from solana.rpc.core import MemcmpOpts
from solders.pubkey import Pubkey
load_dotenv()
@@ -28,13 +28,12 @@ async def get_market_address_by_base_mint(
base_mint_address: Pubkey, amm_program_id: Pubkey
):
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
base_mint_bytes = bytes(base_mint_address)
# Define the offset for base_mint field
offset = 43
# Create the filter to match the base_mint
filters = [MemcmpOpts(offset=offset, bytes=base_mint_bytes)]
# Create the filter to match the base_mint. MemcmpOpts takes the bytes
# base58-encoded, which is what a Pubkey's str already is.
filters = [MemcmpOpts(offset=offset, bytes=str(base_mint_address))]
# Retrieve the accounts that match the filter
response = await client.get_program_accounts(
+8 -6
View File
@@ -24,11 +24,11 @@ import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import MemcmpOpts, TxOpts
from solana.rpc.core import MemcmpOpts, TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import VersionedTransaction
from spl.token.instructions import get_associated_token_address
@@ -150,7 +150,8 @@ async def get_market_address_by_base_mint(
Returns:
Address of the AMM pool (market) for the token
"""
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=bytes(base_mint_address))]
# MemcmpOpts takes the bytes base58-encoded, which a Pubkey's str already is.
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(base_mint_address))]
response = await client.get_program_accounts(
amm_program_id, encoding="base64", filters=filters
)
@@ -646,9 +647,10 @@ async def sell_pump_swap(
# Build and sign transaction
blockhash_resp = await client.get_latest_blockhash()
msg = Message.new_with_blockhash(
[compute_limit_ix, compute_price_ix, create_ata_ix, sell_ix],
msg = MessageV0.try_compile(
payer.pubkey(),
[compute_limit_ix, compute_price_ix, create_ata_ix, sell_ix],
[],
blockhash_resp.value.blockhash,
)
tx = VersionedTransaction(message=msg, keypairs=[payer])
@@ -656,7 +658,7 @@ async def sell_pump_swap(
try:
# Skip preflight to send transaction faster (useful in competitive scenarios)
tx_sig = await client.send_transaction(
tx, opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
tx, opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
)
tx_hash = tx_sig.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
+1 -1
View File
@@ -26,7 +26,7 @@ import os
import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.types import TokenAccountOpts
from solana.rpc.core import TokenAccountOpts
from solders.keypair import Keypair
from solders.pubkey import Pubkey
+11 -11
View File
@@ -7,24 +7,24 @@ requires-python = ">=3.11"
dependencies = [
"base58>=2.1.1",
"construct>=2.10.67",
"solana==0.36.6",
"solders>=0.26.0",
"websockets>=15.0",
"python-dotenv>=1.2.2",
"construct>=2.10.70",
"solana>=0.40.3",
"solders>=0.29.0",
"websockets>=17.1",
"python-dotenv>=1.2.3",
"aiohttp>=3.14.3",
"grpcio>=1.71.0",
"protobuf>=5.29.4",
"pyyaml>=6.0.2",
"uvloop>=0.21.0; sys_platform != 'win32'",
"grpcio>=1.84.0",
"protobuf>=7.36.2",
"pyyaml>=6.0.3",
"uvloop>=0.22.1; sys_platform != 'win32'",
"winloop>=0.1.0; sys_platform == 'win32'",
]
[dependency-groups]
dev = [
"ruff>=0.10.0",
"ruff>=0.16.8",
# protoc; only needed to regenerate src/geyser/generated from src/geyser/proto
"grpcio-tools>=1.71.0",
"grpcio-tools>=1.84.0",
]
[project.scripts]
+5 -1
View File
@@ -33,7 +33,7 @@ from trading.universal_trader import (
DEFAULT_MAX_EXIT_SELL_ATTEMPTS,
UniversalTrader,
)
from utils.logger import setup_file_logging
from utils.logger import install_secret_redaction, setup_file_logging
def setup_logging(bot_name: str):
@@ -274,6 +274,10 @@ def main() -> None:
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Turning the root logger up to INFO is what makes the HTTP clients start
# printing request URLs, and those URLs carry the provider API key. Redact
# before the first line is emitted, not after.
install_secret_redaction()
# Log supported platforms and listeners
try:
+2 -1
View File
@@ -1,7 +1,8 @@
import asyncio
from solders.pubkey import Pubkey
from spl.token.instructions import BurnParams, CloseAccountParams, burn, close_account
from spl.token.instructions import burn, close_account
from spl.token.models import BurnParams, CloseAccountParams
from core.client import SolanaClient
from core.priority_fee.manager import PriorityFeeManager
+9 -6
View File
@@ -11,16 +11,16 @@ from typing import Any
import aiohttp
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Processed
from solana.rpc.types import TxOpts
from solana.rpc.core import TxOptsModel
from solders.account import Account
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.hash import Hash
from solders.instruction import Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.signature import Signature
from solders.transaction import Transaction
from solders.transaction import VersionedTransaction
from core.pubkeys import is_sol_paired
from core.rpc_rate_limiter import TokenBucketRateLimiter
@@ -444,13 +444,16 @@ class SolanaClient:
instructions = fee_instructions + instructions
recent_blockhash = await self.get_cached_blockhash()
message = Message(instructions, signer_keypair.pubkey())
transaction = Transaction([signer_keypair], message, recent_blockhash)
# No address lookup tables: the bot builds every account list itself.
message = MessageV0.try_compile(
signer_keypair.pubkey(), instructions, [], recent_blockhash
)
transaction = VersionedTransaction(message, [signer_keypair])
for attempt in range(max_retries):
try:
await self._rate_limiter.acquire()
tx_opts = TxOpts(
tx_opts = TxOptsModel(
skip_preflight=skip_preflight, preflight_commitment=Processed
)
response = await client.send_transaction(transaction, tx_opts)
+185
View File
@@ -1,12 +1,187 @@
"""
Logging utilities for the pump.fun trading bot.
Includes the credential redaction every entry point installs before it logs
anything — see `install_secret_redaction`.
"""
import logging
import os
import re
from urllib.parse import urlsplit
# Global dict to store loggers
_loggers: dict[str, logging.Logger] = {}
# HTTP and RPC clients that log a full request URL at INFO. Silencing them is
# the first line of defence, but only that: solana-py 0.40 swapped httpx for
# httpx2, which renamed the logger and silently reopened the leak this list was
# written to close. `install_secret_redaction` is what actually holds.
_NOISY_HTTP_LOGGERS = (
"httpx",
"httpx2",
"httpcore",
"aiohttp.client",
"urllib3",
"urllib3.connectionpool",
"grpc",
"websockets.client",
)
# Query parameters that carry a credential. Providers spell it differently —
# `api-key`, `apiKey`, `token` — so match the lot, case-insensitively.
_SECRET_QUERY_KEYS = ("api-key", "api_key", "apikey", "token", "auth", "key", "secret")
_SECRET_QUERY = re.compile(
r"(?i)\b(" + "|".join(re.escape(k) for k in _SECRET_QUERY_KEYS) + r")=[^&\s\"\']+"
)
# A credential sitting in the URL path rather than the query, which is how
# Chainstack and QuickNode endpoints are shaped. A path segment this long and
# this opaque is never a route name.
_SECRET_PATH_SEGMENT = re.compile(r"/[A-Za-z0-9_-]{20,}(?=[/?\s\"\']|$)")
# user:password@host
_URL_USERINFO = re.compile(r"(?<=//)[^/@\s]+(?=@)")
REDACTED = "***"
# Exact values pulled from the environment at install time. Pattern matching
# cannot know that a bare token is a secret; this does.
_literal_secrets: list[str] = []
_redaction_installed = False
# Environment variables whose value must never appear in a log line. The
# endpoints carry their credential inside the URL, so the whole value counts.
_SECRET_ENV_VARS = (
"SOLANA_PRIVATE_KEY",
"GEYSER_API_TOKEN",
"SOLANA_NODE_RPC_ENDPOINT",
"SOLANA_NODE_WSS_ENDPOINT",
"GEYSER_ENDPOINT",
)
# Shortest value worth substring-matching. A one- or two-character variable
# would match half the alphabet and redact the whole log.
_MIN_LITERAL_SECRET_LEN = 8
def redact(text: str) -> str:
"""Strip credentials out of one string.
Masks, in order: any exact value registered from the environment, URL
userinfo, secret-bearing query parameters, and long opaque URL path
segments. Safe to call on a string that holds no secret.
Args:
text: The string to clean, typically a log message
Returns:
The same string with every credential replaced by `***`
"""
for secret in _literal_secrets:
if secret in text:
text = text.replace(secret, _redact_endpoint(secret))
text = _URL_USERINFO.sub(REDACTED, text)
text = _SECRET_QUERY.sub(lambda m: f"{m.group(1)}={REDACTED}", text)
return _SECRET_PATH_SEGMENT.sub(f"/{REDACTED}", text)
def _redact_endpoint(secret: str) -> str:
"""Replace one known secret, keeping the host when it is a URL.
A bare `***` in place of an endpoint hides which provider answered, which
is the one thing a reader needs from that line. The host is not the
credential, so it stays.
Args:
secret: The exact value read from the environment
Returns:
`scheme://host/***` for a URL, `***` for anything else
"""
parts = urlsplit(secret)
if parts.scheme and parts.hostname:
return f"{parts.scheme}://{parts.hostname}/{REDACTED}"
return REDACTED
def silence_http_client_loggers() -> None:
"""Raise the HTTP clients above INFO so they stop logging request URLs."""
for name in _NOISY_HTTP_LOGGERS:
logging.getLogger(name).setLevel(logging.WARNING)
def install_secret_redaction() -> None:
"""Redact credentials from every log record, whoever emits it.
Wraps the log record factory rather than filtering a logger or a handler.
A filter only sees records that reach the object it is attached to, so it
misses a library that logs through its own logger before the bot's handlers
exist; the factory runs for every record in the process, including ones
created by code this repo never imports.
Idempotent, and the environment is read once here so a secret that lands in
`os.environ` later is still caught by the patterns above.
"""
global _redaction_installed
_literal_secrets.clear()
for name in _SECRET_ENV_VARS:
value = os.environ.get(name)
if value and len(value) >= _MIN_LITERAL_SECRET_LEN:
_literal_secrets.append(value)
# Longest first, so an endpoint is masked before a token that is a prefix
# of it can replace half of it.
_literal_secrets.sort(key=len, reverse=True)
silence_http_client_loggers()
if _redaction_installed:
return
previous_factory = logging.getLogRecordFactory()
def factory(*args: object, **kwargs: object) -> logging.LogRecord:
record = previous_factory(*args, **kwargs)
return _redact_record(record)
logging.setLogRecordFactory(factory)
_redaction_installed = True
def _redact_record(record: logging.LogRecord) -> logging.LogRecord:
"""Mask any credential in a record, message and arguments together.
Renders the record the way a handler eventually will, then redacts that.
Masking `record.msg` and its string arguments separately is not enough:
httpx2 logs the URL as a `URL` object, not a `str`, so a type check skips
the one argument that carries the key — the first version of this guard did
exactly that and the key still reached the terminal. Rendering first means
the argument's type stops mattering.
The rendered text replaces the record only when redaction actually changed
something, so an ordinary line keeps its original message and arguments.
Args:
record: The record as the factory built it
Returns:
The same record, credentials masked
"""
try:
message = record.getMessage()
except Exception: # noqa: BLE001 - a malformed record must not break logging
if isinstance(record.msg, str):
record.msg = redact(record.msg)
return record
redacted = redact(message)
if redacted != message:
record.msg = redacted
record.args = None
return record
def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
"""Get or create a logger with the given name.
@@ -23,6 +198,12 @@ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
if name in _loggers:
return _loggers[name]
# Every module in src/ calls this at import, which makes it the one place
# guaranteed to run before anything logs. A caller that forgets to install
# redaction still gets it; see install_secret_redaction for why a filter on
# a handler would not be enough.
install_secret_redaction()
logger = logging.getLogger(name)
logger.setLevel(level)
@@ -39,6 +220,10 @@ def setup_file_logging(
filename: Log file path
level: Logging level for file handler
"""
# A file handler at INFO captures the HTTP clients' request URLs into
# logs/, where they outlive the terminal. Redact before it is attached.
install_secret_redaction()
root_logger = logging.getLogger()
# Check if file handler with same filename already exists
@@ -0,0 +1,279 @@
"""Verify no RPC credential can reach a log line.
The provider endpoints in `.env` carry their API key inside the URL, and the
HTTP clients log a full request URL at INFO. Anything that raises the root
logger to INFO therefore prints the key — `tools/cleanup_accounts.py` did,
for four runs on 2026-09-23, in a terminal and into `logs/`.
That script was already silencing `httpx` and `httpcore` by name. It leaked
anyway, because solana-py 0.40 swapped httpx for httpx2 and the rename took the
guard with it. So naming the clients is a convenience, not the control: these
checks prove the value is masked whichever logger emits it.
Offline machine checks, no network and no funds moved:
1. `redact` masks a secret-bearing query parameter, URL userinfo, and an
opaque path segment — the three shapes a provider credential takes.
2. `redact` masks an exact value registered from the environment, keeping the
host so a log line still says which provider answered.
3. A logger this repo has never heard of, logging at INFO, comes out
redacted — including when the URL is a non-`str` argument, which is how
httpx2 passes it and how the first version of this guard was defeated.
This is the check that would have caught the httpx -> httpx2 rename.
4. Importing `core.client` installs the redaction: every module that logs
calls `get_logger` at import, and that is where it is installed.
5. `setup_file_logging` installs it before attaching the handler, so nothing
reaches `logs/` unredacted.
6. Every `logging.basicConfig` in `src/` and `tools/` installs it too, so the
guard does not depend on import order.
7. No script prints an endpoint environment variable raw; the hostname is the
most any of them may show.
Usage:
uv run tests/regression/verify_no_rpc_credentials_logged.py
"""
import logging
import os
import re
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from utils.logger import ( # noqa: E402
REDACTED,
install_secret_redaction,
redact,
)
SCAN_ROOTS = ("src", "tools", "cookbook")
# The variables whose values are credentials. Printing one raw is the bug.
SECRET_ENV_VARS = (
"SOLANA_PRIVATE_KEY",
"GEYSER_API_TOKEN",
"SOLANA_NODE_RPC_ENDPOINT",
"SOLANA_NODE_WSS_ENDPOINT",
"GEYSER_ENDPOINT",
)
# A print or f-string that interpolates one of those names directly. Reading
# `.hostname` off it first is allowed and is what the cookbook scripts do.
RAW_ENV_IN_OUTPUT = re.compile(
r"(?:print|logger\.\w+|logging\.\w+)\([^)]*\{("
+ "|".join(SECRET_ENV_VARS + ("RPC_ENDPOINT", "WSS_ENDPOINT"))
+ r")\}"
)
class _NotAString:
"""Stands in for httpx2's URL object: renders as a URL, is not a `str`."""
def __init__(self, value: str) -> None:
self._value = value
def __str__(self) -> str:
return self._value
class _Capture(logging.Handler):
"""Collect formatted records so a check can read what would be printed."""
def __init__(self) -> None:
super().__init__()
self.lines: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.lines.append(record.getMessage())
def check_redact_masks_every_credential_shape() -> bool:
"""The three ways a provider puts a key in a URL."""
cases = {
"https://example.com/?api-key=deadbeef0123": "api-key=deadbeef0123",
"https://example.com/?apiKey=deadbeef0123": "deadbeef0123",
"wss://user:hunter2@example.com/stream": "user:hunter2",
"https://example.com/abcdef0123456789abcdef/": "abcdef0123456789abcdef",
}
ok = True
for raw, secret in cases.items():
cleaned = redact(raw)
if secret in cleaned or REDACTED not in cleaned:
print(f" not masked: {raw} -> {cleaned}")
ok = False
return ok
def check_redact_masks_a_registered_value() -> bool:
"""An exact environment value is masked, and the host survives."""
endpoint = "https://rpc.example.com/9f8e7d6c5b4a39281706"
os.environ["SOLANA_NODE_RPC_ENDPOINT"] = endpoint
try:
install_secret_redaction()
cleaned = redact(f"connecting to {endpoint}")
finally:
del os.environ["SOLANA_NODE_RPC_ENDPOINT"]
install_secret_redaction()
if "9f8e7d6c5b4a39281706" in cleaned:
print(f" the registered value survived: {cleaned}")
return False
if "rpc.example.com" not in cleaned:
print(f" the host was masked too, which makes the line useless: {cleaned}")
return False
return True
def check_an_unknown_logger_is_redacted() -> bool:
"""The control, and the one that survives a library renaming its logger.
Silencing clients by name is a list that goes stale. This logs the same
line httpx2 logs, through a name nothing in this repo mentions, at INFO.
"""
install_secret_redaction()
logger = logging.getLogger("some.library.nobody.listed")
logger.setLevel(logging.INFO)
capture = _Capture()
logger.addHandler(capture)
try:
logger.info(
"HTTP Request: POST https://mainnet.example.com/?api-key=deadbeef0123 "
'"HTTP/2 200 OK"'
)
logger.info("endpoint=%s", "https://mainnet.example.com/?api-key=deadbeef0123")
# The shape that actually leaks. httpx2 passes its URL as a URL object,
# not a str, so a guard that redacts `record.msg` and then type-checks
# each argument skips the only one carrying the key. The first version
# of this guard did that and the key still reached the terminal.
logger.info(
'HTTP Request: %s %s "%s"',
"POST",
_NotAString("https://mainnet.example.com/?api-key=deadbeef0123"),
"HTTP/2 200 OK",
)
finally:
logger.removeHandler(capture)
leaked = [line for line in capture.lines if "deadbeef0123" in line]
if leaked:
print(f" {len(leaked)} of {len(capture.lines)} lines kept the key")
return False
if not all("mainnet.example.com" in line for line in capture.lines):
print(" the host was masked too, which makes the line useless")
return False
return len(capture.lines) == 3
def check_importing_src_installs_redaction() -> bool:
"""A fresh interpreter that imports the RPC client is already protected.
`core.client` is the module that talks to the endpoint, and like every
module that logs it calls `get_logger` at import, which installs the
redaction. Run out of process, because this interpreter already has it.
"""
code = (
"import sys, logging; sys.path.insert(0, 'src');"
" import core.client;"
" log = logging.getLogger('third.party');"
" log.setLevel(logging.INFO);"
" import io;"
" buf = io.StringIO();"
" h = logging.StreamHandler(buf);"
" log.addHandler(h);"
" log.info('GET https://x.example.com/?api-key=deadbeef0123');"
" print('LEAK' if 'deadbeef0123' in buf.getvalue() else 'CLEAN')"
)
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=False,
)
if "CLEAN" not in result.stdout:
print(f" {result.stdout.strip() or result.stderr.strip()[:200]}")
return False
return True
def check_file_logging_installs_redaction() -> bool:
"""The other way a root handler appears, and the one that writes to logs/.
A line printed to the terminal is gone when the scrollback is; a line in
`logs/` is not, and `logs/` is what gets attached to a bug report.
"""
source = (PROJECT_ROOT / "src" / "utils" / "logger.py").read_text()
body = source.split("def setup_file_logging")[1]
if "install_secret_redaction()" not in body.split("root_logger.addHandler")[0]:
print(" setup_file_logging attaches a handler before redacting")
return False
return True
def check_every_basic_config_installs_redaction() -> bool:
"""`logging.basicConfig` is what turns the leak on; it must turn the guard on too."""
offenders = []
for root in ("src", "tools"):
for path in sorted((PROJECT_ROOT / root).rglob("*.py")):
source = path.read_text()
if "logging.basicConfig" not in source:
continue
if "install_secret_redaction" not in source:
offenders.append(path.relative_to(PROJECT_ROOT))
for path in offenders:
print(f" {path} calls basicConfig without install_secret_redaction")
return not offenders
def check_no_script_prints_a_raw_endpoint() -> bool:
"""Print the hostname if you must, never the value."""
offenders = []
for root in SCAN_ROOTS:
for path in sorted((PROJECT_ROOT / root).rglob("*.py")):
for number, line in enumerate(path.read_text().splitlines(), start=1):
if RAW_ENV_IN_OUTPUT.search(line):
offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{number}")
for hit in offenders:
print(f" {hit} interpolates a credential straight into output")
return not offenders
def main() -> int:
checks = [
(
"redact masks every credential shape",
check_redact_masks_every_credential_shape,
),
(
"redact masks a registered value, keeping the host",
check_redact_masks_a_registered_value,
),
("an unlisted logger is redacted too", check_an_unknown_logger_is_redacted),
(
"importing the RPC client installs the redaction",
check_importing_src_installs_redaction,
),
("file logging installs the redaction", check_file_logging_installs_redaction),
(
"every basicConfig installs the redaction",
check_every_basic_config_installs_redaction,
),
("no script prints a raw endpoint", check_no_script_prints_a_raw_endpoint),
]
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())
+54 -18
View File
@@ -12,14 +12,19 @@ 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.
The installed solders cannot deserialize a v1 transaction (they begin with byte
129 and put the signatures at the tail). That is 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
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
@@ -33,10 +38,13 @@ Offline machine checks, no network and no funds moved:
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 logs removed, the v1 create is NOT detected — which is what makes
the log route load-bearing rather than decorative. If this check starts
failing, solders has learned to decode a v1 envelope and the routing could
be simplified.
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
@@ -194,20 +202,44 @@ def check_listener_detects_the_v1_create() -> bool:
return True
def check_log_route_is_what_detects_v1() -> bool:
"""Strip the logs and the v1 create must vanish.
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, rather than passing because some
other path happens to cope. solders cannot decode a v1 envelope, so with
no logs there is nothing left to parse.
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 not None:
print(" a v1 create parsed without its logs — routing may have changed")
if token_info is None:
print(" the installed solders cannot decode a v1 envelope")
return False
return True
@@ -271,7 +303,11 @@ def main() -> int:
"block listener still detects a pre-v1 create",
check_listener_still_detects_a_pre_v1_create,
),
("the log route is what detects v1", check_log_route_is_what_detects_v1),
(
"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
+9 -6
View File
@@ -5,22 +5,25 @@ import sys
from dotenv import load_dotenv
from solders.pubkey import Pubkey
from spl.token.instructions import BurnParams, CloseAccountParams, burn, close_account
from spl.token.instructions import burn, close_account
from spl.token.models import BurnParams, CloseAccountParams
from core.client import SolanaClient
from core.pubkeys import SystemAddresses
from core.wallet import Wallet
from utils.logger import get_logger
from utils.logger import get_logger, install_secret_redaction
load_dotenv()
# get_logger attaches no handler — the bot installs one at startup, but a
# standalone example has to do it itself or every line below goes nowhere. This
# script ran completely silently, success or failure, without it.
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
# httpx logs each request at INFO, and the RPC endpoint carries an API key in
# its path — keep it out of the console.
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# The HTTP clients log each request at INFO and the RPC endpoint carries an API
# key, so raising the root logger to INFO is what leaks it. Naming the clients
# here is not enough — solana-py 0.40 renamed httpx to httpx2 and this script
# went on printing the key for four runs. install_secret_redaction masks the
# value itself, whichever logger emits it.
install_secret_redaction()
logger = get_logger(__name__)
RPC_ENDPOINT = os.getenv("SOLANA_NODE_RPC_ENDPOINT")
+5 -5
View File
@@ -29,8 +29,8 @@ from solders.compute_budget import ( # noqa: E402
set_compute_unit_limit,
set_compute_unit_price,
)
from solders.message import Message # noqa: E402
from solders.transaction import Transaction # noqa: E402
from solders.message import MessageV0 # noqa: E402
from solders.transaction import VersionedTransaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
@@ -118,10 +118,10 @@ def install_simulation_hook(client: SolanaClient) -> dict:
preamble.append(set_compute_unit_price(priority_fee))
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[*preamble, *instructions], signer_keypair.pubkey(), blockhash
message = MessageV0.try_compile(
signer_keypair.pubkey(), [*preamble, *instructions], [], blockhash
)
transaction = Transaction([signer_keypair], message, blockhash)
transaction = VersionedTransaction(message, [signer_keypair])
response = await client.post_rpc(
{
+6 -5
View File
@@ -28,9 +28,9 @@ sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.compute_budget import set_compute_unit_limit # noqa: E402
from solders.message import Message # noqa: E402
from solders.message import MessageV0 # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from solders.transaction import Transaction # noqa: E402
from solders.transaction import VersionedTransaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.pubkeys import ( # noqa: E402
@@ -160,12 +160,13 @@ async def simulate(
True if the simulation reported no program error
"""
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[set_compute_unit_limit(SIMULATION_CU_LIMIT), *instructions],
message = MessageV0.try_compile(
wallet.pubkey,
[set_compute_unit_limit(SIMULATION_CU_LIMIT), *instructions],
[],
blockhash,
)
transaction = Transaction([wallet.keypair], message, blockhash)
transaction = VersionedTransaction(message, [wallet.keypair])
response = await client.post_rpc(
{
Generated
+671 -248
View File
File diff suppressed because it is too large Load Diff