mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* feat(monitoring): add shreds listener for pre-execution token detection Adds a `shreds` listener backed by geyser's SubscribeDeshred, which delivers transactions when entries form from shreds, before execution. pump.fun only; bots/bot-sniper-5-shreds.yaml ships disabled. With no TransactionStatusMeta there is no CreateEvent, so the listener decodes the create instruction and resolves what the buy needs itself. It marks its tokens state_from_event, because there is no curve to read before the create executes: trading on the instruction or not trading at all are the only options. Two fixes to the shared instruction-decode path, which geyser and blocks also reach when logs are absent: - `user` is account 5 on create_v2, not 7. create_v2 drops the metaplex accounts legacy create carries, so index 7 is the token program and every address derived from the wallet was wrong. - A holder-reward coin's creator is PDA(["holder-rewards", mint]). The program ignores args.creator on those coins and writes that PDA into BondingCurve.creator, so creator_vault must derive from it or the buy is rejected on a seeds constraint. Subscribes on the mint authority rather than the program id: it appears in create/create_v2 and in no trade instruction, which keeps the stream to creations instead of every pump.fun transaction. Verified live on mainnet with extreme_fast_mode on and off and both the time_based and tp_sl exits, each ending with the wallet holding SOL only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop reported measurement statistics from prose Removes sample counts, traffic percentages, latency percentiles, window durations and "measured on <date>" claims from docstrings, comments, docs and bot YAML. The fact each measurement supported is kept and stated plainly. Numbers that are load-bearing rather than reporting stay: protocol and packet limits, the CU figures used to tune compute budgets, and dates pinning on-chain or dependency upgrades. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
"""Verify no trade path prices a coin before resolving its quote mint.
|
|
|
|
pump.fun's quote assets are not just SOL and USDC. The `QuoteControl` registry
|
|
(PDA `["quote-control"]`) admits mints at 6, 8 and 9 decimals — many of them
|
|
tokenized equities, 8 decimals for Backed's xStocks and 6 for Backpack
|
|
Securities — and coins paired with them trade live.
|
|
|
|
`quote_units()` used to default to 9 decimals for an unresolved mint. Every
|
|
cookbook trade script called it *before* `resolve_quote_token_program()` warmed
|
|
the decimals cache, so every one of those coins was sized against the wrong
|
|
power of ten:
|
|
|
|
xStock (8 decimals) -> cap and size 10x too large
|
|
Backpack (6 decimals) -> cap and size 1000x too large
|
|
|
|
The error does not cancel. `price_per_token()` divides the quote reserves by the
|
|
same wrong unit, so the price comes out low by the same factor, the token amount
|
|
comes out high by it, and the inflated cap authorises the overspend instead of
|
|
catching it.
|
|
|
|
Offline machine checks, no network and no funds moved:
|
|
|
|
A. `quote_units()` raises for a mint whose decimals are unknown, rather than
|
|
assuming 9, and returns the right value once resolved.
|
|
B. `resolve_quote_token_program()` caches decimals off the same account read
|
|
it makes for the token program, so resolving costs no extra RPC call.
|
|
C. Every cookbook script that calls `quote_units` or `price_per_token` calls
|
|
`resolve_quote_token_program` first, checked per function body.
|
|
|
|
Usage:
|
|
uv run tests/regression/verify_quote_decimals_resolved.py
|
|
"""
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT / "cookbook" / "pumpfun" / "trade"))
|
|
|
|
import pumpfun_instructions_v2 as pump_v2 # noqa: E402
|
|
from solders.pubkey import Pubkey # noqa: E402
|
|
|
|
COOKBOOK = PROJECT_ROOT / "cookbook"
|
|
|
|
# A real Token-2022 tokenized equity: Apple xStock, 8 decimals, admitted through
|
|
# QuoteControl rather than Global's whitelist.
|
|
AAPLX = Pubkey.from_string("XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp")
|
|
AAPLX_DECIMALS = 8
|
|
|
|
|
|
class _FakeMintAccount:
|
|
"""The two fields `resolve_quote_token_program` reads off a mint account."""
|
|
|
|
def __init__(self, owner: Pubkey, decimals: int) -> None:
|
|
"""Build a mint account whose data carries `decimals` at offset 44.
|
|
|
|
Args:
|
|
owner: Token program that owns the mint
|
|
decimals: Decimal count to place at the mint layout's fixed offset
|
|
"""
|
|
self.owner = owner
|
|
data = bytearray(82)
|
|
data[44] = decimals
|
|
self.data = bytes(data)
|
|
|
|
|
|
def check_unresolved_mint_raises() -> None:
|
|
"""An unknown quote mint must fail loudly, never default to 9 decimals."""
|
|
assert AAPLX not in pump_v2.QUOTE_DECIMALS, "fixture mint must start unknown"
|
|
try:
|
|
pump_v2.quote_units(AAPLX)
|
|
except ValueError as exc:
|
|
assert "resolve_quote_token_program" in str(exc), (
|
|
f"the error must say how to fix it, got: {exc}"
|
|
)
|
|
else:
|
|
raise AssertionError(
|
|
"quote_units() returned a value for an unresolved mint; a wrong "
|
|
"power of ten here oversizes the trade instead of capping it"
|
|
)
|
|
|
|
# The pre-seeded pair still answers without a lookup.
|
|
assert pump_v2.quote_units(pump_v2.WSOL_MINT) == 10**9
|
|
assert pump_v2.quote_units(pump_v2.USDC_MINT) == 10**6
|
|
|
|
|
|
async def check_resolution_caches_decimals() -> None:
|
|
"""Resolving the token program must cache decimals from the same read."""
|
|
reads = []
|
|
|
|
async def get_account(address: Pubkey) -> _FakeMintAccount:
|
|
reads.append(address)
|
|
return _FakeMintAccount(pump_v2.TOKEN_2022_PROGRAM, AAPLX_DECIMALS)
|
|
|
|
program = await pump_v2.resolve_quote_token_program(AAPLX, get_account)
|
|
assert program == pump_v2.TOKEN_2022_PROGRAM, program
|
|
assert pump_v2.quote_units(AAPLX) == 10**AAPLX_DECIMALS, (
|
|
"decimals were not cached by the token-program resolution"
|
|
)
|
|
assert len(reads) == 1, f"expected one account read, made {len(reads)}"
|
|
|
|
# Second call must be free.
|
|
await pump_v2.resolve_quote_token_program(AAPLX, get_account)
|
|
assert len(reads) == 1, "a resolved mint was fetched again"
|
|
|
|
|
|
def _called_names(node: ast.AST) -> list[tuple[str, int]]:
|
|
"""Every call made inside one function body, as (name, line).
|
|
|
|
Args:
|
|
node: The function definition to walk
|
|
|
|
Returns:
|
|
Called attribute/function names paired with their line numbers
|
|
"""
|
|
out = []
|
|
for sub in ast.walk(node):
|
|
if not isinstance(sub, ast.Call):
|
|
continue
|
|
fn = sub.func
|
|
name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", None)
|
|
if name:
|
|
out.append((name, sub.lineno))
|
|
return out
|
|
|
|
|
|
def check_scripts_resolve_before_pricing() -> None:
|
|
"""No cookbook script may size or price a trade before resolving.
|
|
|
|
Checked per function body, not per file: a module-level helper that wraps
|
|
`price_per_token()` is defined above the trade path that calls it, so plain
|
|
source order reads as a violation when the runtime order is fine.
|
|
"""
|
|
offenders = []
|
|
for path in sorted(COOKBOOK.rglob("*.py")):
|
|
source = path.read_text()
|
|
# Only scripts that share the helper module's cache are in scope; the
|
|
# offline decoders carry their own self-contained arithmetic.
|
|
if "pumpfun_instructions_v2" not in source:
|
|
continue
|
|
tree = ast.parse(source)
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef):
|
|
continue
|
|
calls = _called_names(node)
|
|
sizing = [
|
|
ln for name, ln in calls if name in {"quote_units", "price_per_token"}
|
|
]
|
|
resolving = [
|
|
ln for name, ln in calls if name == "resolve_quote_token_program"
|
|
]
|
|
if not sizing:
|
|
continue
|
|
where = f"{path.relative_to(PROJECT_ROOT)}:{node.name}"
|
|
if not resolving:
|
|
# Fine only if this function never sizes a trade itself, i.e. it
|
|
# is a pure helper whose caller resolves. Those take the curve
|
|
# state as an argument rather than fetching it.
|
|
if any(name == "quote_units" for name, _ in calls):
|
|
offenders.append(f"{where}: sizes without resolving")
|
|
elif min(sizing) < min(resolving):
|
|
offenders.append(
|
|
f"{where}: prices at line {min(sizing)}, "
|
|
f"resolves at line {min(resolving)}"
|
|
)
|
|
assert not offenders, "trade paths price before resolving:\n " + "\n ".join(
|
|
offenders
|
|
)
|
|
|
|
|
|
async def main() -> None:
|
|
"""Run every check and report."""
|
|
print("=" * 72)
|
|
print("Verifying quote-mint decimals are resolved before pricing")
|
|
print("=" * 72)
|
|
|
|
checks = [
|
|
("an unresolved quote mint raises", check_unresolved_mint_raises),
|
|
("resolution caches decimals in one read", check_resolution_caches_decimals),
|
|
("every script resolves before pricing", check_scripts_resolve_before_pricing),
|
|
]
|
|
for label, fn in checks:
|
|
result = fn()
|
|
if result is not None:
|
|
await result
|
|
print(f"{label} -> OK")
|
|
|
|
print("\nALL CHECKS PASSED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
|
|
asyncio.run(main())
|