Files
pumpfun-bonkfun-bot_github/learning-examples/bonding-curve-progress/get_graduating_tokens.py
T
55ae2afd82 feat(pumpfun): resync with the 2026-09-15 program upgrade (#202)
* feat(idl): refresh to upstream 8109141 and decode omitted OptionU64 args

Vendors pump.json, pump_amm.json and pump_fees.json from
pump-fun/pump-public-docs@8109141 (holder rewards coins, cashback
deprecation). create_v2 gains two trailing optionals: creator_fee_bps
(OptionU64) and is_holder_reward (OptionBool).

_is_optional_type only recognised OptionBool, so a create_v2 that omits
creator_fee_bps failed the whole decode and the coin dropped out of the
instruction path — the same failure #184 fixed for is_cashback_enabled.
Reproduced against two live mainnet create_v2 transactions captured
2026-09-15: one omits creator_fee_bps (157-byte instruction data), one
sends it (156 bytes) — both are added as getTransaction fixtures under
learning-examples/.

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

* fix(learning-examples): accept 125-byte bonding curves in dataSize filters

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

* docs(learning-examples): replace unrun/recycled curve-size claims with dated live measurements

The 45s/205-update/24-curve figures and the logsSubscribe/same-tx
extend_account claim in the two graduating-token docstrings were
carried over from before the 125/151 filter change and never
re-verified under it. Replaced with fresh 2026-09-15 measurements: a
length-unfiltered programSubscribe run, a direct 125->151 transition
observed on one curve, and a live run of the Geyser script's own
two-group build_subscribe_request confirming both filter groups
actually deliver. Unreproduced claims are now marked UNVERIFIED
in-line instead of stated flatly.

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

* fix(learning-examples): stop enumerating bonding-curve dataSize lengths

extend_account can grow a curve to any length the program allows, so the
CURVE_ACCOUNT_LENS = (125, 151) allowlist in both graduating-token scripts
was whack-a-mole -- a live 256-byte curve was already missed. Drop the
dataSize/datasize server-side filter entirely (discriminator + complete
memcmp already restrict delivery to BondingCurve accounts) and replace the
allowlist with a MIN_CURVE_LEN client-side floor. Collapse the Geyser
script's two named filter groups back to one now that there's no length to
fan out over. Extend verify_curve_account_sizes.py to assert neither script
constructs a dataSize filter and that 125/151/256-byte curves all decode.

Measured live 2026-09-15, filtered vs unfiltered subscribed simultaneously
so both see identical trade activity: ~0.6-0.7% extra traffic on WebSocket,
statistically zero on Geyser -- dropping the filter is effectively free.

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

* fix(learning-examples): remove untraceable bandwidth figure, session wording

Review of the dataSize-enumeration fix found a docstring figure (90s window,
2/224/866 updates at 256/125/151 bytes) that no command in the fix report
actually produced -- it was a byproduct of the discarded sequential-window
run, never written down with its command+output. Drop it and rely only on
the properly-documented 120s simultaneous measurement already backing the
rest of the paragraph.

Also replace two "carried from an earlier session" UNVERIFIED markers with
the repo's own convention (UNVERIFIED: <what>, no reference to how or when
the file was edited), and give both the update and byte ratio (1.006x /
1.007x) instead of a single derived percentage.

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

* feat(pumpfun): surface is_holder_reward and creator_fee_bps on TokenInfo

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

* fix(pumpfun): verify the USDC half of the creator_fee_bps claim

The comment on TokenInfo.creator_fee_bps stated flatly that SOL- and
USDC-paired coins always carry bps 0, but only the SOL half had been
observed live. Found 10 USDC-paired bonding curves (via Dune's indexed
CreateEvent table) and read each one directly through
PumpFunCurveManager.get_pool_state: all 10 show creator_fee_bps=0,
matching every custom-pair coin observed showing nonzero. Dated the
verification in the comment instead of leaving it an unmarked claim.

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

* feat(examples): create holder-reward coins; mark cashback legacy-only

create_v2 now rejects is_cashback_enabled=[true] with 6082
CashbackDeprecated as of the 2026-09-15 upgrade. Add ENABLE_HOLDER_REWARD
to mint_and_buy_v2.py so the trailing create_v2 args can reach
is_holder_reward instead, and relabel the still-live cashback code paths
in core.py, instruction_builder.py and curve_manager.py with the
deprecation date. No cashback account handling is removed anywhere -
existing cashback coins keep trading and accruing exactly as before.

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

* feat(pumpfun): resolve quote mints to their real token program

A 2026-09-15 pump.fun upgrade lets create_v2 pair a coin with a Token-2022
quote mint (error 6064 now accepts "SPL Token or Token-2022"), and such
coins already trade on mainnet. quote_token_program() only knew WSOL/USDC
and silently defaulted everything else to SPL Token, deriving associated
token accounts that don't exist on chain for any other quote mint.

Add an async resolver that reads a mint's owner from chain and caches it
for the process, pre-seeded with WSOL/USDC so they stay free. The bot
warms this cache once at startup for every configured quote mint; the
hot path (event parsing, address resolution) only ever reads the cache,
preserving extreme_fast_mode's zero-RPC contract between detection and
submission. quote_token_program() itself is untouched so its
existing call sites keep their non-optional Pubkey return.

Also fixes the standalone pump_v2.py learning-examples helper, which had
the same bug in a form that ignored its argument entirely, and gives
simulate_bot_buy_path.py a quote_amounts entry so it can exercise a
non-SOL-paired coin instead of unconditionally skipping it.

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

* feat(core): name Anchor error codes when reporting a reverted transaction

confirm_transaction only answers whether a transaction landed; meta.err
carries the revert. Logging it raw as {'InstructionError': [idx,
{'Custom': n}]} told an operator nothing, and pump.fun's 2026-09-15
upgrade added 22 new error codes to the pump program and 10 to
pump-amm, so an unnamed number is now more likely to be wrong than
useful. IDLParser.describe_error_code() looks a Custom(N) code up in
an IDL's error table; SolanaClient logs the match alongside the raw
meta.err at both revert sites, so a buy reverting with
BuybackFeeRecipientMissing (issue #175) now prints "6062
BuybackFeeRecipientMissing" instead of a bare 6062.

SolanaClient is shared across pump.fun and letsbonk.fun and has no
record of which program a given transaction invoked, so this only
checks pump.fun's table -- the program the bot's own buy_v2/sell_v2
call directly. A revert on a different program is described against
the wrong table if the code number collides, or left unnamed
otherwise; see the docstring on _describe_program_error.

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

* fix(core): correct get_account_info's return type annotation

It was typed dict[str, Any] but solana-py's AsyncClient.get_account_info
actually returns a solders Account object. Every real caller already
reads attributes (.data, .owner) rather than subscripting, so this was
latent rather than a live bug -- but the wrong annotation is what let
those callers work by accident, and would mislead anyone who trusted
the type and reached for result["owner"] instead.

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

* fix(core): tag the revert description with the table it came from

The name appended to a reverted transaction's log line looked like a
fact about whichever program actually reverted, but it is only ever
looked up in pump.fun's error table. This bot also trades letsbonk.fun,
so a letsbonk revert whose numeric code happens to also exist in
pump's table would get a confident, wrong name -- worse than the bare
number it replaced, since a wrong name stops the reader from checking.
Prefixing the description with "pump.fun IDL:" carries the same
limitation the docstring already stated out to the log line itself, so
it's visible without reading the source.

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

* docs: resync CLAUDE.md and README.md with the 2026-09-15 program upgrade

The bonding curve is 125 bytes now (not 151, no more padding), create_v2
takes eight args instead of six, cashback creation is deprecated (6082
CashbackDeprecated) while existing cashback coins keep trading, quote
assets are no longer just SOL/USDC (QuoteControl, not Global, is the
authoritative mint registry), and holder-reward coins were undocumented
entirely. Add verify_curve_account_sizes.py to both docs' verification
lists, retire the superseded CU comments in instruction_builder.py (the
180k/120k limits are untouched), and fix two stale cashback comments plus
a docstring in client.py that showed a msg suffix code 6062 doesn't have.

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

* fix(trading): refresh quote_token_program_id alongside quote_mint

_refresh_quote_mint synced token_info.quote_mint from freshly-read curve
state but left quote_token_program_id at its construction-time value. A
stale non-None program id wins the `or` in
PumpFunAddressProvider.resolve_quote, so a curve refresh that reveals a
Token-2022 quote mint still resolves the associated_quote_* ATAs under SPL
Token -- an address that does not exist on chain.

cached_quote_token_program is a synchronous dict read, so this keeps
extreme_fast_mode's zero-RPC-call contract between detection and
submission.

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

* fix(examples): resolve the quote mint's real token program in manual buy/sell

manual_buy.py and manual_sell.py derived the quote ATA's owning program
through pump_v2.quote_token_program, which defaults any mint outside the
WSOL/USDC map to SPL Token. A pump.fun upgrade verified 2026-09-15 lets
create_v2 pair a coin with a Token-2022 quote mint (error 6064 now accepts
"SPL Token or Token-2022"), so that default derives an ATA that does not
exist on a Token-2022-quoted coin -- and these scripts submit real
transactions, so the failure burns one.

Both now call pump_v2.resolve_quote_token_program (free for WSOL/USDC via
its cache, one RPC call otherwise) and thread the resolved program into
both the buy_v2/sell_v2 instruction and the quote ATA creation.

Verified against a real Token-2022-quoted pump.fun coin found via Dune
(pumpdotfun_solana.pump_evt_createevent, mint G5Ve125Poi7KoDRP5uLsgu5NQdm3KoqfyUkq4cqtTo4r,
quote_mint Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh, confirmed Token-2022-owned
by RPC read): the old default derives associated_quote_bonding_curve
FViEZvbcYjoSwGE2b37HiCXmPT3N7kSd1nQavFzcD7Yb, which does not exist on chain;
the fixed resolution derives DfxcBKXTLDqobNEmR5JkaBeoKqNbJE1vKbtQVbowXz4x,
which exists on chain and is owned by the Token-2022 program.

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

* fix(core): correct get_multiple_accounts's return type annotation

Declared as list[Any] while it actually returns a list of solders Account
objects (or None for missing accounts) -- the same stale-annotation
pattern already corrected on get_account_info.

Checked the one caller, PumpFunCurveManager.get_pool_state_and_token_program,
which already unpacks the two entries and reads .data / .owner off them
directly rather than subscripting -- no live bug behind the wrong
annotation here.

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

* refactor(examples): use the public decode API in the optional-args verifier

check_omitted_option_u64_decodes and check_option_u64_present_decodes
reached into IDLParser._decode_instruction_args behind # noqa: SLF001
instead of the public decode_instruction(data, keys, accounts) already
used by every other check in this file. The fixtures already carry valid
accounts/keys (previously discarded as data, _, _), so the public method
expresses the same check -- confirmed it returns byte-for-byte identical
decoded args as the private call did.

Confirmed the check stays load-bearing: reverting _is_optional_type to a
narrow == "OptionBool" comparison drops the verifier to 2/7 checks passed,
exit 1, including the two checks touched here.

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

* docs: correct unverified claims from the final branch review

Text-only fixes from the whole-branch review — no code defects found.

- CLAUDE.md: create_v2 trailing-arg claim was circular (sourced from our own
  minter script) and wrong — the three committed fixtures show 0, 1 and 2
  trailing args, never 3. Rewritten with the counted byte lengths and dated;
  the unobserved 3-arg form is now marked UNVERIFIED instead of asserted.
- src/interfaces/core.py, CLAUDE.md: corrected an inflated "25 SOL-paired"
  sample to the actual 21 (25 coins captured live, 4 of them custom-pair).
- instruction_builder.py: buy_v2/sell_v2 CU comments cited figures from a
  task brief that were never re-measured on this branch. Re-measured via
  simulate_v2_trades.py on 2026-09-15 (106,677 / ~77,196 CU) and updated the
  comments; the configured 180k/120k limits are unchanged.
- simulate_bot_buy_path.py: removed a working-session reference ("this
  task") from a comment.
- get_graduating_tokens.py, get_graduating_tokens_geyser.py's sibling docs
  (pump_v2.py, fetch_price.py, get_bonding_curve_status.py,
  verify_pumpportal_buy_path.py): made the curve-length story consistent
  (125/151/256, don't enumerate lengths) instead of a stale "125 or 151"
  framing; resolved an UNVERIFIED marker that mint_and_buy_v2.py already
  answers (extend_account can share a transaction with create_v2); removed
  a redundant restated clause in the bandwidth-measurement paragraph.

Verified: all 7 verify_*/simulate offline+mainnet checks still exit 0,
buy_v2/sell_v2 account counts unchanged at 27/26.

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

* fix(trading): resolve quote-mint decimals instead of defaulting to 9

quote_decimals()/quote_units_per_token() defaulted every quote mint outside
the WSOL/USDC map to SOL's 9 decimals, mis-scaling max_sol_cost/min_sol_output
by up to 1000x for a custom Token-2022 quote mint (verified: 6 decimals).
Extend resolve_quote_token_program's existing mint-account fetch to also
parse and cache decimals from the same read (byte offset 44, shared by SPL
Token and Token-2022), so the fix costs zero extra RPC calls and the
zero-RPC extreme_fast_mode contract holds. Mirrored the same treatment in
learning-examples/pump_v2.py's standalone copy.

Also decode is_holder_reward in the instruction-only create_v2 parse path
(a landed transaction proves it, since the program rejects the flag when
globally disabled) while leaving creator_fee_bps at its default, since
upstream docs say it's ignored for SOL/USDC-paired coins and nothing reads
it today.

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

* fix(examples): strengthen optional-arg verifier assertions

Fix verify_create_v2_optional_args.py to properly assert value equality
rather than just presence when checking optional arguments:

- check_omitted_option_u64_decodes: verify both creator_fee_bps and
  is_holder_reward are exactly None (not just present in dict)
- check_option_u64_present_decodes: decode the fixture, verify
  creator_fee_bps shape is {"field_0": <value>}, and assert the actual
  value (0) rather than just non-null
- Fixture decodes to creator_fee_bps={'field_0': 0}, is_holder_reward=None

Also fix README.md line 166: "Three examples" → "Four examples" to match
the four verification commands listed.

Temporary revert of idl_parser.py's _is_optional_type confirms the
strengthened assertions are load-bearing: changing from startswith("Option")
back to == "OptionBool" causes 4 of 7 checks to fail.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkvFUgSiZoaF7w4xugrgBc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:36:44 +02:00

557 lines
23 KiB
Python

"""Watch for pump.fun coins approaching graduation, over plain WebSocket RPC.
Usage:
uv run learning-examples/bonding-curve-progress/get_graduating_tokens.py
uv run learning-examples/bonding-curve-progress/get_graduating_tokens.py --min-progress 95
Why a subscription and not `getProgramAccounts`: the pump.fun program now owns
over 10 million accounts, and every provider refuses to scan it. Helius, Alchemy
and dRPC reject with `Too many accounts requested (10000001 pubkeys)`; QuickNode
and Chainstack time out. No filter set fixes that — the rejection is on program
size, before filters apply. `getProgramAccountsV2` is a provider extension (Helius,
Solana Tracker), not core Agave, and its `limit` is a *scan* budget rather than a
result count, so answering this question with it means ~1000 sequential pages.
`programSubscribe` sidesteps the scan entirely. A curve can only approach
graduation by being traded, and every write to it pushes the full account — 125
bytes as `create_v2` allocates it, or 151 once `extend_account` has run on it —
so each notification carries everything needed to compute progress — there is no
state to accumulate and no cold start beyond the next trade. Verified accepted on
both a paid endpoint and the public `api.mainnet-beta.solana.com`.
See `get_graduating_tokens_geyser.py` for the same report over Geyser gRPC, which
also gives you the transaction signature behind each update.
Selecting a graduation threshold
--------------------------------
Progress is measured against `Global.initial_real_token_reserves` (~793.1M tokens)
read from chain, not a hardcoded constant, because a mayhem coin can launch with
different virtual params and would otherwise show the wrong percentage.
The pre-filter the server applies can only match exact bytes, so it cannot do
"anything above 90%". It can only do a few fixed cutoffs. `--min-progress` uses the
closest cutoff that is still wide enough, then makes the exact comparison here:
filter cutoff ≈ graduated past
2 zero bytes @ 30 281.5M tokens left 64.5%
3 zero bytes @ 29 1.1M tokens left 99.86%
4 zero bytes @ 28 4,295 tokens left 99.9995%
--min-progress pre-filtered by the server?
below 64.5% no, every curve arrives and is filtered here
64.5% to 99.86% yes, at the 64.5% cutoff
99.86% and up yes, at the 99.86% cutoff
So the pre-filter saves traffic, it does not decide the answer — whatever percentage
you ask for is honoured either way. Low thresholds just cost more bandwidth. If you
want to hand-tune, pick a different cutoff from the table: the last moments before
migration want the 3-byte one, a wider funnel the 2-byte one.
Checked against mainnet by running the filtered and unfiltered subscriptions side by
side for a minute: same curves, nothing dropped, nothing extra.
This script does **not** filter on `dataSize`. `create_v2` allocates the curve
at exactly 125 bytes, an account grows to 151 once `extend_account` has run on
it, and a rarer third length (256 bytes, confirmed live below) also exists.
`extend_account` can grow a curve to any length — nothing enumerates every
size it might produce — so a fixed `dataSize` allowlist is whack-a-mole: the
next length silently drops curves again, and the failure mode is invisible,
since the script just prints fewer results rather than an error. The
discriminator `memcmp` alone already restricts delivery to `BondingCurve`
accounts, so dropping `dataSize` costs no precision — only bandwidth (see the
measurement below). A `MIN_CURVE_LEN` floor still discards anything shorter
than the smallest real struct, so a stray short/legacy account can't reach the
decoder.
Confirmed live on 2026-09-15 two ways. First, directly: `getAccountInfo` on
`EJpNsfxnTB6mtVdzrTcgQ9xfywobHSSsUtu1Gh1GFvEg` (a 256-byte curve reported
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.
**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.
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
its own later transaction rather than bundled into `create_v2` — not
re-measured here. `extend_account` **can** land in the same transaction as
`create_v2` — `learning-examples/mint_and_buy_v2.py` does exactly that,
appending `create_extend_account_instruction` right after
`create_pump_create_v2_instruction` in the same instruction list — so both
orderings occur; how common a further-resized (256-byte) curve is remains
unmeasured.
"""
import argparse
import asyncio
import base64
import json
import os
import struct
import sys
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 solders.pubkey import Pubkey
load_dotenv()
# Constants
RPC_ENDPOINT: Final[str] = os.environ.get("SOLANA_NODE_RPC_ENDPOINT", "")
WSS_ENDPOINT: Final[str] = os.environ.get("SOLANA_NODE_WSS_ENDPOINT", "")
PUMP_PROGRAM_ID: Final[Pubkey] = Pubkey.from_string(
"6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
)
PUMP_GLOBAL: Final[Pubkey] = Pubkey.from_string(
"4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf"
)
# Coins created by `create_v2` are Token-2022, so that is tried first. Querying
# under the wrong token program returns nothing at all.
TOKEN_2022_PROGRAM_ID: Final[Pubkey] = Pubkey.from_string(
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
)
TOKEN_PROGRAM_ID: Final[Pubkey] = Pubkey.from_string(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
)
# See learning-examples/calculate_discriminator.py
BONDING_CURVE_DISCRIMINATOR: Final[bytes] = bytes.fromhex("17b7f83760d8ac60")
# create_v2 allocates the 125-byte struct; extend_account can grow it past
# that to any length the program allows (151 and 256 both confirmed live,
# 2026-09-15) — there is no fixed set of lengths to enumerate. This is a
# floor, not an allowlist: anything shorter than the smallest real struct is
# dropped client-side, everything at or above it is decoded and let through.
MIN_CURVE_LEN: Final[int] = 125
TOKEN_DECIMALS: Final[int] = 6
_RESERVES_OFFSET: Final[int] = 24 # real_token_reserves, u64 LE
_COMPLETE_OFFSET: Final[int] = 48
_QUOTE_MINT_OFFSET: Final[int] = 83
_GLOBAL_INITIAL_REAL_TOKEN_RESERVES_OFFSET: Final[int] = 89
_BAD_DISCRIMINATOR_MSG: Final[str] = "Invalid discriminator for bonding curve"
# Quote assets. `quote_mint` is all zeros on SOL-paired coins, and the quote-side
# reserves are in that mint's raw units — 1e9 for SOL, 1e6 for USDC.
DEFAULT_QUOTE_MINT: Final[Pubkey] = Pubkey.from_bytes(bytes(32))
WSOL_MINT: Final[Pubkey] = Pubkey.from_string(
"So11111111111111111111111111111111111111112"
)
USDC_MINT: Final[Pubkey] = Pubkey.from_string(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
)
QUOTE_DECIMALS: Final[dict[Pubkey, int]] = {WSOL_MINT: 9, USDC_MINT: 6}
QUOTE_SYMBOLS: Final[dict[Pubkey, str]] = {WSOL_MINT: "SOL", USDC_MINT: "USDC"}
# Only used if the Global account cannot be read: 1B supply less 206.9M reserved.
FALLBACK_INITIAL_REAL_TOKEN_RESERVES: Final[float] = 793_100_000.0
# A qualifying curve is traded several times a second. Reprint it only once it has
# moved this far, so the output stays readable.
REPRINT_STEP_PCT: Final[float] = 0.25
RECONNECT_DELAY: Final[int] = 5
def zero_prefix_gate(bound_raw: int) -> tuple[int, bytes] | None:
"""Pick the tightest server-side cutoff that still lets every match through.
See the module docstring for the cutoffs on offer. Returns None when none of
them is wide enough to be safe, in which case there is no pre-filtering and
every curve is checked here instead.
Args:
bound_raw: Highest reserves value, in raw units, that should still qualify
Returns:
An (offset, zero bytes) pair for a memcmp filter, or None for no filter
"""
# Only 2, 3 and 4 zero bytes are offered. One zero byte would be a cutoff so
# high that no coin could ever fail it, which filters nothing while looking
# like it does.
for zero_bytes in (4, 3, 2):
if 2 ** (8 * (8 - zero_bytes)) > bound_raw:
return 32 - zero_bytes, bytes(zero_bytes)
return None
def build_filters(bound_raw: int) -> list[dict[str, Any]]:
"""Assemble the server-side `programSubscribe` filters.
No `dataSize` filter: `extend_account` can grow a curve past 125 bytes to
any length the program allows, so there is no fixed set of lengths to
match. The discriminator memcmp alone already restricts delivery to
`BondingCurve` accounts — see the module docstring for the bandwidth this
trades away and the client-side `MIN_CURVE_LEN` floor that replaces it.
Args:
bound_raw: Highest qualifying `real_token_reserves`, in raw units
Returns:
Filter dicts in the shape the RPC expects
"""
def memcmp(offset: int, raw: bytes) -> dict[str, Any]:
return {
"memcmp": {
"offset": offset,
"bytes": base64.b64encode(raw).decode(),
"encoding": "base64",
}
}
filters: list[dict[str, Any]] = [
memcmp(0, BONDING_CURVE_DISCRIMINATOR),
memcmp(_COMPLETE_OFFSET, b"\x00"), # Not graduated yet
]
gate = zero_prefix_gate(bound_raw)
if gate:
filters.append(memcmp(*gate))
return filters
def parse_curve(data: bytes) -> dict[str, Any]:
"""Decode the bonding curve fields needed for a progress report.
Works at any length the account might arrive at — 125 bytes as created,
151 once extended, 256 confirmed live, or any other length the program
allows in the future — since every field read here sits in the first 115
bytes, well before where any resizing appends. Callers are expected to
apply `MIN_CURVE_LEN` first; this function does not re-check it.
Args:
data: Raw bonding curve account data
Returns:
Reserves in whole tokens, plus the quote asset's symbol
Raises:
ValueError: If the discriminator does not match a bonding curve
"""
if data[:8] != BONDING_CURVE_DISCRIMINATOR:
raise ValueError(_BAD_DISCRIMINATOR_MSG)
real_token_reserves = struct.unpack_from("<Q", data, _RESERVES_OFFSET)[0]
real_quote_reserves = struct.unpack_from("<Q", data, _RESERVES_OFFSET + 8)[0]
quote_mint = Pubkey.from_bytes(data[_QUOTE_MINT_OFFSET : _QUOTE_MINT_OFFSET + 32])
if quote_mint == DEFAULT_QUOTE_MINT:
quote_mint = WSOL_MINT
quote_unit = 10 ** QUOTE_DECIMALS.get(quote_mint, 9)
return {
"real_token_reserves": real_token_reserves / 10**TOKEN_DECIMALS,
"real_quote_reserves": real_quote_reserves / quote_unit,
"quote_symbol": QUOTE_SYMBOLS.get(quote_mint, str(quote_mint)),
}
async def fetch_initial_real_token_reserves(client: AsyncClient) -> float:
"""Read the launch-time real token reserves from the Global account.
Global layout up to this field: discriminator(8) + initialized(1) +
authority(32) + fee_recipient(32) + initial_virtual_token_reserves(8) +
initial_virtual_sol_reserves(8), so initial_real_token_reserves sits at 89.
Args:
client: Connected RPC client
Returns:
Initial real token reserves in whole tokens, or the fallback constant
"""
try:
resp = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
raw = struct.unpack_from(
"<Q", resp.value.data, _GLOBAL_INITIAL_REAL_TOKEN_RESERVES_OFFSET
)[0]
if raw:
return raw / 10**TOKEN_DECIMALS
except Exception as e: # noqa: BLE001 - fall back rather than abort the watcher
print(f"⚠️ Could not read Global, using the fallback baseline: {e}")
return FALLBACK_INITIAL_REAL_TOKEN_RESERVES
async def resolve_mint(client: AsyncClient, curve: Pubkey) -> Pubkey | None:
"""Recover a coin's mint from its bonding curve address.
The curve account carries no mint field and `["bonding-curve", mint]` is not
reversible, so this goes through the associated bonding curve — an ordinary ATA
owned by the curve. That ATA belongs to Token-2022 for `create_v2` coins, which
is every coin now being launched, so Token-2022 is tried first. The answer is
checked by re-deriving the curve PDA from the mint.
Args:
client: Connected RPC client
curve: The bonding curve address
Returns:
The mint, or None if no owned token account resolves back to this curve
"""
for program_id in (TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID):
try:
resp = await client.get_token_accounts_by_owner(
curve, TokenAccountOpts(program_id=program_id)
)
except Exception as e: # noqa: BLE001 - a miss here is not fatal
print(f"⚠️ Mint lookup failed for {curve}: {e}")
continue
if not resp.value:
continue
mint = Pubkey(resp.value[0].account.data[:32])
derived, _ = Pubkey.find_program_address(
[b"bonding-curve", bytes(mint)], PUMP_PROGRAM_ID
)
if derived == curve:
return mint
return None
def print_banner(baseline: float, min_progress: float) -> None:
"""Describe the baseline and the filter that will be installed.
Args:
baseline: Launch-time real token reserves, in whole tokens
min_progress: Graduation progress threshold, as a percentage
"""
print(f"Graduation baseline: {baseline:,.0f} tokens (from Global)")
print(f"Reporting curves at or above {min_progress:.2f}% graduated")
gate = zero_prefix_gate(progress_to_bound(baseline, min_progress))
if gate:
cutoff_tokens = 2 ** (8 * (8 - len(gate[1]))) / 10**TOKEN_DECIMALS
cutoff_pct = max(100 - cutoff_tokens * 100 / baseline, 0.0)
print(
f"Pre-filter: the server sends only curves past ~{cutoff_pct:.2f}% "
f"({cutoff_tokens:,.0f} tokens left); the rest is checked here"
)
else:
print("Pre-filter: none, so every curve arrives and is checked here")
print("Waiting for trades on qualifying curves...\n")
def progress_to_bound(baseline: float, min_progress: float) -> int:
"""Convert a progress threshold into a raw `real_token_reserves` ceiling.
Args:
baseline: Launch-time real token reserves, in whole tokens
min_progress: Graduation progress threshold, as a percentage
Returns:
The highest raw reserves value that still qualifies
"""
return int(baseline * (1 - min_progress / 100) * 10**TOKEN_DECIMALS)
class GraduationReporter:
"""Turns raw curve updates into one printed line per meaningful change.
Holds the mint cache and the last-printed progress per curve, so the transport
loop only has to hand over decoded account bytes.
"""
def __init__(
self, client: AsyncClient, baseline: float, min_progress: float
) -> None:
"""Initialize the reporter.
Args:
client: Connected RPC client, used to resolve mints
baseline: Launch-time real token reserves, in whole tokens
min_progress: Graduation progress threshold, as a percentage
"""
self.client = client
self.baseline = baseline
self.min_progress = min_progress
self.mints: dict[Pubkey, Pubkey | None] = {}
self.last_printed: dict[Pubkey, float] = {}
async def handle(self, curve: Pubkey, data: bytes, suffix: str = "") -> None:
"""Report one curve update, if it qualifies and has moved far enough.
Args:
curve: The bonding curve address
data: Raw bonding curve account data
suffix: Extra provenance to append to the line, if the transport has any
"""
try:
state = parse_curve(data)
except (ValueError, struct.error) as e:
print(f"⚠️ Could not decode {curve}: {e}")
return
# The server-side gate is coarser than the requested threshold, so the exact
# comparison happens here.
progress = max(100 - state["real_token_reserves"] * 100 / self.baseline, 0.0)
if progress < self.min_progress:
return
previous = self.last_printed.get(curve)
if previous is not None and abs(progress - previous) < REPRINT_STEP_PCT:
return
self.last_printed[curve] = progress
if curve not in self.mints:
self.mints[curve] = await resolve_mint(self.client, curve)
mint = self.mints[curve]
print(
f"🎓 {progress:6.2f}% "
f"mint={mint if mint else '<unresolved>'} "
f"curve={curve} "
f"{state['real_token_reserves']:,.0f} tokens left "
f"{state['real_quote_reserves']:,.4f} {state['quote_symbol']}"
f"{suffix}"
)
async def stream_once(
reporter: GraduationReporter, filters: list[dict[str, Any]]
) -> None:
"""Subscribe and consume notifications until the connection drops.
One subscription is enough now that there is no `dataSize` filter to
fan out over — a curve at any length is delivered on this single
filter set. Only the subscription id handed back by our own ack is
treated as ours, so a notification from an unrelated subscription on this
connection (there should not be one) is ignored rather than mishandled.
Args:
reporter: Sink for decoded curve updates
filters: The server-side filter list to install
Raises:
ConnectionRefusedError: If the endpoint rejects the subscription outright
"""
async with websockets.connect(WSS_ENDPOINT, max_size=None) as ws:
await ws.send(
json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "programSubscribe",
"params": [
str(PUMP_PROGRAM_ID),
{
"encoding": "base64",
"commitment": "processed",
"filters": filters,
},
],
}
)
)
ack = json.loads(await ws.recv())
if "error" in ack:
raise ConnectionRefusedError(str(ack["error"]))
subscription_id = ack["result"]
while True:
# ConnectionClosed deliberately propagates to the reconnect handler in
# watch(). Swallowing it here would make every further recv() raise
# instantly, forever. A JSONDecodeError is per-message rather than
# per-connection, so that one is safe to skip.
try:
message = json.loads(await ws.recv())
except json.JSONDecodeError:
continue
if message.get("method") != "programNotification":
continue
if message["params"]["subscription"] != subscription_id:
continue
value = message["params"]["result"]["value"]
data = base64.b64decode(value["account"]["data"][0])
if len(data) < MIN_CURVE_LEN:
# Shorter than the smallest real BondingCurve struct — not a
# shape the decoder should be trusted with. See MIN_CURVE_LEN.
continue
await reporter.handle(Pubkey.from_string(value["pubkey"]), data)
async def watch(min_progress: float) -> None:
"""Stream curve updates and report coins at or above `min_progress`.
Args:
min_progress: Graduation progress threshold, as a percentage
"""
if not WSS_ENDPOINT or not RPC_ENDPOINT:
print("❌ Set SOLANA_NODE_RPC_ENDPOINT and SOLANA_NODE_WSS_ENDPOINT in .env")
return
async with AsyncClient(RPC_ENDPOINT) as client:
baseline = await fetch_initial_real_token_reserves(client)
print_banner(baseline, min_progress)
bound_raw = progress_to_bound(baseline, min_progress)
filters = build_filters(bound_raw)
reporter = GraduationReporter(client, baseline, min_progress)
while True:
try:
await stream_once(reporter, filters)
except ConnectionRefusedError as e:
print(f"❌ Subscription rejected: {e}")
return
except Exception as e: # noqa: BLE001 - keep watching across hiccups
print(f"⚠️ {type(e).__name__}: {e}; reconnecting in {RECONNECT_DELAY}s")
await asyncio.sleep(RECONNECT_DELAY)
def main() -> None:
"""Parse arguments and start watching."""
# A watcher is usually piped into a file or grep, where block buffering would
# hold every line back — and lose them entirely if the process is killed.
sys.stdout.reconfigure(line_buffering=True)
parser = argparse.ArgumentParser(
description="Report pump.fun coins approaching graduation, over WebSocket RPC"
)
parser.add_argument(
"--min-progress",
type=float,
default=90.0,
help="Only report curves at or above this graduation percentage "
"(default: 90.0)",
)
args = parser.parse_args()
asyncio.run(watch(args.min_progress))
if __name__ == "__main__":
main()