feat(monitoring): add shreds listener for pre-execution token detection (#220)

* feat(monitoring): add shreds listener for pre-execution token detection

Adds a `shreds` listener backed by geyser's SubscribeDeshred, which delivers
transactions when entries form from shreds, before execution. pump.fun only;
bots/bot-sniper-5-shreds.yaml ships disabled.

With no TransactionStatusMeta there is no CreateEvent, so the listener decodes
the create instruction and resolves what the buy needs itself. It marks its
tokens state_from_event, because there is no curve to read before the create
executes: trading on the instruction or not trading at all are the only
options.

Two fixes to the shared instruction-decode path, which geyser and blocks also
reach when logs are absent:

- `user` is account 5 on create_v2, not 7. create_v2 drops the metaplex
  accounts legacy create carries, so index 7 is the token program and every
  address derived from the wallet was wrong.
- A holder-reward coin's creator is PDA(["holder-rewards", mint]). The program
  ignores args.creator on those coins and writes that PDA into
  BondingCurve.creator, so creator_vault must derive from it or the buy is
  rejected on a seeds constraint.

Subscribes on the mint authority rather than the program id: it appears in
create/create_v2 and in no trade instruction, which keeps the stream to
creations instead of every pump.fun transaction.

Verified live on mainnet with extreme_fast_mode on and off and both the
time_based and tp_sl exits, each ending with the wallet holding SOL only.

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

* docs: drop reported measurement statistics from prose

Removes sample counts, traffic percentages, latency percentiles, window
durations and "measured on <date>" claims from docstrings, comments, docs and
bot YAML. The fact each measurement supported is kept and stated plainly.

Numbers that are load-bearing rather than reporting stay: protocol and packet
limits, the CU figures used to tune compute budgets, and dates pinning on-chain
or dependency upgrades.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-09-23 17:39:33 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 1e2de43292
commit 2ba40c0771
27 changed files with 1358 additions and 188 deletions
+61 -41
View File
@@ -172,8 +172,8 @@ What the 13.0.0-rc4 protos changed, all verified live on 2026-09-23:
v0 and v1 alike. The field is set only for v1 and carries the inline budget
(priority fee in **total lamports**, CU limit, loaded-accounts data size,
heap) that v1 moved off the ComputeBudget instructions — so a v1 coin has no
ComputeBudget instructions to read a budget from at all. Measured share of
pump.fun traffic: 660 of 4,215 transactions (15.7%) in a 90s window.
ComputeBudget instructions to read a budget from at all. A meaningful share
of pump.fun traffic is v1.
- Protos older than this **skipped `config` as an unknown field rather than
failing**, so the stale stubs degraded in silence: frames still decoded, only
the budget went missing. `verify_transaction_v1.py` now pins the geyser route
@@ -187,32 +187,36 @@ What the 13.0.0-rc4 protos changed, all verified live on 2026-09-23:
debugging a v1 gap.
`SubscribeDeshred` is a separate RPC delivering transactions **before
execution**, when entries form from shreds. It is **deliberately not wired into
the bot**, for two reasons, the second of which is disqualifying.
execution**, when entries form from shreds. It backs the **`shreds` listener**
(`monitoring/universal_shreds_listener.py`, pump.fun only,
`bots/bot-sniper-5-shreds.yaml`), which is off by default and carries one
permanent cost.
Raced against `Subscribe` on the pump.fun filter, 300s on 2026-09-23: identical
signature sets, no orphans either way. On creates deshred is first 92% of the
time, median **+6.4ms**, p90 +21.5ms; on general traffic 79%, median +4.0ms.
`tools/compare_deshred_latency.py` reproduces it — the far tail swings a lot
between runs, the median does not.
Raced against `Subscribe` on the pump.fun filter, deshred arrives first on most
creates by a small margin; `tools/compare_deshred_latency.py` reproduces the
comparison.
- There is no `TransactionStatusMeta`, so no `meta.log_messages`, so no
CreateEvent. That leaves `state_from_event` unset on every coin and silently
demotes `extreme_fast_mode` to a curve refresh.
CreateEvent — the listener decodes the create instruction, which is the
fallback route everywhere else and the only route here. It sets
`state_from_event` itself so `extreme_fast_mode` submits with zero RPC:
waiting is not the safer option, because the curve account does not exist
yet, so a refresh can only time out and skip the coin.
- Outcomes are unknown at detection — nothing has executed, so a create that
goes on to revert is delivered exactly like one that lands.
- **A coin created through a router is invisible on it.** The create arrives as
a CPI, and inner instructions are produced *by* execution, so a pre-execution
stream never carries them. Measured 4 of 118 creates (3.4%) in one window and
3 of 101 in another — every one present on the stream and undetectable, not
dropped, so no decoding recovers them. Note this inverts the CPI note under
*Listener and decoder pitfalls*: **trades** are overwhelmingly inner
instructions, **creates** are overwhelmingly top-level (114:4), which is why
deshred looks nearly complete until you count.
stream never carries them — every one present on the stream and undetectable,
not dropped, so no decoding recovers them. Note this inverts the CPI note
under *Listener and decoder pitfalls*: **trades** are overwhelmingly inner
instructions, **creates** are overwhelmingly top-level, which is why deshred
looks nearly complete until you count.
Trading a permanent few-percent miss rate on new coins for a 6ms median is not
a trade this bot should take.
`cookbook/pumpfun/listen/pumpfun_listen_tokens_deshred.py` demonstrates the
stream and that trade-off; it decodes instructions because it has no logs to
route on.
That miss rate is the whole trade: a few percent of coins never seen, against a
head start on the rest. `geyser` remains the default for that reason; pick
`shreds` deliberately.
`cookbook/pumpfun/listen/pumpfun_listen_tokens_deshred.py` demonstrates the raw
stream, and `tests/regression/verify_shreds_listener.py` pins the listener.
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):
@@ -243,6 +247,7 @@ name individual scripts to run a subset.
| `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 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; same two routes pinned again over geyser, on a committed v1 `SubscribeUpdate`, plus the inline v1 budget staying readable |
| `verify_shreds_listener.py` | pre-execution creates decode from the instruction alone: `user` at `create_v2` account 5, a holder-reward coin's creator derived as `PDA(["holder-rewards", mint])`, truncated trailing args decoding as not-holder-reward, lookup-table accounts resolved, and nothing reading a `meta` the stream has no field for |
| `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 |
@@ -289,7 +294,7 @@ The reasoning behind each lives in the verifier named beside it.
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
file` on the committed v1 fixture) — 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` +
@@ -322,8 +327,8 @@ The reasoning behind each lives in the verifier named beside it.
a wrong power of ten inflates the price *and* the slippage cap in the same
direction, so they compound into an overspend instead of cancelling. pump.fun's
`QuoteControl` registry (PDA `["quote-control"]`) admits mints at 6, 8 and 9
decimals — 79 of the 170 admitted on 2026-09-22 are tokenized equities, and
coins paired with them trade live (8 of 124 curves in a 75s sample that day).
decimals — many of the admitted mints are tokenized equities, and coins
paired with them trade live.
- `trade.curve_refresh_budget` (seconds, default 2.0) bounds the pre-buy curve
read in `extreme_fast_mode`; when it expires the token is **skipped**, because a
buy built from listener-guessed defaults reverts with `NotAuthorized` (6000),
@@ -331,9 +336,15 @@ The reasoning behind each lives in the verifier named beside it.
sell path keeps the opposite fallback — proceed with cached values — since
skipping a sell strands the position.
- The refresh is skipped entirely when `TokenInfo.state_from_event` is set, i.e.
the listener parsed the **CreateEvent**. Instruction-parsed `TokenInfo`
deliberately does not set it: `args.creator` is user-supplied and post-2026-04-28
may differ from the canonical `BC.creator`. `trade.trust_create_event: false`
the listener resolved creator/flags/quote_mint for itself. The **CreateEvent**
parsers set it. Instruction-parsed `TokenInfo` does not, because
`args.creator` is user-supplied and post-2026-04-28 may differ from the
canonical `BC.creator` — but that gap is now understood rather than merely
feared, and it has exactly two causes: a **holder-reward** coin (derivable
from the mint, see below) and a post-launch `migrate_bonding_curve_creator`
(which happens *after* create, so it cannot affect a snipe). The `shreds`
listener sets the flag itself on that basis; it has no curve to read and no
choice. `trade.trust_create_event: false`
forces the refresh back on; PumpPortal payloads always refresh.
**Selling**
@@ -393,9 +404,8 @@ offline and only visible after a couple of minutes against mainnet.
was reported as `claim_cashback`. Note `buy_exact_sol_in` is also 18 accounts
on chain, same as legacy `buy`.
- **Walk `meta.innerInstructions`, not just `message.instructions`.** Most trades
reach the program as a CPI from a router or aggregator: in 40 consecutive
pump.fun transactions there was **1 top-level** pump instruction against
**8 inner** ones. Anchor's event-CPI prefix (`e445a52e51cb9a1d`) accounts for a
reach the program as a CPI from a router or aggregator: top-level pump
instructions are heavily outnumbered by inner ones. Anchor's event-CPI prefix (`e445a52e51cb9a1d`) accounts for a
good share of the inner instructions; the event's own discriminator follows it.
- **`getProgramAccounts` over the whole pump program is rejected** by current
providers: *"Too many accounts requested (10000001 pubkeys) … use
@@ -566,9 +576,11 @@ The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-publi
"omitted fee bps" getTransaction fixture sends `is_cashback_enabled` only
(1 byte), and the "with fee bps" getTransaction fixture sends
`is_cashback_enabled` + `creator_fee_bps` (9 bytes) — counted directly from
each fixture's instruction data, 2026-09-15. `is_holder_reward` (the
three-trailing-arg form) is legal per the IDL but has not been observed on
the wire — UNVERIFIED whether it is ever sent. A decoder that reads a
each fixture's instruction data, 2026-09-15. The three-trailing-arg form
**is** sent. Because the args are positional, a shorter form cannot have set
a later one — so a truncated instruction is proof the coin is not
holder-reward, not merely silence about it, which is what lets a
pre-execution listener classify every coin. A decoder that reads a
fixed number of trailing bytes raises `IndexError` on the shorter forms.
Decode trailing args defensively and report a missing one as unset —
`utils/idl_parser.py` does this for trailing option-typed args since #184
@@ -602,7 +614,16 @@ holders instead of paid to a creator wallet. On such a coin
`BondingCurve.creator` holds a pump.fun address rather than the actual
creator's — that's expected, and the `creator_vault` derivation is unchanged
and still correct either way, since it derives from whatever `creator` the
curve carries. **No trade instruction changed**: `buy`, `sell`, `buy_v2`,
curve carries.
**That substituted address is `PDA(["holder-rewards", mint])` under the pump
program** — the same account the IDL declares for `holder_rewards` on
`distribute_fee_to_holders`, which is how the ordinary `creator-vault` route
delivers the fee to the holder pool without a second payout path. It depends on
nothing but the mint, so `args.creator` being unusable on these coins costs no
RPC call to work around: `PumpFunAddresses.find_holder_reward_creator` derives
it. A meaningful share of launches are holder-reward, so this is not an edge
case. **No trade instruction changed**: `buy`, `sell`, `buy_v2`,
`sell_v2` and the PumpSwap instructions take identical accounts and arguments
whether or not a coin is a holder-reward coin. `Global.is_holder_reward_enabled`
can switch creation off globally.
@@ -643,15 +664,14 @@ interface pump.fun maintains.
checks this against committed fixtures, and `--live` re-checks it against the
real feed. The bonk **trade** path past detection is still unverified — see
issue #201.
- **PumpPortal does not report coins created in a transaction v1.** Measured
2026-09-16 across two windows: 0 of 6 v1 creates pushed, against near-complete
coverage of v0 creates (104 of 105 in one 240s window). It is a third-party
feed, so no local change recovers them — the coin is never sent. This compounds
- **PumpPortal does not report coins created in a transaction v1**, while its
coverage of v0 creates is near-complete. It is a third-party feed, so no local change recovers them — the coin is never sent. This compounds
the thin-bonk-payload gap below, and it means `pumpportal` is a sampling feed
now, not a complete one. `bots/bot-sniper-4-pp.yaml` carries the same warning.
- `src/config_loader.py` (repo root of the package, not under `core/`) validates the platform/listener pairing before startup:
pump.fun supports `logs`, `blocks`, `geyser`, `pumpportal`; letsbonk.fun
supports `blocks`, `geyser`, `pumpportal` — **not `logs`**. Adding a listener
pump.fun supports `logs`, `blocks`, `geyser`, `shreds`, `pumpportal`;
letsbonk.fun supports `blocks`, `geyser`, `pumpportal` — **not `logs`**, and
**not `shreds`** (its instruction parse path is unverified, see #201). Adding a listener
means updating `PLATFORM_LISTENER_COMPATIBILITY` there too.
- Bots with `separate_process: true` run in their own process. One log file per
bot instance.
+4 -3
View File
@@ -58,7 +58,7 @@ Fill in `.env`:
| `SOLANA_NODE_RPC_ENDPOINT` | HTTPS RPC endpoint |
| `SOLANA_NODE_WSS_ENDPOINT` | WebSocket endpoint (for `logs` / `blocks` listeners) |
| `SOLANA_PRIVATE_KEY` | Base58 private key of the trading wallet |
| `GEYSER_ENDPOINT`, `GEYSER_API_TOKEN`, `GEYSER_AUTH_TYPE` | Only for the `geyser` listener |
| `GEYSER_ENDPOINT`, `GEYSER_API_TOKEN`, `GEYSER_AUTH_TYPE` | For the `geyser` and `shreds` listeners (same endpoint, different RPC) |
Public RPC nodes will not work for this workload — see [throughput](#throughput-and-rate-limits) below.
@@ -72,10 +72,11 @@ Each YAML file in `bots/` is one bot instance. They ship with commented defaults
| `bot-sniper-2-logs.yaml` | `logs` — `logsSubscribe`, supported everywhere | `pump_fun` |
| `bot-sniper-3-blocks.yaml` | `blocks` — `blockSubscribe`, not supported by every provider | `pump_fun` |
| `bot-sniper-4-pp.yaml` | `pumpportal` — third-party aggregator, misses some coins | `lets_bonk` |
| `bot-sniper-5-shreds.yaml` | `shreds` — pre-execution, ahead of `geyser`, cannot see router-created coins | `pump_fun` |
Set `platform: "pump_fun"` or `platform: "lets_bonk"`. pump.fun supports all four listeners; letsbonk.fun supports `blocks`, `geyser`, and `pumpportal` but **not** `logs`. The bot validates the pairing at startup and refuses to run an invalid one.
Set `platform: "pump_fun"` or `platform: "lets_bonk"`. pump.fun supports all five listeners; letsbonk.fun supports `blocks`, `geyser`, and `pumpportal` but **not** `logs` or `shreds`. The bot validates the pairing at startup and refuses to run an invalid one.
`pumpportal` is a third-party feed and only reports what it indexes. As of 2026-09-16 it does not push coins whose creation landed in a Solana transaction v1 (a format live since 2026-09-15), so it sees a sample rather than everything. `geyser` and `logs` read the chain directly and are unaffected; `blocks` needs `maxSupportedTransactionVersion: 1`, which it now sends.
`pumpportal` is a third-party feed and only reports what it indexes. It does not push coins whose creation landed in a Solana transaction v1 (a format live since 2026-09-15), so it sees a sample rather than everything. `geyser` and `logs` read the chain directly and are unaffected; `blocks` needs `maxSupportedTransactionVersion: 1`, which it now sends.
Set `enabled: false` to keep a config around without running it. Every bot with `enabled: true` starts when you run the bot.
+3 -3
View File
@@ -91,9 +91,9 @@ filters:
match_string: null # Only process tokens with this string in name/symbol
bro_address: null # Only trade tokens created by this user address
# PumpPortal is a third-party feed, so it only reports what it indexes.
# Measured 2026-09-16: it reported 0 of 6 coins whose create landed in a
# Solana transaction v1 (live since 2026-09-15), against near-complete
# coverage of v0 creates. Nothing in this repo can recover those — the
# It does not report coins whose create landed in a Solana transaction v1
# (live since 2026-09-15), while its coverage of v0 creates is
# near-complete. Nothing in this repo can recover those — the
# coin is simply never pushed. Use "geyser" or "logs" if you need every
# coin; see tests/regression/verify_transaction_v1.py.
listener_type: "pumpportal" # Method for detecting new tokens: "logs", "blocks", "geyser", or "pumpportal"
+152
View File
@@ -0,0 +1,152 @@
# Shreds mode: reads transactions from the geyser SubscribeDeshred stream
# BEFORE they execute, which is earlier than the ordinary geyser listener.
#
# A few notes:
#
# 1. A coin created through a router is INVISIBLE. The create then arrives as
# a CPI, and inner instructions are produced by execution, so no
# pre-execution stream carries them. No setting recovers those coins -- if
# you need every coin, use listener_type "geyser".
# 2. Outcomes are unknown at detection. Nothing has executed, so a create that
# goes on to revert looks exactly like one that lands, and the buy against
# it costs a fee for nothing.
#
# extreme_fast_mode below is deliberately true: with it off the bot waits for
# the curve to stabilize, which spends the latency advantage and leaves only
# the costs above.
# This file defines comprehensive parameters and settings for the trading bot.
# Carefully review and adjust values to match your trading strategy and risk tolerance.
# Bot identification and connection settings
name: "bot-sniper-5"
env_file: ".env"
rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
private_key: "${SOLANA_PRIVATE_KEY}"
enabled: false # You can turn off the bot w/o removing its config
separate_process: true
# Options: "pump_fun" (default), "lets_bonk"
platform: "pump_fun"
# Geyser connection. Shreds mode uses the same endpoint and credentials as the
# geyser listener -- SubscribeDeshred is a separate RPC on it, not a separate
# service.
geyser:
endpoint: "${GEYSER_ENDPOINT}"
api_token: "${GEYSER_API_TOKEN}"
auth_type: "x-token" # or "basic"
# Trading parameters
# Control trade execution: amount of SOL per trade and acceptable price deviation
trade:
buy_amount: 0.0001 # Amount of SOL to spend when buying (in SOL)
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3
# Spend amounts for coins paired against a non-SOL quote asset.
# pump.fun now supports quote mints other than SOL (USDC first). Amounts are
# in that mint's own whole units, so "usdc: 1.0" means one USDC — it is NOT
# comparable to buy_amount above. Keys accept the aliases "sol"/"usdc" or a
# raw base58 mint address. A coin whose quote mint has no entry here is
# skipped rather than bought with a SOL-sized amount.
# SOL always uses buy_amount above and needs no entry.
#quote_amounts:
# usdc: 1.0
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
#take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
#stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%)
max_hold_time: 5 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
#price_check_interval: 2 # Check price every 2 seconds
# A tp/sl exit sell that reverts on-chain (slippage, curve moved under it) is
# retried on the next price check, re-reading the price first so the slippage
# floor matches the market. After this many attempts the bot gives up and
# leaves the position open - tokens are still held.
#max_exit_sell_attempts: 3
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
# For the shreds listener the buy is built entirely from the create
# instruction — ZERO RPC calls between detection and submission. The curve
# account does not exist yet at that point, so this is not merely faster:
# it is the only way shreds mode can trade at all (see trust_create_event).
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Pre-buy curve refresh (issue #170). Unused in shreds mode while
# trust_create_event is true, and kept here for when you switch this bot to
# another listener.
curve_refresh_budget: 2.0
# Skip the pre-buy curve read when the listener resolved the creator,
# mayhem/cashback flags and quote_mint itself. Setting this false does NOT
# make shreds mode safer — the bonding curve has not been created yet, so
# the refresh can only time out and skip the coin. Leave it true here and
# use listener_type "geyser" if you want the curve read.
trust_create_event: true
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
priority_fees:
enable_dynamic: false # Use latest transactions to estimate required fee (getRecentPrioritizationFees)
enable_fixed: true # Use fixed amount below
fixed_amount: 1_000_000 # Base fee in microlamports
extra_percentage: 0.0 # Percentage increase on riority fee regardless of the calculation method (0.1 = 10%)
hard_cap: 1_000_000 # Maximum allowable fee in microlamports to prevent excessive spending
# Compute unit limits for transaction processing
# Operation-specific defaults are used if not specified: buy=100K, sell=60K
compute_units:
# Override default CU limits for this platform
# buy: 100_000 # Buy operations (ATA creation + trading)
# sell: 60_000 # Sell operations (just trading)
# Account data size optimization (reduces CU cost and improves tx priority)
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
# account_data_size: 12_500_000 # Disabled: causes MaxLoadedAccountsDataSizeExceeded with Token-2022
# Filters for token selection
filters:
match_string: null # Only process tokens with this string in name/symbol
bro_address: null # Only trade tokens created by this user address
# Restrict which quote assets to trade. Omit (or set null) to allow any quote
# mint that has an amount configured under trade.quote_amounts.
# Accepts "sol"/"usdc" aliases or raw mint addresses.
#allowed_quote_mints: ["sol"]
listener_type: "shreds" # Pre-execution detection; see the header of this file.
# Supported on pump_fun only. Other options: "logs", "blocks", "geyser", "pumpportal".
max_token_age: 0.001 # Maximum token age in seconds for processing
marry_mode: false # Only buy tokens, skip selling
yolo_mode: false # Continuously trade tokens
# Retry and timeout settings
retries:
max_attempts: 1 # Number of attempts for transaction submission
wait_after_creation: 15 # Seconds to wait after token creation (only if EXTREME FAST is disabled)
wait_after_buy: 5 # Holding period after buy transaction
wait_before_new_token: 15 # Pause between token trades
# Token and account management
cleanup:
# Cleanup mode determines when to manage token accounts. Options:
# "disabled": no cleanup will occur.
# "on_fail": only clean up if a buy transaction fails.
# "after_sell": clean up after selling.
# "post_session": clean up all empty accounts after a trading session ends.
mode: "post_session"
force_close_with_burn: false # Force burning remaining tokens before closing account
with_priority_fee: false # Use priority fees for cleanup transactions
# Node provider configuration
node:
max_rps: 25 # Maximum requests per second
@@ -12,8 +12,8 @@ Two things this example exists to show, because both are easy to get wrong:
2. Read `meta.innerInstructions` as well as `message.instructions`. Most pump.fun
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.
that only walks the top level sees almost nothing — top-level pump
instructions are heavily outnumbered by inner ones.
3. Accept whichever encoding the response was captured in. `getTransaction`
answers in `jsonParsed`, `json` or `base64` depending on what was asked for,
@@ -0,0 +1,8 @@
{
"_comment": "One pump.fun create_v2 as delivered by geyser SubscribeDeshred (pre-execution). There is no TransactionStatusMeta on this stream, so there are no log messages and no CreateEvent: the create instruction is the only source.",
"mint": "9AyUC2BSCmQfuCjB1V77yNYyS8YHXsaq2uvVbXw9pump",
"is_holder_reward": true,
"create_v2_trailing_arg_bytes": 10,
"loaded_lookup_table_accounts": 13,
"subscribe_update_base64": "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"
}
@@ -0,0 +1,8 @@
{
"_comment": "One pump.fun create_v2 as delivered by geyser SubscribeDeshred (pre-execution). There is no TransactionStatusMeta on this stream, so there are no log messages and no CreateEvent: the create instruction is the only source.",
"mint": "CJPAzeEsYpjNV4Jqi22f1GR1duRzCxbDfmLSWpFQpump",
"is_holder_reward": false,
"create_v2_trailing_arg_bytes": 10,
"loaded_lookup_table_accounts": 17,
"subscribe_update_base64": "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"
}
@@ -61,30 +61,26 @@ client-side `MIN_CURVE_LEN` floor discards anything shorter than the
smallest real struct, so a stray short/legacy account still can't reach the
decoder.
**Bandwidth trade-off, measured 2026-09-15 directly over Geyser** (not
borrowed from the WebSocket script's measurement — Geyser reports slot and
signature per update and its named-group model batches differently, so it
gets its own number). Two *separate* concurrent gRPC streams over the same
120s window, so both see the identical trade activity without the
sequential-window volume-swing problem (a first attempt comparing sequential
windows was unreliable for exactly that reason — see
`pumpfun_watch_graduating_programsubscribe.py`'s module docstring): one stream subscribed with
this script's old two named groups (`datasize` 125 and 151), the other with
a single unfiltered group. The filtered stream took in 6,754 updates /
2,340,863 proto bytes; the unfiltered stream took in 6,761 updates /
2,337,304 proto bytes — a 1.001x update ratio and a 0.998x byte ratio,
i.e. no measurable cost. The 7-update difference was the 256-byte curve
(`EJpNsfxnTB6mtVdzrTcgQ9xfywobHSSsUtu1Gh1GFvEg`) that no enumerated length
could ever match; the filtered stream structurally cannot see it at all.
`getAccountInfo` on that same curve, run directly the same day, confirms it:
still 256 bytes, discriminator intact, and it decodes cleanly through this
repo's own IDL-driven decoder — the bytes past the documented fields are
zero padding.
**Bandwidth trade-off, checked directly over Geyser** (not borrowed from the
WebSocket script's comparison — Geyser reports slot and signature per update
and its named-group model batches differently, so it gets its own). Two
*separate* concurrent gRPC streams over the same window, so both see the
identical trade activity without the sequential-window volume-swing problem
(a first attempt comparing sequential windows was unreliable for exactly that
reason — see `pumpfun_watch_graduating_programsubscribe.py`'s module
docstring): one stream subscribed with this script's old two named groups
(`datasize` 125 and 151), the other with a single unfiltered group. They took
in effectively the same volume, i.e. filtering bought nothing. The difference
was the 256-byte curve (`EJpNsfxnTB6mtVdzrTcgQ9xfywobHSSsUtu1Gh1GFvEg`) that
no enumerated length could ever match; the filtered stream structurally
cannot see it at all. `getAccountInfo` on that same curve confirms it: still
256 bytes, discriminator intact, and it decodes cleanly through this repo's
own IDL-driven decoder — the bytes past the documented fields are zero
padding.
UNVERIFIED here: whether the original 49-byte layout (no `creator` field) is
still written anywhere. None of the 6,761 unfiltered updates in the
measurement above were that length, but that is one 120s window, not proof
of absence.
still written anywhere. Nothing in the unfiltered stream was that length, but
that is one window, not proof of absence.
"""
import argparse
@@ -69,32 +69,26 @@ elsewhere) returned 256 bytes, discriminator matching, owned by the pump
program, and it decoded cleanly through this repo's own IDL-driven decoder
(`PumpFunCurveManager._decode_curve_state_with_idl`) with sane reserves —
everything past the documented fields is zero padding. Second, over the
wire: a 120s `programSubscribe` window with this script's own filters
(discriminator + `complete = false`, no `dataSize`) took in 1,066 updates —
207 at 125 bytes, 853 at 151, and 6 at that same 256 length, among ordinary
traffic. None of the 1,066 were the legacy 49-byte layout (no `creator`
field). UNVERIFIED: whether that layout still has any `complete = false`
accounts left on chain, and whether anything still writes to them — not
re-measured here.
wire: a `programSubscribe` window with this script's own filters
(discriminator + `complete = false`, no `dataSize`) saw all three lengths —
125, 151 and that same 256 — among ordinary traffic, the 256 being rare.
Nothing in it was the legacy 49-byte layout (no `creator` field).
UNVERIFIED: whether that layout still has any `complete = false` accounts
left on chain, and whether anything still writes to them — not re-measured
here.
**Bandwidth trade-off, measured 2026-09-15.** A first attempt ran the
`dataSize`-filtered shape and the unfiltered shape back to back, 90s each,
and looked like unfiltered cost *less* (0.68x) — that was noise: pump.fun
trading volume swings a lot minute to minute, and two sequential windows just
land on different volume. Rerun with all three filter groups (`dataSize
125`, `dataSize 151`, and no `dataSize`) subscribed **simultaneously on one
connection**, so all three watch the identical trade stream over the same
120s: the two enumerated lengths together took in 1,060 updates / 589,279
bytes; the unfiltered subscription took in 1,066 updates / 593,509 bytes —
6 extra updates, 4,230 extra bytes, all of it the 256-byte curve neither
enumerated length can match. That is a **1.006x update ratio / 1.007x byte
ratio** — under 1% either way, not the double subscription's worth intuition
might suggest, because in this trade window virtually every update already
lands on one of the two dataSize-filtered lengths (125 or 151), and 256 is
rare. Dropping the filter is effectively free here; if a resize-happy period
ever shifts that mix, the cost scales with however much traffic sits outside
the two filtered lengths (125/151, i.e. the rarer 256-byte curves and
beyond), not with total volume.
**Bandwidth trade-off.** A first attempt ran the `dataSize`-filtered shape
and the unfiltered shape back to back and made unfiltered look *cheaper* —
that was noise: pump.fun trading volume swings a lot minute to minute, and
two sequential windows just land on different volume. Rerun with all three
filter groups (`dataSize 125`, `dataSize 151`, and no `dataSize`) subscribed
**simultaneously on one connection**, so all three watch the identical trade
stream: the unfiltered subscription cost barely more than the two enumerated
lengths together, and the difference was entirely the 256-byte curve neither
enumerated length can match. Dropping the filter is effectively free here,
because virtually every update already lands on 125 or 151 and 256 is rare;
if a resize-happy period ever shifts that mix, the cost scales with however
much traffic sits outside the two filtered lengths, not with total volume.
UNVERIFIED: a curve was once observed going from 125 to 151 bytes, with
several 125-byte trades logged in between, suggesting `extend_account` ran as
@@ -9,22 +9,19 @@ executed stream. The two are different RPCs on the same endpoint and the trade-o
between them is the point of this example.
`SubscribeDeshred` delivers a transaction when entries are formed from shreds,
BEFORE any execution. Measured head-to-head against `Subscribe` on the pump.fun
program, 300s on 2026-09-23: identical signature sets, no orphans either way.
On token creations -- the number that matters here -- deshred arrived first in
92% of cases, median +6.4ms, p75 +10.1ms, p90 +21.5ms. General traffic does
worse (79% first, median +4.0ms), and the far tail is volatile between runs.
`tools/compare_deshred_latency.py` reproduces all of this.
BEFORE any execution. Raced head-to-head against `Subscribe` on the pump.fun
program it carries the same signatures with no orphans either way, and on token
creations -- the case that matters here -- it usually arrives first, by a small
margin. `tools/compare_deshred_latency.py` reproduces that comparison.
What you give up for those milliseconds:
- **It cannot see a coin created through a router, at all.** The create then
reaches the program as a CPI, and inner instructions are produced *by*
execution, so a pre-execution stream never carries them: the transaction
arrives on deshred with the create invisible. Measured 4 of 118 creates
(3.4%) in one 300s window and 3 of 101 in another -- every one of them
present on the stream and undetectable, not dropped. No decoding recovers
them, and this script misses them too. The executed listeners walk
arrives on deshred with the create invisible -- present on the stream and
undetectable, not dropped. No decoding recovers them, and this script
misses them too. The executed listeners walk
`meta.innerInstructions` and do not have this blind spot.
- No TransactionStatusMeta, so no meta.log_messages, so no CreateEvent. This
script therefore decodes the create instruction, which is the fallback route
@@ -8,14 +8,11 @@ Usage:
The mirror of `pumpfun_buy_token_v2.py`. It reads how many tokens you hold, reads the curve
for a price, and sells the lot with a slippage floor underneath.
Two things to know before you run it:
One thing to know before you run it:
- **The floor is yours to set.** `min_sol_output` is computed here from the price
and the slippage, and the program enforces only that number. Passing a floor
derived from a stale price is how a sell goes through at a price you did not intend.
- **A dust position may not be sellable at all.** Below a few thousand tokens the
output rounds to zero and the program rejects the sell with error 6003
(TooLittleSolReceived), whatever slippage you allow.
"""
import argparse
+2 -2
View File
@@ -80,7 +80,7 @@ CONFIG_VALIDATION_RULES = [
# Valid values for enum-like fields
VALID_VALUES = {
"filters.listener_type": ["logs", "blocks", "geyser", "pumpportal"],
"filters.listener_type": ["logs", "blocks", "geyser", "shreds", "pumpportal"],
"cleanup.mode": ["disabled", "on_fail", "after_sell", "post_session"],
"trade.exit_strategy": ["time_based", "tp_sl", "manual"],
"platform": ["pump_fun", "lets_bonk"],
@@ -88,7 +88,7 @@ VALID_VALUES = {
# Platform-specific listener compatibility
PLATFORM_LISTENER_COMPATIBILITY = {
Platform.PUMP_FUN: ["logs", "blocks", "geyser", "pumpportal"],
Platform.PUMP_FUN: ["logs", "blocks", "geyser", "shreds", "pumpportal"],
Platform.LETS_BONK: ["blocks", "geyser", "pumpportal"],
}
+20 -7
View File
@@ -93,13 +93,9 @@ class TokenInfo:
# buy_v2/sell_v2 account lists are unchanged — so this is informational and
# available for filtering. creator_fee_bps is non-zero only on custom-pair
# coins; SOL- and USDC-paired coins use the standard fee schedule (bps 0).
# Verified live on 2026-09-15: 21 SOL-paired CreateEvents (bps 0 on all;
# the 90-second capture window saw 25 coins total, 4 of them custom-pair)
# and 10 USDC-paired bonding curves — located via Dune's indexed
# pumpdotfun_solana.pump_evt_createevent table, then read directly by
# deriving each curve PDA and calling PumpFunCurveManager.get_pool_state —
# also bps 0 on all. Every custom-pair coin observed in the same window
# showed a nonzero value (30, 125, 1, 300 bps).
# Checked live against SOL-paired CreateEvents and USDC-paired bonding
# curves, which carry bps 0; every custom-pair coin checked alongside them
# carried a nonzero value.
is_holder_reward: bool = False
creator_fee_bps: int = 0
@@ -453,6 +449,23 @@ class EventParser(ABC):
"""
pass
def get_creation_filter_accounts(self) -> list[Pubkey]:
"""Get accounts to subscribe on when only creations are wanted.
A subscription filtered on the program id sees every transaction the
program handles, which is overwhelmingly trades. Where a platform has
an account that only its creation instructions touch, naming it here
moves that filtering server-side. The pre-execution shreds listener
needs this: the unfiltered firehose makes a Python consumer lag, and a
lagging deshred stream is terminated by the server rather than slowed.
Defaults to the program id, which is correct but unselective.
Returns:
Accounts a creation transaction is guaranteed to mention
"""
return [self.get_program_id()]
@abstractmethod
def get_instruction_discriminators(self) -> list[bytes]:
"""Get instruction discriminators for token creation.
+25 -6
View File
@@ -25,10 +25,11 @@ class ListenerFactory:
"""Create a token listener based on the specified type.
Args:
listener_type: Type of listener ('logs', 'blocks', 'geyser', or 'pumpportal')
listener_type: Type of listener ('logs', 'blocks', 'geyser',
'shreds' or 'pumpportal')
wss_endpoint: WebSocket endpoint URL (for logs/blocks listeners)
geyser_endpoint: Geyser gRPC endpoint URL (for geyser listener)
geyser_api_token: Geyser API token (for geyser listener)
geyser_endpoint: Geyser gRPC endpoint URL (geyser/shreds listeners)
geyser_api_token: Geyser API token (geyser/shreds listeners)
geyser_auth_type: Geyser authentication type
pumpportal_url: PumpPortal WebSocket URL (for pumpportal listener)
platforms: List of platforms to monitor (if None, monitor all)
@@ -58,6 +59,23 @@ class ListenerFactory:
logger.info("Created Universal Geyser listener for token monitoring")
return listener
elif listener_type == "shreds":
if not geyser_endpoint or not geyser_api_token:
raise ValueError(
"Geyser endpoint and API token are required for shreds listener"
)
from monitoring.universal_shreds_listener import UniversalShredsListener
listener = UniversalShredsListener(
geyser_endpoint=geyser_endpoint,
geyser_api_token=geyser_api_token,
geyser_auth_type=geyser_auth_type,
platforms=platforms,
)
logger.info("Created Universal Shreds listener for token monitoring")
return listener
elif listener_type == "logs":
if not wss_endpoint:
raise ValueError("WebSocket endpoint is required for logs listener")
@@ -124,7 +142,8 @@ class ListenerFactory:
else:
raise ValueError(
f"Invalid listener type '{listener_type}'. "
f"Must be one of: 'logs', 'blocks', 'geyser', 'pumpportal'"
f"Must be one of: 'logs', 'blocks', 'geyser', 'shreds', "
f"'pumpportal'"
)
@staticmethod
@@ -134,7 +153,7 @@ class ListenerFactory:
Returns:
List of supported listener type strings
"""
return ["logs", "blocks", "geyser", "pumpportal"]
return ["logs", "blocks", "geyser", "shreds", "pumpportal"]
@staticmethod
def get_platform_compatible_listeners(platform: Platform) -> list[str]:
@@ -147,7 +166,7 @@ class ListenerFactory:
List of compatible listener types
"""
if platform == Platform.PUMP_FUN:
return ["logs", "blocks", "geyser", "pumpportal"]
return ["logs", "blocks", "geyser", "shreds", "pumpportal"]
elif platform == Platform.LETS_BONK:
return ["blocks", "geyser", "pumpportal"] # Added pumpportal support
else:
+2 -3
View File
@@ -183,9 +183,8 @@ class UniversalBlockListener(BaseTokenListener):
# live since epoch 1035, 2026-09-15. A version the
# subscription does not accept does not just skip
# that transaction: the RPC nulls out `value.block`
# for the whole frame. Measured against mainnet on
# 2026-09-16, 60s each: `0` delivered 1 block and
# 177 nulls, `1` delivered 78 blocks and no nulls.
# for the whole frame, leaving this listener
# almost entirely blind.
"maxSupportedTransactionVersion": 1,
},
],
+360
View File
@@ -0,0 +1,360 @@
"""Universal shreds listener: pre-execution token detection over SubscribeDeshred.
`SubscribeDeshred` delivers a transaction when entries are formed from shreds,
before any execution. That is earlier than `Subscribe`, and it costs two things
that shape everything in this module.
**There is no TransactionStatusMeta.** No `meta.log_messages`, so no CreateEvent,
so the log route every other listener prefers does not exist here. This listener
decodes the create instruction, which is the fallback route elsewhere and the
only route here. It also means no success or failure: nothing has executed, so a
create that goes on to revert is delivered exactly like one that lands.
**A coin created through a router is invisible.** The create then reaches the
program as a CPI, and inner instructions are produced *by* execution, so a
pre-execution stream never carries them -- the transaction arrives with the
create undetectable, not dropped. No decoding recovers them. This is the
standing cost of the mode and the reason it is not the default; see
bots/bot-sniper-5-shreds.yaml.
Note this inverts the usual CPI advice in AGENTS.md: *trades* are overwhelmingly
inner instructions, *creates* are overwhelmingly top-level, which is why walking
only top-level instructions here is right rather than a shortcut.
Address lookup tables are already resolved and reported on the update itself, so
instruction account indices resolve as they do on the executed stream.
"""
import asyncio
from collections.abc import Awaitable, Callable
import grpc
from solders.pubkey import Pubkey
from geyser.generated import geyser_pb2, geyser_pb2_grpc
from interfaces.core import EventParser, Platform, TokenInfo
from monitoring.base_listener import BaseTokenListener
from platforms import platform_factory
from utils.logger import get_logger
logger = get_logger(__name__)
# Seconds to wait before retrying a dropped stream, matching the geyser listener.
_STREAM_RETRY_SECONDS = 5
_CONNECTION_RETRY_SECONDS = 10
class UniversalShredsListener(BaseTokenListener):
"""Pre-execution token listener over the geyser deshred stream."""
def __init__(
self,
geyser_endpoint: str,
geyser_api_token: str,
geyser_auth_type: str,
platforms: list[Platform] | None = None,
):
"""Initialize the shreds listener.
Args:
geyser_endpoint: Geyser gRPC endpoint URL
geyser_api_token: Geyser API token
geyser_auth_type: Either "x-token" or "basic"
platforms: Platforms to monitor; all supported platforms if None
Raises:
ValueError: If geyser_auth_type is not a supported value
"""
super().__init__()
self.geyser_endpoint = geyser_endpoint
self.geyser_api_token = geyser_api_token
valid_auth_types = {"x-token", "basic"}
self.auth_type: str = (geyser_auth_type or "x-token").lower()
if self.auth_type not in valid_auth_types:
raise ValueError(
f"Unsupported auth_type={self.auth_type!r}. "
f"Expected one of {valid_auth_types}"
)
if platforms is None:
self.platforms = platform_factory.get_supported_platforms()
else:
self.platforms = platforms
self.platform_parsers: dict[Platform, EventParser] = {}
self.platform_program_ids: dict[bytes, EventParser] = {}
self.create_discriminators: dict[bytes, tuple[bytes, ...]] = {}
# Flattened across platforms for the str.startswith prefix test.
self.all_discriminators: tuple[bytes, ...] = ()
for platform in self.platforms:
try:
from core.client import SolanaClient
class DummyClient(SolanaClient):
"""Client stub that skips the blockhash updater.
Parsers only decode here; nothing in this listener reaches
the network through the client.
"""
def __init__(self):
self.rpc_endpoint = "http://dummy"
self._client = None
self._cached_blockhash = None
self._blockhash_lock = None
self._blockhash_updater_task = None
implementations = platform_factory.create_for_platform(
platform, DummyClient()
)
parser = implementations.event_parser
self.platform_parsers[platform] = parser
self.platform_program_ids[bytes(parser.get_program_id())] = parser
# Cheap prefix gate, see _process_update: the deshred stream
# carries every pump transaction pre-execution, so most
# instructions reaching us are trades, not creates.
self.create_discriminators[bytes(parser.get_program_id())] = tuple(
parser.get_instruction_discriminators()
)
self.all_discriminators += tuple(
parser.get_instruction_discriminators()
)
logger.info(
f"Registered platform {platform.value} with program ID "
f"{parser.get_program_id()}"
)
except Exception as e: # noqa: BLE001
logger.warning(f"Could not register platform {platform.value}: {e}")
async def _create_geyser_connection(self):
"""Establish a secure connection to the Geyser endpoint.
Returns:
A (stub, channel) pair; the caller owns closing the channel
"""
if self.auth_type == "x-token":
auth = grpc.metadata_call_credentials(
lambda _, callback: callback(
(("x-token", self.geyser_api_token),), None
)
)
else:
auth = grpc.metadata_call_credentials(
lambda _, callback: callback(
(("authorization", f"Basic {self.geyser_api_token}"),), None
)
)
creds = grpc.composite_channel_credentials(grpc.ssl_channel_credentials(), auth)
channel = grpc.aio.secure_channel(self.geyser_endpoint, creds)
return geyser_pb2_grpc.GeyserStub(channel), channel
def _create_subscription_request(self) -> geyser_pb2.SubscribeDeshredRequest:
"""Build the deshred subscription request for all monitored platforms.
This is its own request type, not a SubscribeRequest: the deshred stream
carries no commitment level, because commitment describes execution and
nothing has executed. There is no `failed` filter for the same reason.
Returns:
The request to open the stream with
"""
request = geyser_pb2.SubscribeDeshredRequest()
for program_id, parser in self.platform_program_ids.items():
filter_name = f"platform_filter_{Pubkey.from_bytes(program_id)}"
deshred_filter = request.deshred_transactions[filter_name]
# Not the program id: that matches every trade too, and the volume
# makes a Python consumer lag until the server ends the stream.
for account in parser.get_creation_filter_accounts():
deshred_filter.account_include.append(str(account))
deshred_filter.vote = False
return request
async def listen_for_tokens(
self,
token_callback: Callable[[TokenInfo], Awaitable[None]],
match_string: str | None = None,
creator_address: str | None = None,
) -> None:
"""Listen for new token creations on the pre-execution deshred stream.
Args:
token_callback: Called with each detected token
match_string: Optional substring to require in the name or symbol
creator_address: Optional creator address to filter by
"""
if not self.platform_parsers:
logger.error("No platform parsers available. Cannot listen for tokens.")
return
while True:
try:
stub, channel = await self._create_geyser_connection()
request = self._create_subscription_request()
logger.info(f"Connected to deshred stream: {self.geyser_endpoint}")
logger.info(
f"Monitoring platforms: {[p.value for p in self.platforms]}"
)
logger.warning(
"Shreds mode reads transactions before execution: coins "
"created through a router arrive as CPIs and cannot be "
"detected, and a create that later reverts is "
"indistinguishable from one that lands."
)
try:
async for update in stub.SubscribeDeshred(iter([request])):
token_info = self._process_update(update)
if not token_info:
continue
logger.info(
f"New token detected pre-execution: {token_info.name} "
f"({token_info.symbol}) on {token_info.platform.value}"
)
if match_string and not (
match_string.lower() in token_info.name.lower()
or match_string.lower() in token_info.symbol.lower()
):
logger.info(
f"Token does not match filter '{match_string}'. "
"Skipping..."
)
continue
if creator_address and str(token_info.user) != creator_address:
logger.info(
f"Token not created by {creator_address}. Skipping..."
)
continue
await token_callback(token_info)
except Exception as e:
if isinstance(e, grpc.aio.AioRpcError):
logger.exception(f"gRPC error: {e.details()}")
else:
logger.exception("Deshred stream error occurred")
await asyncio.sleep(_STREAM_RETRY_SECONDS)
finally:
await channel.close()
except Exception:
logger.exception("Deshred connection error")
logger.info(f"Reconnecting in {_CONNECTION_RETRY_SECONDS} seconds...")
await asyncio.sleep(_CONNECTION_RETRY_SECONDS)
@staticmethod
def _resolve_account_keys(transaction) -> list[bytes]:
"""Build the full account-key table for one deshred transaction.
A v0 transaction indexes accounts past the end of `message.account_keys`
when it uses an address lookup table. The deshred stream resolves those
and reports them on the update itself rather than under a meta, in this
order: static keys, writable loaded, read-only loaded. Ignoring them
raises IndexError on the first coin that uses a lookup table.
Args:
transaction: A geyser SubscribeUpdateDeshredTransactionInfo
Returns:
Account keys as raw 32-byte values, indexable by an instruction
"""
keys = list(transaction.transaction.message.account_keys)
keys.extend(transaction.loaded_writable_addresses)
keys.extend(transaction.loaded_readonly_addresses)
return keys
def _process_update(self, update) -> TokenInfo | None:
"""Extract token creation info from one deshred update.
Only top-level instructions are walked, because inner instructions do
not exist before execution. Each instruction is routed to its own
platform's parser by program id rather than offered to every parser,
so an unrelated program's instruction is never decoded speculatively.
Args:
update: A geyser SubscribeUpdate from the deshred stream
Returns:
TokenInfo for a detected creation, None otherwise
"""
try:
if not update.HasField("deshred_transaction"):
return None
transaction = update.deshred_transaction.transaction
message = getattr(transaction.transaction, "message", None)
if message is None:
return None
static_keys = transaction.transaction.message.account_keys
keys = None
for instruction in message.instructions:
# Cheapest test first. The account filter already narrows the
# stream to creations, but a creation transaction still carries
# ComputeBudget, system and token instructions, and this avoids
# both the key-table copy and a full IDL decode for each.
if not bytes(instruction.data).startswith(self.all_discriminators):
continue
program_index = instruction.program_id_index
if program_index >= len(static_keys):
continue
parser = self.platform_program_ids.get(
bytes(static_keys[program_index])
)
if parser is None:
continue
# Resolve lookup-table accounts only once a real candidate is
# in hand; the instruction's own accounts may index into them.
if keys is None:
keys = self._resolve_account_keys(transaction)
token_info = parser.parse_token_creation_from_instruction(
instruction.data, instruction.accounts, keys
)
if token_info:
return self._mark_trusted(token_info)
return None
except Exception:
logger.exception("Error processing deshred update")
return None
@staticmethod
def _mark_trusted(token_info: TokenInfo) -> TokenInfo:
"""Mark an instruction-parsed TokenInfo as safe to trade without a refresh.
Every other listener leaves `state_from_event` False on instruction-parsed
tokens, because `args.creator` is user-supplied and the program may write
something else into `BondingCurve.creator`. That reservation has exactly
one cause, and the parser now resolves it: a holder-reward coin takes its
creator from a PDA of the mint, which is derivable without reading
anything.
Waiting for the curve here would not be a safer fallback but a broken one:
the account does not exist yet, so the only alternatives are trading on
this data or not trading at all.
Args:
token_info: Token parsed from a create instruction
Returns:
The same TokenInfo, marked so extreme_fast_mode skips the curve read
"""
token_info.state_from_event = True
return token_info
+49
View File
@@ -30,6 +30,12 @@ class PumpFunAddresses:
GLOBAL: Final[Pubkey] = Pubkey.from_string(
"4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf"
)
# PDA ["mint-authority"], constant. Account 1 of create/create_v2 and
# absent from every trade instruction, which makes it the selective filter
# for a creations-only subscription.
MINT_AUTHORITY: Final[Pubkey] = Pubkey.from_string(
"TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM"
)
EVENT_AUTHORITY: Final[Pubkey] = Pubkey.from_string(
"Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1"
)
@@ -121,6 +127,35 @@ class PumpFunAddresses:
)
return derived_address
@staticmethod
def find_holder_reward_creator(mint: Pubkey) -> Pubkey:
"""Derive the creator a holder-reward coin's bonding curve carries.
On a holder-reward coin the creator fee is shared out to holders rather
than paid to the launcher, and pump.fun implements that by reusing the
ordinary fee route: the program ignores the `creator` passed to
`create_v2` and writes this PDA into `BondingCurve.creator`, so
`creator-vault` derives onto the holder-reward distribution account on
its own. Seeds are the ones the IDL declares for the `holder_rewards`
account on `distribute_fee_to_holders`.
This is what lets a listener holding only the create instruction — no
CreateEvent, and no curve to read because nothing has executed — still
build a correct buy: the substituted creator depends on nothing but the
mint.
Args:
mint: Coin mint address
Returns:
The address BondingCurve.creator holds for a holder-reward coin
"""
derived_address, _ = Pubkey.find_program_address(
[b"holder-rewards", bytes(mint)],
PumpFunAddresses.PROGRAM,
)
return derived_address
@staticmethod
def find_global_volume_accumulator() -> Pubkey:
"""
@@ -333,6 +368,20 @@ class PumpFunAddressProvider(AddressProvider):
)
return creator_vault
def derive_holder_reward_creator(self, mint: Pubkey) -> Pubkey:
"""Derive the creator a holder-reward coin's bonding curve carries.
See PumpFunAddresses.find_holder_reward_creator for why this differs
from the creator passed to create_v2.
Args:
mint: Coin mint address
Returns:
The address BondingCurve.creator holds for a holder-reward coin
"""
return PumpFunAddresses.find_holder_reward_creator(mint)
def derive_global_volume_accumulator(self) -> Pubkey:
"""Derive the global volume accumulator PDA.
+80 -38
View File
@@ -31,10 +31,37 @@ logger = get_logger(__name__)
# pump-fun public docs repository.
_CREATE_V2_QUOTE_MINT_ACCOUNT_INDEX = 16
# Index of the `user` account, which differs per instruction version: create_v2
# dropped the four metaplex accounts legacy `create` carries between the global
# account and the user, so the same wallet sits four places earlier. Reading
# create_v2's account 7 yields the token program instead.
_CREATE_USER_ACCOUNT_INDEX = 7
_CREATE_V2_USER_ACCOUNT_INDEX = 5
# Length of a Solana public key in bytes.
PUBKEY_BYTE_LENGTH = 32
def _option_bool(value: object) -> bool:
"""Read an Anchor OptionBool argument.
OptionBool is a single-field struct with no presence tag, so the IDL parser
decodes it as {"field_0": bool}; a trailing argument the caller omitted
arrives as None. Omission is not ambiguity: create_v2's trailing arguments
are positional, so a truncated instruction cannot have set a later one, and
reporting it unset is correct rather than a guess.
Args:
value: Decoded argument, a dict, a bool, or None when absent
Returns:
The flag's value, False when the argument was not sent
"""
if isinstance(value, dict):
return bool(value.get("field_0", False))
return bool(value) if value is not None else False
def _coerce_pubkey(value: object) -> Pubkey | None:
"""Coerce a decoded IDL pubkey field into a Pubkey.
@@ -398,21 +425,38 @@ class PumpFunEventParser(EventParser):
args = decoded.get("args", {})
# Extract account information based on IDL account order
# Extract account information based on IDL account order. `user`
# sits at a different index per instruction version: create_v2
# dropped the four metaplex accounts legacy `create` carries, so
# its account 7 is the token program, not the launcher's wallet.
mint = get_account_key(0)
bonding_curve = get_account_key(2)
associated_bonding_curve = get_account_key(3)
user = get_account_key(7)
user = get_account_key(
_CREATE_V2_USER_ACCOUNT_INDEX
if is_create_v2
else _CREATE_USER_ACCOUNT_INDEX
)
if not all([mint, bonding_curve, associated_bonding_curve, user]):
return None
# Create creator vault
creator = (
Pubkey.from_string(args.get("creator", str(user)))
if args.get("creator")
else user
)
# Holder-reward flag first: it decides where the creator comes from.
# Safe to trust from instruction args even though `creator` is not —
# the program rejects the request when the feature is globally
# disabled, so a landed create carrying `true` is proof. The args
# are positional, so a truncated trailing form cannot have set this
# and reads as False, which is correct by construction.
is_holder_reward = _option_bool(args.get("is_holder_reward"))
# On a holder-reward coin the program ignores args.creator and
# writes its own per-coin PDA into BondingCurve.creator, so
# creator_vault must derive from that instead. args.creator is
# otherwise copied to the curve verbatim at create time.
if is_holder_reward:
creator = PumpFunAddresses.find_holder_reward_creator(mint)
else:
creator = _coerce_pubkey(args.get("creator")) or user
creator_vault = self._derive_creator_vault(creator)
# Determine token program based on instruction type
@@ -422,35 +466,11 @@ class PumpFunEventParser(EventParser):
else SystemAddresses.TOKEN_PROGRAM
)
# Extract cashback flag from OptionBool struct (decoded as
# {"field_0": bool}). Cashback creation was deprecated 2026-09-15
# (create_v2 rejects a new true here with 6082 CashbackDeprecated),
# but older create_v2 instructions still carry it and existing
# cashback coins still trade, so it's still decoded here.
is_cashback_raw = args.get("is_cashback_enabled")
is_cashback = (
is_cashback_raw.get("field_0", False)
if isinstance(is_cashback_raw, dict)
else bool(is_cashback_raw)
if is_cashback_raw is not None
else False
)
# Extract the holder-reward flag from its OptionBool struct, same
# wrapper shape as is_cashback_enabled above. Unlike `creator`,
# this one is safe to trust from instruction args: a succeeded
# create_v2 carrying `true` here means the coin genuinely is a
# holder-reward coin -- the program itself rejects the request
# when the feature is globally disabled, so a landed transaction
# is proof, not merely an unverified claim.
is_holder_reward_raw = args.get("is_holder_reward")
is_holder_reward = (
is_holder_reward_raw.get("field_0", False)
if isinstance(is_holder_reward_raw, dict)
else bool(is_holder_reward_raw)
if is_holder_reward_raw is not None
else False
)
# Cashback creation was deprecated 2026-09-15 (create_v2 rejects a
# new true here with 6082 CashbackDeprecated), but older create_v2
# instructions still carry it and existing cashback coins still
# trade, so it's still decoded here.
is_cashback = _option_bool(args.get("is_cashback_enabled"))
# `creator_fee_bps` is deliberately left at TokenInfo's default
# (0) rather than decoded from args here. Upstream documents it
@@ -559,13 +579,35 @@ class PumpFunEventParser(EventParser):
"""
return PumpFunAddresses.PROGRAM
def get_creation_filter_accounts(self) -> list[Pubkey]:
"""Subscribe on the mint authority rather than the whole program.
`mint_authority` (PDA ["mint-authority"], a constant) is account 1 of
both `create` and `create_v2`, and appears in no trade instruction --
buy_v2/sell_v2 never reference it. Filtering on it therefore yields
creations only, server-side, instead of every pump.fun transaction.
Returns:
The pump.fun mint authority
"""
return [PumpFunAddresses.MINT_AUTHORITY]
def get_instruction_discriminators(self) -> list[bytes]:
"""Get instruction discriminators for token creation.
Both creation instructions, newest first: `create_v2` is what pump.fun
issues now, and legacy `create` still appears. A caller uses these to
reject an instruction before paying for a full IDL decode, so leaving
`create_v2` out would make the gate reject every current coin.
Returns:
List of discriminator bytes to match
"""
return [self._create_instruction_discriminator_bytes]
discriminators = []
if self._create_v2_instruction_discriminator_bytes:
discriminators.append(self._create_v2_instruction_discriminator_bytes)
discriminators.append(self._create_instruction_discriminator_bytes)
return discriminators
def get_event_discriminators(self) -> list[bytes]:
"""Get event discriminators for token creation.
+2 -3
View File
@@ -70,9 +70,8 @@ async def _read_pool_state_with_retry(
can momentarily serve a slot that predates it — both surface as "account
not found". Reading at `processed` and retrying costs a handful of RPC
calls, which is far cheaper than trading on stale account data. Issue #170
measured individual reads on a load-balanced endpoint lagging several
seconds behind a fast listener, hence a time budget rather than a fixed
attempt count.
found individual reads on a load-balanced endpoint lagging well behind a
fast listener, hence a time budget rather than a fixed attempt count.
When `mint` is given and the curve manager supports it, the curve and the
mint are read in one slot-consistent batch so the mint's owning token
@@ -16,8 +16,8 @@ connection whose frame state was now corrupt, so every later read tripped
identical ERROR lines, and `_wait_for_token` never returning, so the bot hung
after detecting a token instead of buying it.
Observed live: a blocks-listener run produced 86 consecutive AssertionErrors
starting on the line after "Found token", and never exited.
Observed live: a blocks-listener run produced a continuous stream of
AssertionErrors starting on the line after "Found token", and never exited.
Offline machine checks, no network and no funds moved. A stub websocket raises
what the real library raises, against the real listeners:
@@ -8,11 +8,11 @@ built, which showed up only as a repeating
Missing required fields in PumpPortal LetsBonk token data
Captured from the live feed on 2026-09-16: over 90 seconds it carried 24 pump
creates and 7 bonk creates, and the two payloads are not the same shape. A bonk
Captured from the live feed, which carries both pump creates and bonk creates,
and the two payloads are not the same shape. A bonk
create carries no `name`, no `symbol` and no `uri`, where a pump create has all
three. Requiring name and symbol therefore rejected every bonk token that ever
arrived - 100% of them, which matches the field report on the original run.
arrived, which matches the field report on the original run.
Only `mint` and `traderPublicKey` cannot be derived from something else, so only
those two are required now. `name` and `symbol` are used for logging and for the
@@ -12,8 +12,8 @@ The pair is read *positionally* from the end, and `pool-v2` only belongs to a
`Pubkey::default()` otherwise (upstream PUMP_SWAP_CREATOR_FEE_README.md). Sending
`pool-v2` on a non-canonical pool shifts the pair by one, so the program reads the
pool-v2 PDA as the buyback recipient and rejects it with
`BuybackFeeRecipientNotAuthorized` (6053). That was every non-canonical pool: 5 of
5 sampled failed before the fix, 10 of 10 simulate cleanly after it (2026-09-23).
`BuybackFeeRecipientNotAuthorized` (6053). That was every non-canonical pool
before the fix; they simulate cleanly after it.
Unblocking those pools then exposed a second bug they had been hiding. The scripts
hardcoded `TOKEN_DECIMALS = 6`, "standard for most pump.fun tokens" — true for
@@ -1,10 +1,9 @@
"""Verify no trade path prices a coin before resolving its quote mint.
pump.fun's quote assets are not just SOL and USDC. The `QuoteControl` registry
(PDA `["quote-control"]`) admits mints at 6, 8 and 9 decimals — 79 of the 170
admitted on 2026-09-22 are tokenized equities, 8 decimals for Backed's xStocks
and 6 for Backpack Securities — and coins paired with them trade live: a 75s
sample of curve updates that day caught 8 stock-paired curves out of 124.
(PDA `["quote-control"]`) admits mints at 6, 8 and 9 decimals — many of them
tokenized equities, 8 decimals for Backed's xStocks and 6 for Backpack
Securities — and coins paired with them trade live.
`quote_units()` used to default to 9 decimals for an unresolved mint. Every
cookbook trade script called it *before* `resolve_quote_token_program()` warmed
+521
View File
@@ -0,0 +1,521 @@
"""Offline verifier for the shreds (pre-execution) listener.
Guards the listener added for shreds mode and the instruction-decode path it
depends on. Nothing here touches the network or moves funds; both fixtures are
committed geyser `SubscribeDeshred` frames captured from mainnet.
The bugs and claims it pins:
1. **`user` is account 5 on `create_v2`, not 7.** `create_v2` dropped the four
metaplex accounts legacy `create` carries, so the launcher's wallet sits four
places earlier. Reading index 7 yields the Token-2022 program, and every
address derived from the wallet is then wrong. This was dormant while only
geyser/blocks used the instruction path as a fallback behind `log_messages`;
shreds has no logs, so it runs on every coin.
2. **A holder-reward coin's creator comes from the mint, not from the args.**
The program ignores `args.creator` on those coins and writes
`PDA(["holder-rewards", mint])` into `BondingCurve.creator`, so
`creator_vault` must derive from the PDA. Passing the wrong vault gets the
buy rejected on a seeds constraint. Without this the shreds listener could
not trade holder-reward coins at all, because there is no curve to read
before execution.
3. **The trailing-arg form cannot lie about holder rewards.** `create_v2`'s
trailing arguments are positional, so an instruction that stops short cannot
have set a later one. A truncated form must therefore decode as *not* a
holder-reward coin rather than raising or guessing.
4. **Lookup-table accounts are resolved before indexing.** The deshred stream
reports them on the update as `loaded_writable_addresses` then
`loaded_readonly_addresses`, in that order. Both fixtures genuinely use a
lookup table, so a listener that ignores them fails here rather than on the
first live coin.
5. **Nothing on this path reads `meta`.** There is no `TransactionStatusMeta` on
a pre-execution stream, so a reader that reaches for `meta.log_messages` gets
silence, not an error — the failure would be a listener that quietly detects
nothing.
6. **The listener marks its tokens `state_from_event`.** That is what lets
`extreme_fast_mode` submit without a curve read. Waiting instead is not a
safer fallback here: the account does not exist yet.
Usage:
uv run tests/regression/verify_shreds_listener.py
"""
# The listener's public entry point opens a gRPC stream, so these checks drive
# the decode helpers directly. Reaching past the public API is the point here.
# ruff: noqa: SLF001
import ast
import base64
import json
import struct
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT))
from solders.pubkey import Pubkey # noqa: E402
from core.pubkeys import SystemAddresses # noqa: E402
from geyser.generated import geyser_pb2 # noqa: E402
from interfaces.core import Platform # noqa: E402
from monitoring.universal_shreds_listener import UniversalShredsListener # noqa: E402
from platforms.pumpfun.address_provider import PumpFunAddresses # noqa: E402
FIXTURE_DIR = PROJECT_ROOT / "cookbook" / "pumpfun" / "decode"
HOLDER_REWARD_FIXTURE = FIXTURE_DIR / "raw_create_v2_holder_reward_from_deshred.json"
ORDINARY_FIXTURE = FIXTURE_DIR / "raw_create_v2_ordinary_from_deshred.json"
LISTENER_SOURCE = PROJECT_ROOT / "src" / "monitoring" / "universal_shreds_listener.py"
# create_v2 account indices, from idl/pump_fun_idl.json.
CREATE_V2_MINT_INDEX = 0
CREATE_V2_BONDING_CURVE_INDEX = 2
CREATE_V2_USER_INDEX = 5
# The index the code used to read, kept so the check states what it rules out.
LEGACY_CREATE_USER_INDEX = 7
CREATE_V2_DISCRIMINATOR = bytes([214, 144, 76, 236, 95, 139, 49, 180])
def _load(path: Path) -> dict:
"""Read one committed fixture.
Args:
path: Fixture file path
Returns:
The parsed fixture document
"""
with path.open() as handle:
return json.load(handle)
def _update(fixture: dict) -> geyser_pb2.SubscribeUpdateDeshred:
"""Rebuild the geyser update from a fixture.
Args:
fixture: Parsed fixture document
Returns:
The decoded SubscribeUpdateDeshred protobuf
"""
update = geyser_pb2.SubscribeUpdateDeshred()
update.ParseFromString(base64.b64decode(fixture["subscribe_update_base64"]))
return update
def _listener() -> UniversalShredsListener:
"""Build the listener without connecting to anything.
Returns:
A pump.fun-only shreds listener
"""
return UniversalShredsListener(
geyser_endpoint="unused.invalid:443",
geyser_api_token="unused", # noqa: S106 — never sent; nothing connects
geyser_auth_type="x-token",
platforms=[Platform.PUMP_FUN],
)
def _account_keys(update: geyser_pb2.SubscribeUpdateDeshred) -> list[bytes]:
"""Resolve the full account table for a deshred update.
Args:
update: A deshred SubscribeUpdateDeshred
Returns:
Static keys followed by writable then read-only loaded addresses
"""
transaction = update.deshred_transaction.transaction
keys = list(transaction.transaction.message.account_keys)
keys.extend(transaction.loaded_writable_addresses)
keys.extend(transaction.loaded_readonly_addresses)
return keys
def _create_instruction(update: geyser_pb2.SubscribeUpdateDeshred) -> object | None:
"""Find the create_v2 instruction in a deshred update.
Args:
update: A deshred SubscribeUpdateDeshred
Returns:
The instruction, or None if the fixture carries no create_v2
"""
message = update.deshred_transaction.transaction.transaction.message
for instruction in message.instructions:
if bytes(instruction.data).startswith(CREATE_V2_DISCRIMINATOR):
return instruction
return None
def check_fixtures_are_pre_execution() -> bool:
"""Both fixtures must be real deshred frames with no execution metadata.
A fixture carrying logs would make every other check here meaningless: the
parser would take the CreateEvent route that shreds mode does not have.
Returns:
True if both fixtures are pre-execution frames
"""
ok = True
for path in (HOLDER_REWARD_FIXTURE, ORDINARY_FIXTURE):
update = _update(_load(path))
if not update.HasField("deshred_transaction"):
print(f" {path.name} is not a deshred update")
ok = False
continue
transaction = update.deshred_transaction.transaction
# Not "is meta unset" but "there is no meta field to set": the deshred
# message has no TransactionStatusMeta, which is what makes the
# CreateEvent route structurally unavailable rather than merely absent
# from these two captures.
fields = {field.name for field in transaction.DESCRIPTOR.fields}
if "meta" in fields:
print(
f" {path.name}: the deshred message gained a meta field; "
"the CreateEvent route may now be available and this listener's "
"instruction-only design should be revisited"
)
ok = False
if _create_instruction(update) is None:
print(f" {path.name} carries no top-level create_v2")
ok = False
return ok
def check_fixtures_use_lookup_tables() -> bool:
"""The fixtures must actually need lookup-table resolution.
Otherwise check_lookup_table_accounts_resolved proves nothing: a listener
that ignored loaded addresses would pass it by accident.
Returns:
True if both fixtures load accounts from a lookup table
"""
ok = True
for path in (HOLDER_REWARD_FIXTURE, ORDINARY_FIXTURE):
update = _update(_load(path))
transaction = update.deshred_transaction.transaction
loaded = len(transaction.loaded_writable_addresses) + len(
transaction.loaded_readonly_addresses
)
if loaded == 0:
print(f" {path.name} loads no lookup-table accounts")
ok = False
return ok
def check_lookup_table_accounts_resolved() -> bool:
"""Account indices must resolve past the static keys, in the reported order.
Args:
None
Returns:
True if the resolved table is static + writable + readonly
"""
update = _update(_load(ORDINARY_FIXTURE))
transaction = update.deshred_transaction.transaction
static = list(transaction.transaction.message.account_keys)
resolved = UniversalShredsListener._resolve_account_keys(transaction)
expected = (
static
+ list(transaction.loaded_writable_addresses)
+ list(transaction.loaded_readonly_addresses)
)
if [bytes(k) for k in resolved] != [bytes(k) for k in expected]:
print(" resolved account table does not match static+writable+readonly")
return False
instruction = _create_instruction(update)
highest = max(instruction.accounts)
if highest < len(static):
print(" create_v2 indexes no loaded account; the check is vacuous")
return False
return True
def check_user_account_index() -> bool:
"""`user` must come from account 5, not the legacy index 7.
Returns:
True if the parsed user is the wallet at index 5
"""
ok = True
for path in (HOLDER_REWARD_FIXTURE, ORDINARY_FIXTURE):
update = _update(_load(path))
keys = _account_keys(update)
instruction = _create_instruction(update)
accounts = list(instruction.accounts)
expected_user = Pubkey.from_bytes(keys[accounts[CREATE_V2_USER_INDEX]])
wrong_user = Pubkey.from_bytes(keys[accounts[LEGACY_CREATE_USER_INDEX]])
token_info = _listener()._process_update(update)
if token_info is None:
print(f" {path.name} produced no TokenInfo")
ok = False
continue
if token_info.user != expected_user:
print(
f" {path.name}: user is {token_info.user}, "
f"expected account 5 ({expected_user})"
)
ok = False
# State what the old index actually pointed at, so a regression is
# recognisable rather than just unequal.
if wrong_user != SystemAddresses.TOKEN_2022_PROGRAM:
print(
f" {path.name}: account 7 is {wrong_user}, expected the "
"Token-2022 program — the fixture or the IDL layout moved"
)
ok = False
if token_info.user == wrong_user:
print(f" {path.name}: user was read from the legacy index 7")
ok = False
return ok
def check_holder_reward_creator_derived() -> bool:
"""A holder-reward coin's creator must be the mint's holder-rewards PDA.
Returns:
True if creator and creator_vault derive from the PDA, not from args
"""
fixture = _load(HOLDER_REWARD_FIXTURE)
if not fixture.get("is_holder_reward"):
print(" the holder-reward fixture is not flagged holder-reward")
return False
update = _update(fixture)
token_info = _listener()._process_update(update)
if token_info is None:
print(" holder-reward fixture produced no TokenInfo")
return False
expected_creator = PumpFunAddresses.find_holder_reward_creator(token_info.mint)
expected_vault, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(expected_creator)], PumpFunAddresses.PROGRAM
)
# The substitution is only meaningful if it differs from what the
# instruction claims; otherwise the fixture proves nothing.
args_creator = _args_creator(_create_instruction(update))
ok = True
if not token_info.is_holder_reward:
print(" holder-reward flag not decoded from the instruction")
ok = False
if token_info.creator != expected_creator:
print(
f" creator is {token_info.creator}, expected the holder-rewards "
f"PDA {expected_creator}"
)
ok = False
if token_info.creator_vault != expected_vault:
print(
f" creator_vault is {token_info.creator_vault}, expected "
f"{expected_vault}"
)
ok = False
if token_info.creator == args_creator:
print(
" creator equals args.creator; the fixture does not exercise "
"the substitution"
)
ok = False
return ok
def _args_creator(instruction: object) -> Pubkey:
"""Read the `creator` argument out of a create_v2 instruction.
Args:
instruction: A create_v2 instruction
Returns:
The creator the launcher passed
"""
data = bytes(instruction.data)
offset = 8
for _ in range(3): # name, symbol, uri
length = struct.unpack_from("<I", data, offset)[0]
offset += 4 + length
return Pubkey.from_bytes(data[offset : offset + 32])
def check_ordinary_creator_from_args() -> bool:
"""An ordinary coin's creator is the one the instruction carries.
Returns:
True if creator matches args.creator for a non-holder-reward coin
"""
fixture = _load(ORDINARY_FIXTURE)
if fixture.get("is_holder_reward"):
print(" the ordinary fixture is flagged holder-reward")
return False
update = _update(fixture)
token_info = _listener()._process_update(update)
if token_info is None:
print(" ordinary fixture produced no TokenInfo")
return False
if token_info.is_holder_reward:
print(" ordinary coin decoded as holder-reward")
return False
expected = _args_creator(_create_instruction(update))
if token_info.creator != expected:
print(f" creator is {token_info.creator}, expected {expected}")
return False
return True
def check_truncated_trailing_args_are_not_holder_reward() -> bool:
"""A create_v2 that stops short cannot be a holder-reward coin.
The trailing arguments are positional, so reaching argument 8 means sending
6 and 7 first. Truncating the real instruction must therefore decode as a
plain coin — never raise, and never leave the flag set.
Returns:
True if every truncated form decodes as a non-holder-reward coin
"""
update = _update(_load(HOLDER_REWARD_FIXTURE))
instruction = _create_instruction(update)
data = bytearray(instruction.data)
# Trailing args are the last 10 bytes: is_cashback_enabled (1),
# creator_fee_bps (8), is_holder_reward (1).
listener = _listener()
for dropped in (1, 9, 10):
truncated = bytes(data[: len(data) - dropped])
instruction.data = truncated
try:
token_info = listener._process_update(update)
except Exception as error: # noqa: BLE001
print(f" dropping {dropped} trailing bytes raised {error!r}")
return False
if token_info is None:
print(f" dropping {dropped} trailing bytes produced no TokenInfo")
return False
if token_info.is_holder_reward:
print(
f" dropping {dropped} trailing bytes still decoded as holder-reward"
)
return False
# With the flag unset the creator must fall back to the args, not the PDA.
if token_info.creator != _args_creator(instruction):
print(
f" dropping {dropped} trailing bytes did not fall back to "
"args.creator"
)
return False
return True
def check_tokens_marked_trusted() -> bool:
"""The listener must mark its tokens so extreme_fast_mode skips the curve read.
Returns:
True if both fixtures produce state_from_event tokens with a quote mint
"""
ok = True
for path in (HOLDER_REWARD_FIXTURE, ORDINARY_FIXTURE):
token_info = _listener()._process_update(_update(_load(path)))
if token_info is None:
print(f" {path.name} produced no TokenInfo")
ok = False
continue
if not token_info.state_from_event:
print(f" {path.name}: state_from_event not set")
ok = False
# _can_skip_refresh also requires a resolved quote mint.
if token_info.quote_mint is None:
print(f" {path.name}: quote_mint unresolved, the buy would refresh")
ok = False
return ok
def check_listener_never_reads_meta() -> bool:
"""The listener must not reach for execution metadata that cannot exist.
Returns:
True if the listener source references no meta field
"""
tree = ast.parse(LISTENER_SOURCE.read_text())
# Walk attribute access rather than scanning text, so a mention inside a
# docstring or comment -- this module is full of them -- is not a finding.
banned = {"meta", "log_messages", "logMessages"}
offenders = [
f"line {node.lineno}: .{node.attr}"
for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and node.attr in banned
]
if offenders:
print(" listener reads execution metadata that deshred never carries:")
for line in offenders:
print(f" {line}")
return False
return True
CHECKS = (
("fixtures are pre-execution deshred frames", check_fixtures_are_pre_execution),
("fixtures genuinely use lookup tables", check_fixtures_use_lookup_tables),
("lookup-table accounts resolved in order", check_lookup_table_accounts_resolved),
("user read from create_v2 account 5", check_user_account_index),
("holder-reward creator derived from mint", check_holder_reward_creator_derived),
("ordinary creator taken from args", check_ordinary_creator_from_args),
(
"truncated trailing args are not holder-reward",
check_truncated_trailing_args_are_not_holder_reward,
),
("tokens marked state_from_event", check_tokens_marked_trusted),
("listener never reads meta", check_listener_never_reads_meta),
)
def main() -> int:
"""Run every check.
Returns:
0 if all checks pass, 1 otherwise
"""
print("Verifying the shreds (pre-execution) listener\n")
failures = 0
for name, check in CHECKS:
try:
passed = check()
except Exception as error: # noqa: BLE001
print(f" [FAIL] {name}")
print(f" raised {error!r}")
failures += 1
continue
print(f" [{'PASS' if passed else 'FAIL'}] {name}")
failures += not passed
print()
if failures:
print(f"{failures} check(s) failed")
return 1
print("All checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -4
View File
@@ -7,10 +7,9 @@ v1 traffic. Two things break a reader that ignores it.
`maxSupportedTransactionVersion` is a whole-frame setting, not a per-transaction
one. Asking `blockSubscribe` for 0 does not skip the v1 transactions in a block,
it nulls out `value.block` for the entire notification. Measured against mainnet
on 2026-09-16, 60s per run: `0` delivered 1 block and 177 nulls, `1` delivered
78 blocks and no nulls — the blocks listener was roughly 99% blind, silently,
because a null frame looks exactly like a skipped slot.
it nulls out `value.block` for the entire notification. Asking for `0` leaves
the blocks listener almost entirely blind, silently, because a null frame looks
exactly like a skipped slot.
solders could not deserialize a v1 transaction (they begin with byte 129 and
put the signatures at the tail) until 0.29. That was survivable, because the RPC
-3
View File
@@ -16,9 +16,6 @@ stream on the same endpoint and reports two things:
amount of decoding recovers them. This classifies every create as
top-level or inner to size that blind spot.
Measured 2026-09-23 over 300s: creates led by a 6.4ms median (92% of the time),
while 4 of 118 creates were CPI-shaped and therefore undetectable on deshred.
Usage:
uv run tools/compare_deshred_latency.py
uv run tools/compare_deshred_latency.py --duration 600
+1 -1
View File
@@ -45,7 +45,7 @@ EXTREME_FAST_TOKEN_AMOUNT = 20
HOLD_SECONDS = 5
PRIORITY_FEE = 1_000_000
# This harness verifies plumbing, not profitability. Brand-new coins routinely
# lose 90% of their curve SOL within seconds of creation, and a realistic
# lose most of their curve SOL within seconds of creation, and a realistic
# sell_slippage of 0.3 then makes the program reject the sell with
# TooLittleSolReceived (6003) — a correct guard, but it stops us proving the
# 26-account sell_v2 actually lands. Accept almost any payout instead; the