mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* fix(cookbook,tools): read GEYSER_AUTH_TYPE and reject an unknown scheme
Four Geyser clients advertised the variable and ignored it, sending Basic for
anything that was not exactly "x-token". Validate against {x-token, basic}.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): detect blockSubscribe creations from logs alone
The envelope fallback cannot see a create reached by CPI, so it covered a
subset of the log route. Envelope decoding stays in the decode/ script.
Also skip meta.err: logs emitted before a failure are kept, and that mint
does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): raise when the graduation baseline cannot be read
The watchers fell back to a hardcoded 793,100,000 and still printed it as
"(from Global)". Every progress figure is measured against that baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): report an unsent create flag as unknown, not false
Truncated create_v2 args and legacy create were filled in as False, reading
as a coin the program said is not in mayhem mode. Leave them out, print N/A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): build Geyser credentials before the reconnect loop
The loop catches every exception and sleeps, so an unusable auth type
reconnected forever instead of reporting a misconfiguration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): treat a short Global buffer as an unreadable baseline
The guard only caught a missing account, so a short read raised struct.error
past the documented ValueError.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): surface a listener's exception instead of an idle market
A listener that raised on connect returned nothing, exactly like a quiet
market, in the harness whose job is to certify listeners. Take the listener
list from the factory too, so one added there is not silently skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): simulate the transaction the bot built
The hook rebuilt the transaction itself and dropped the account data size
limit, so the simulation was more permissive than the send. Hook the RPC
client instead, build the client before detection so its blockhash cache is
warm, and report the compute budget read back off the wire.
A listener that raised on connect also read as a quiet market here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): skip failed migrations on err, not on the word "Error"
The notification carries err beside the logs. Scanning the text dropped a
migration whose logs merely said "Error" and kept ones that truly failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): refuse a letsbonk buy that accepts any output
The only one of the four without the guard. Near graduation the floor
division reaches zero, and the buy submits with no slippage protection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): require the pool's own platform_config
The fallback was a partner-specific address that reverts with
ConstraintAddress (2012). A pool that omits it cannot be traded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): raise when curve data ends before a flag
is_mayhem_mode picks the fee recipient for a buy, and the wrong one reverts
with NotAuthorized (6000), so defaulting it only moves the failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): trade the coin's own PumpSwap pool, not an arbitrary one
Anyone can open a pool against any mint and getProgramAccounts has no defined
order, so [0] was a stranger's pool. Select on coin_creator, which migration
copies from the bonding curve and nothing else sets, and refuse rather than
guess when there is no single match. --pool trades a specific one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
542 lines
21 KiB
Python
542 lines
21 KiB
Python
"""Watch for pump.fun coins approaching graduation, over plain WebSocket RPC.
|
|
|
|
Usage:
|
|
uv run cookbook/pumpfun/graduation/pumpfun_watch_graduating_programsubscribe.py
|
|
uv run cookbook/pumpfun/graduation/pumpfun_watch_graduating_programsubscribe.py --min-progress 95
|
|
|
|
Why a subscription and not `getProgramAccounts`: the pump.fun program owns over
|
|
10 million accounts and every provider refuses to scan it — the rejection is on
|
|
program size, before filters apply. `getProgramAccountsV2` is a provider
|
|
extension, not core Agave, and its `limit` is a scan budget rather than a result
|
|
count, so one filtered answer costs ~1000 sequential pages.
|
|
|
|
`programSubscribe` sidesteps the scan. A curve can only approach graduation by
|
|
being traded, and every write pushes the full account, so each notification
|
|
carries everything needed to compute progress: no accumulated state, no cold
|
|
start beyond the next trade. It is accepted even by the public
|
|
`api.mainnet-beta.solana.com`.
|
|
|
|
See `pumpfun_watch_graduating_geyser.py` for the same report over Geyser gRPC,
|
|
which also gives the transaction signature behind each update.
|
|
|
|
Selecting a graduation threshold
|
|
--------------------------------
|
|
Progress is measured against `Global.initial_real_token_reserves` read from
|
|
chain, not a hardcoded constant: a mayhem coin can launch with different virtual
|
|
params and would otherwise show the wrong percentage.
|
|
|
|
The server-side pre-filter matches exact bytes, so it cannot express "anything
|
|
above 90%" — only a few fixed cutoffs. `--min-progress` picks 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%
|
|
|
|
So the pre-filter saves bandwidth, it does not decide the answer — whatever
|
|
percentage you ask for is honoured either way, low thresholds just cost more
|
|
traffic. To hand-tune, pick a different cutoff: the last moments before
|
|
migration want the 3-byte one, a wider funnel the 2-byte one.
|
|
|
|
This script does **not** filter on `dataSize`. `create_v2` allocates 125 bytes,
|
|
`extend_account` can grow a curve to any length the program allows (151 and 256
|
|
both occur live), and no finite allowlist closes that gap — the next length
|
|
silently drops curves, and the failure mode is invisible because the script just
|
|
prints fewer results. The discriminator `memcmp` alone already restricts delivery
|
|
to `BondingCurve` accounts, so dropping `dataSize` costs no precision. A
|
|
`MIN_CURVE_LEN` floor still discards anything shorter than the smallest real
|
|
struct.
|
|
"""
|
|
|
|
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.core 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 cookbook/solana/anchor_calculate_discriminator.py
|
|
BONDING_CURVE_DISCRIMINATOR: Final[bytes] = bytes.fromhex("17b7f83760d8ac60")
|
|
|
|
# create_v2 allocates the 125-byte struct; extend_account can grow it to any
|
|
# length the program allows (151 and 256 both occur live), so there is no fixed
|
|
# set to enumerate. This is a floor, not an allowlist: anything shorter than the
|
|
# smallest real struct is dropped, everything at or above it is decoded.
|
|
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"}
|
|
|
|
# Same offset in SPL Token and Token-2022: extensions are appended after it.
|
|
_MINT_DECIMALS_OFFSET: Final[int] = 44
|
|
|
|
_NO_BASELINE_MSG: Final[str] = (
|
|
"Cannot read initial_real_token_reserves from the pump.fun Global account"
|
|
)
|
|
|
|
# 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:
|
|
Token reserves in whole tokens, quote reserves raw, plus the quote mint
|
|
|
|
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-side reserves stay raw: only the quote mint's decimals scale them.
|
|
return {
|
|
"real_token_reserves": real_token_reserves / 10**TOKEN_DECIMALS,
|
|
"real_quote_reserves_raw": real_quote_reserves,
|
|
"quote_mint": quote_mint,
|
|
"quote_symbol": QUOTE_SYMBOLS.get(quote_mint, str(quote_mint)),
|
|
}
|
|
|
|
|
|
async def read_quote_decimals(conn: AsyncClient, quote_mint: Pubkey) -> int:
|
|
"""Read a quote mint's decimals from chain.
|
|
|
|
`QuoteControl` admits mints from 4 to 12 decimals, so a default of 9 is
|
|
wrong for most of them and misscales every quote-side figure silently.
|
|
|
|
Raises:
|
|
ValueError: If the mint is missing or too short to be a mint
|
|
"""
|
|
if quote_mint in QUOTE_DECIMALS:
|
|
return QUOTE_DECIMALS[quote_mint]
|
|
|
|
response = await conn.get_account_info(quote_mint, encoding="base64")
|
|
if response.value is None:
|
|
raise ValueError(f"Quote mint {quote_mint} does not exist on chain")
|
|
data = bytes(response.value.data)
|
|
if len(data) <= _MINT_DECIMALS_OFFSET:
|
|
raise ValueError(
|
|
f"Account {quote_mint} is only {len(data)} bytes, too short to be a mint"
|
|
)
|
|
return data[_MINT_DECIMALS_OFFSET]
|
|
|
|
|
|
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
|
|
|
|
Raises:
|
|
ValueError: If Global is missing or carries a zero at that offset. The
|
|
baseline is the 0% mark every progress figure is measured against,
|
|
so a guessed one misreports every coin the run touches.
|
|
"""
|
|
resp = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
|
|
if (
|
|
resp.value is None
|
|
or len(resp.value.data) < _GLOBAL_INITIAL_REAL_TOKEN_RESERVES_OFFSET + 8
|
|
):
|
|
raise ValueError(_NO_BASELINE_MSG)
|
|
raw = struct.unpack_from(
|
|
"<Q", resp.value.data, _GLOBAL_INITIAL_REAL_TOKEN_RESERVES_OFFSET
|
|
)[0]
|
|
if not raw:
|
|
raise ValueError(_NO_BASELINE_MSG)
|
|
return raw / 10**TOKEN_DECIMALS
|
|
|
|
|
|
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.quote_decimals: dict[Pubkey, int] = {}
|
|
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]
|
|
|
|
quote_mint = state["quote_mint"]
|
|
if quote_mint not in self.quote_decimals:
|
|
self.quote_decimals[quote_mint] = await read_quote_decimals(
|
|
self.client, quote_mint
|
|
)
|
|
quote_unit = 10 ** self.quote_decimals[quote_mint]
|
|
|
|
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_raw'] / quote_unit:,.4f} "
|
|
f"{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()
|