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