mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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:
co-authored by
Claude Opus 5
parent
41b971147a
commit
ebbfa9f124
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user