mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
fix(core): raise on unresolved quote decimals instead of defaulting to 9 (#223)
Only the buy path's quote gate kept a guessed power of ten off the chain. `quote_decimals` now raises, `cached_quote_units` returns None for the curve decoder, and `resolve_quote_token_program` no longer short-circuits before reading decimals. The buy validates its price after the gate. verify_quote_decimals_resolved gains four checks and now covers src/. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b04a375506
commit
01edbbf8c4
@@ -135,9 +135,13 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
|
||||
|
||||
- **Resolve a coin's quote mint before pricing or sizing anything.**
|
||||
`resolve_quote_token_program` returns the token program and caches the mint's
|
||||
decimals off the same read; `quote_units` raises rather than guessing, because
|
||||
a wrong power of ten inflates the price *and* the slippage cap in the same
|
||||
direction, so they compound into an overspend instead of cancelling.
|
||||
decimals off the same read. Both unit helpers then raise rather than guess —
|
||||
`quote_units_per_token` in `src/`, `quote_units` in `cookbook/` — because a
|
||||
wrong power of ten inflates the price *and* the slippage cap in the same
|
||||
direction, so they compound into an overspend instead of cancelling. Only
|
||||
`cached_quote_units` answers `None` instead of raising, for the curve decoder,
|
||||
which runs against coins that are never traded; it leaves `price_per_token`
|
||||
unset and the caller's quote gate refuses the coin.
|
||||
- `trade.curve_refresh_budget` (seconds, default 2.0) bounds the pre-buy curve
|
||||
read in `extreme_fast_mode`; when it expires the token is **skipped**, because
|
||||
a buy built from listener-guessed defaults reverts with `NotAuthorized` (6000),
|
||||
|
||||
@@ -27,7 +27,7 @@ individual scripts for a subset.
|
||||
| `verify_time_exit_without_price.py` | `max_hold_time` still fires when every price read fails |
|
||||
| `verify_exit_sell_confirmation.py` | an exit sell is retried only when retrying is provably safe |
|
||||
| `verify_rpc_deadline.py` | `post_rpc` bounds wall time, not just attempts (virtual clock) |
|
||||
| `verify_quote_decimals_resolved.py` | no trade path prices a coin before resolving its quote mint's decimals, and no script anywhere falls back to a literal decimal count |
|
||||
| `verify_quote_decimals_resolved.py` | no trade path prices a coin before resolving its quote mint's decimals; nothing in `cookbook/` or `src/` falls back to a literal decimal count; both unit helpers raise for an unresolved mint; the two pre-seeded quote tables carry the same mints; the buy clears its quote gate before it sizes |
|
||||
| `verify_cookbook_arguments.py` | every cookbook script takes its input as a command-line argument |
|
||||
| `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx |
|
||||
| `verify_no_rpc_credentials_logged.py` | credentials masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first |
|
||||
|
||||
+42
-5
@@ -102,14 +102,29 @@ _QUOTE_DECIMALS_CACHE: dict[Pubkey, int] = dict(QUOTE_DECIMALS)
|
||||
def quote_decimals(quote_mint: Pubkey) -> int:
|
||||
"""Get a quote mint's decimal count from the warm cache -- zero RPC.
|
||||
|
||||
Pre-seeded with WSOL (9) and USDC (6); every other configured quote mint is
|
||||
cached at startup by `resolve_quote_token_program`, so an uncached mint here
|
||||
is one the bot refuses to trade rather than one this silently mis-scales.
|
||||
Pre-seeded with WSOL (9) and USDC (6); every other quote mint is cached by
|
||||
`resolve_quote_token_program`, which every configured mint goes through at
|
||||
startup.
|
||||
|
||||
Raises rather than guessing. `QuoteControl` admits mints at 6, 8 and 9
|
||||
decimals, so a default would misprice the coin and inflate the slippage cap
|
||||
in the same direction, compounding into an overspend instead of cancelling.
|
||||
Callers that must not fail on an unknown mint use `cached_quote_units`.
|
||||
|
||||
Returns:
|
||||
Number of decimals used by the quote mint
|
||||
|
||||
Raises:
|
||||
ValueError: If the mint's decimals have not been resolved yet
|
||||
"""
|
||||
return _QUOTE_DECIMALS_CACHE.get(quote_mint, 9)
|
||||
decimals = _QUOTE_DECIMALS_CACHE.get(quote_mint)
|
||||
if decimals is None:
|
||||
raise ValueError(
|
||||
f"Decimals for quote mint {quote_mint} are unknown. Call "
|
||||
f"resolve_quote_token_program() for it before pricing or sizing a "
|
||||
f"trade -- guessing here misprices the trade by a power of ten."
|
||||
)
|
||||
return decimals
|
||||
|
||||
|
||||
def quote_units_per_token(quote_mint: Pubkey) -> int:
|
||||
@@ -117,10 +132,29 @@ def quote_units_per_token(quote_mint: Pubkey) -> int:
|
||||
|
||||
Returns:
|
||||
10 ** decimals for the quote mint (1e9 for SOL, 1e6 for USDC)
|
||||
|
||||
Raises:
|
||||
ValueError: If the mint's decimals have not been resolved yet
|
||||
"""
|
||||
return 10 ** quote_decimals(quote_mint)
|
||||
|
||||
|
||||
def cached_quote_units(quote_mint: Pubkey) -> int | None:
|
||||
"""Get the raw-units-per-whole-unit factor, or None if it is unknown.
|
||||
|
||||
For callers that decode a coin they may never trade -- a curve decoder runs
|
||||
against every coin a listener reports, and raising there would turn an
|
||||
unknown quote asset into a decode failure that a retry loop keeps retrying.
|
||||
Anything that sizes or prices a real trade uses `quote_units_per_token` and
|
||||
takes the raise.
|
||||
|
||||
Returns:
|
||||
10 ** decimals for the quote mint, or None if unresolved
|
||||
"""
|
||||
decimals = _QUOTE_DECIMALS_CACHE.get(quote_mint)
|
||||
return None if decimals is None else 10**decimals
|
||||
|
||||
|
||||
def quote_token_program(quote_mint: Pubkey) -> Pubkey:
|
||||
"""Get the token program owning a quote mint, defaulting to SPL Token.
|
||||
|
||||
@@ -167,8 +201,11 @@ async def resolve_quote_token_program(
|
||||
uncaught at startup: trading a quote mint the bot cannot size is
|
||||
worse than refusing to start.
|
||||
"""
|
||||
# Both caches, not just the program one: a mint seeded into
|
||||
# QUOTE_TOKEN_PROGRAMS but not QUOTE_DECIMALS would otherwise short-circuit
|
||||
# here and never have its decimals read at all.
|
||||
cached = _QUOTE_TOKEN_PROGRAM_CACHE.get(quote_mint)
|
||||
if cached is not None:
|
||||
if cached is not None and quote_mint in _QUOTE_DECIMALS_CACHE:
|
||||
return cached
|
||||
|
||||
account = await get_account_info(quote_mint)
|
||||
|
||||
@@ -8,9 +8,9 @@ from core.client import SolanaClient
|
||||
from core.pubkeys import (
|
||||
LAMPORTS_PER_SOL,
|
||||
TOKEN_DECIMALS,
|
||||
cached_quote_units,
|
||||
is_sol_paired,
|
||||
normalize_quote_mint,
|
||||
quote_units_per_token,
|
||||
)
|
||||
from interfaces.core import CurveManager, Platform
|
||||
from utils.idl_parser import IDLParser
|
||||
@@ -115,6 +115,11 @@ class PumpFunCurveManager(CurveManager):
|
||||
Returns:
|
||||
Current token price denominated in the curve's quote asset
|
||||
(SOL for SOL-paired coins, USDC for USDC-paired coins)
|
||||
|
||||
Raises:
|
||||
ValueError: If the curve's quote mint has not been resolved, so no
|
||||
price can be expressed. Distinct from the 0.0 below: that is a
|
||||
curve with nothing in it, this is a curve nobody can price.
|
||||
"""
|
||||
pool_state = await self.get_pool_state(pool_address)
|
||||
|
||||
@@ -123,7 +128,13 @@ class PumpFunCurveManager(CurveManager):
|
||||
|
||||
# _decode_curve_state_with_idl already scales by the quote mint's
|
||||
# decimals, so don't re-derive the price with a hardcoded 1e9 here.
|
||||
return pool_state["price_per_token"]
|
||||
price = pool_state["price_per_token"]
|
||||
if price is None:
|
||||
raise ValueError(
|
||||
f"Cannot price {pool_address}: decimals for its quote mint "
|
||||
f"{pool_state['quote_mint']} are unresolved"
|
||||
)
|
||||
return price
|
||||
|
||||
async def calculate_buy_amount_out(
|
||||
self, pool_address: Pubkey, amount_in: int
|
||||
@@ -229,7 +240,11 @@ class PumpFunCurveManager(CurveManager):
|
||||
if isinstance(raw_quote_mint, str)
|
||||
else raw_quote_mint
|
||||
)
|
||||
quote_unit = quote_units_per_token(quote_mint)
|
||||
# None for a quote mint nobody resolved. Decoding runs against every coin
|
||||
# a listener reports, most of which are never traded, so an unknown quote
|
||||
# asset leaves the quote-denominated fields unset rather than failing the
|
||||
# decode -- the caller's quote gate is what refuses the coin.
|
||||
quote_unit = cached_quote_units(quote_mint)
|
||||
|
||||
curve_data = {
|
||||
"virtual_token_reserves": decoded_curve_state.get(
|
||||
@@ -271,6 +286,9 @@ class PumpFunCurveManager(CurveManager):
|
||||
|
||||
# Price is denominated in the curve's quote asset, so scale by that
|
||||
# mint's decimals (1e9 for SOL, 1e6 for USDC) rather than assuming SOL.
|
||||
# None when those decimals are unknown: a price is unanswerable without
|
||||
# them, and a guessed power of ten would misprice the coin and inflate
|
||||
# the slippage cap the same way, compounding instead of cancelling.
|
||||
curve_data["price_per_token"] = (
|
||||
(
|
||||
curve_data["virtual_quote_reserves"]
|
||||
@@ -278,6 +296,8 @@ class PumpFunCurveManager(CurveManager):
|
||||
)
|
||||
* (10**TOKEN_DECIMALS)
|
||||
/ quote_unit
|
||||
if quote_unit is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Add convenience decimal fields
|
||||
@@ -286,6 +306,8 @@ class PumpFunCurveManager(CurveManager):
|
||||
)
|
||||
curve_data["quote_reserves_decimal"] = (
|
||||
curve_data["virtual_quote_reserves"] / quote_unit
|
||||
if quote_unit is not None
|
||||
else None
|
||||
)
|
||||
curve_data["sol_reserves_decimal"] = curve_data["quote_reserves_decimal"]
|
||||
|
||||
@@ -293,7 +315,7 @@ class PumpFunCurveManager(CurveManager):
|
||||
f"Decoded curve state: virtual_token_reserves={curve_data['virtual_token_reserves']}, "
|
||||
f"virtual_quote_reserves={curve_data['virtual_quote_reserves']}, "
|
||||
f"quote_mint={quote_mint}, "
|
||||
f"price={curve_data['price_per_token']:.8f} quote/token"
|
||||
f"price={curve_data['price_per_token']} quote/token"
|
||||
)
|
||||
|
||||
return curve_data
|
||||
|
||||
@@ -218,6 +218,9 @@ class PlatformAwareBuyer(Trader):
|
||||
# Quote asset is resolved from the curve below; start from whatever
|
||||
# the listener gave us so extreme_fast_mode has a usable default.
|
||||
quote_mint = normalize_quote_mint(token_info.quote_mint)
|
||||
# Read from the curve on the regular path, back-derived from the
|
||||
# fixed token count in extreme_fast_mode, which reads no curve.
|
||||
token_price_sol: float | None = None
|
||||
|
||||
if self.extreme_fast_mode:
|
||||
# Zero-RPC hot path: when the CreateEvent carried the canonical
|
||||
@@ -241,14 +244,11 @@ class PlatformAwareBuyer(Trader):
|
||||
|
||||
# Regular path: fetch pool state for price and flags
|
||||
pool_state = await curve_manager.get_pool_state(pool_address)
|
||||
# Validated below, after the quote gate: an unconfigured quote
|
||||
# asset leaves this None, and "no configured buy amount" is the
|
||||
# accurate reason to skip, not "invalid price".
|
||||
token_price_sol = pool_state.get("price_per_token")
|
||||
|
||||
if token_price_sol is None or token_price_sol <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid price_per_token: {token_price_sol} for pool {pool_address} "
|
||||
f"(mint: {token_info.mint}) - cannot execute buy with zero/invalid price"
|
||||
)
|
||||
|
||||
# Mayhem/cashback flags decide fee_recipient and the account-list
|
||||
# shape (cashback sells use 17 accounts, non-cashback 16).
|
||||
token_info.is_mayhem_mode = pool_state.get("is_mayhem_mode", False)
|
||||
@@ -280,6 +280,13 @@ class PlatformAwareBuyer(Trader):
|
||||
token_amount = self.extreme_fast_token_amount
|
||||
token_price_sol = quote_amount / token_amount if token_amount > 0 else 0
|
||||
else:
|
||||
if token_price_sol is None or token_price_sol <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid price_per_token: {token_price_sol} for pool "
|
||||
f"{self._get_pool_address(token_info, address_provider)} "
|
||||
f"(mint: {token_info.mint}) - cannot execute buy with "
|
||||
f"zero/invalid price"
|
||||
)
|
||||
token_amount = quote_amount / token_price_sol
|
||||
|
||||
minimum_token_amount = token_amount * (1 - self.slippage)
|
||||
|
||||
@@ -18,6 +18,11 @@ 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.
|
||||
|
||||
The bot carried the same default long after the cookbook stopped: `quote_units_per_token`
|
||||
returned 9 for an unresolved mint, and only the buy path's quote gate kept a
|
||||
mispriced trade off the chain. Checks E through G cover `src/` so the guarantee
|
||||
lives in the function rather than in two call sites.
|
||||
|
||||
Offline machine checks, no network and no funds moved:
|
||||
|
||||
A. `quote_units()` raises for a mint whose decimals are unknown, rather than
|
||||
@@ -26,7 +31,19 @@ Offline machine checks, no network and no funds moved:
|
||||
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.
|
||||
D. No cookbook script falls back to a literal decimal count for a quote mint.
|
||||
D. Nothing in `cookbook/` or `src/` falls back to a literal decimal count for
|
||||
a quote mint. The table name is matched on containing DECIMAL, not ending
|
||||
in it: the bot's default sat on `_QUOTE_DECIMALS_CACHE`, which an
|
||||
endswith test walks straight past.
|
||||
E. `quote_units_per_token()` raises for an unresolved mint, and
|
||||
`cached_quote_units()` answers None instead, for decoders that must not
|
||||
fail on a coin nobody trades.
|
||||
F. `QUOTE_TOKEN_PROGRAMS` and `QUOTE_DECIMALS` carry the same mints. A mint in
|
||||
the first alone short-circuits resolution before its decimals are read.
|
||||
G. The buy path resolves its spend amount before it sizes with a quote unit.
|
||||
The sell path deliberately does not: it liquidates a position already held,
|
||||
against a mint inherited from the buy, and gating it on a configured buy
|
||||
amount would strand that position whenever a config changed.
|
||||
|
||||
Usage:
|
||||
uv run tests/regression/verify_quote_decimals_resolved.py
|
||||
@@ -38,11 +55,15 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "cookbook" / "pumpfun" / "trade"))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
import pumpfun_instructions_v2 as pump_v2 # noqa: E402
|
||||
from solders.pubkey import Pubkey # noqa: E402
|
||||
|
||||
from core import pubkeys as core_pubkeys # noqa: E402
|
||||
|
||||
COOKBOOK = PROJECT_ROOT / "cookbook"
|
||||
SRC = PROJECT_ROOT / "src"
|
||||
|
||||
# A real Token-2022 tokenized equity: Apple xStock, 8 decimals, admitted through
|
||||
# QuoteControl rather than Global's whitelist.
|
||||
@@ -172,16 +193,23 @@ def check_scripts_resolve_before_pricing() -> None:
|
||||
|
||||
|
||||
def check_no_assumed_quote_decimals() -> None:
|
||||
"""No script may fall back to a literal decimal count for a quote mint.
|
||||
"""No script or bot module may fall back to a literal decimal count.
|
||||
|
||||
`QuoteControl` admits mints from 4 to 12 decimals, so the literal is wrong
|
||||
for most quote assets and silently so. A two-argument `.get` on a decimals
|
||||
table is the defect whatever the default is; read the mint instead.
|
||||
|
||||
The table name is matched on containing DECIMAL rather than ending in it.
|
||||
The bot's own default was `_QUOTE_DECIMALS_CACHE.get(quote_mint, 9)`, which
|
||||
an endswith test reports as clean.
|
||||
"""
|
||||
_GET_WITH_DEFAULT_ARGC = 2 # dict.get(key, default)
|
||||
|
||||
offenders = []
|
||||
for path in sorted(COOKBOOK.rglob("*.py")):
|
||||
trees = [*sorted(COOKBOOK.rglob("*.py")), *sorted(SRC.rglob("*.py"))]
|
||||
for path in trees:
|
||||
if "generated" in path.parts:
|
||||
continue
|
||||
for node in ast.walk(ast.parse(path.read_text())):
|
||||
if (
|
||||
not isinstance(node, ast.Call)
|
||||
@@ -192,7 +220,7 @@ def check_no_assumed_quote_decimals() -> None:
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "get":
|
||||
continue
|
||||
table = func.value
|
||||
if not isinstance(table, ast.Name) or not table.id.endswith("DECIMALS"):
|
||||
if not isinstance(table, ast.Name) or "DECIMAL" not in table.id.upper():
|
||||
continue
|
||||
offenders.append(
|
||||
f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}: "
|
||||
@@ -203,6 +231,96 @@ def check_no_assumed_quote_decimals() -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_core_unit_helper_raises() -> None:
|
||||
"""The bot's own unit helper must refuse an unresolved mint too.
|
||||
|
||||
The buy path's quote gate keeps an unconfigured mint off the chain, but that
|
||||
is a property of two call sites rather than of the helper. A helper that
|
||||
answers 9 for anything it has not heard of is one refactor away from
|
||||
mispricing a trade.
|
||||
"""
|
||||
unknown = Pubkey.from_string("XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp")
|
||||
assert unknown not in core_pubkeys.QUOTE_DECIMALS, "fixture mint must be unknown"
|
||||
|
||||
try:
|
||||
core_pubkeys.quote_units_per_token(unknown)
|
||||
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_per_token() answered for an unresolved mint; a wrong "
|
||||
"power of ten oversizes the trade instead of capping it"
|
||||
)
|
||||
|
||||
# The non-raising variant exists for decoders, and must not guess either.
|
||||
assert core_pubkeys.cached_quote_units(unknown) is None
|
||||
assert core_pubkeys.cached_quote_units(core_pubkeys.WSOL_MINT) == 10**9
|
||||
assert core_pubkeys.quote_units_per_token(core_pubkeys.USDC_MINT) == 10**6
|
||||
|
||||
|
||||
def check_seed_tables_agree() -> None:
|
||||
"""Every pre-seeded quote mint must carry both facts.
|
||||
|
||||
`resolve_quote_token_program` returns early once a mint's token program is
|
||||
known. A mint seeded into QUOTE_TOKEN_PROGRAMS but not QUOTE_DECIMALS would
|
||||
take that early return and never have its decimals read -- at startup, right
|
||||
behind a log line saying it resolved.
|
||||
"""
|
||||
programs = set(core_pubkeys.QUOTE_TOKEN_PROGRAMS)
|
||||
decimals = set(core_pubkeys.QUOTE_DECIMALS)
|
||||
assert programs == decimals, (
|
||||
"pre-seeded quote mints disagree:\n"
|
||||
f" token program only: {sorted(str(m) for m in programs - decimals)}\n"
|
||||
f" decimals only: {sorted(str(m) for m in decimals - programs)}"
|
||||
)
|
||||
|
||||
|
||||
def check_buy_resolves_amount_before_sizing() -> None:
|
||||
"""The buy must clear its quote gate before it scales anything.
|
||||
|
||||
Scoped to the buyer by name. The seller sizes without the gate on purpose:
|
||||
it liquidates a position already held, against a mint inherited from the
|
||||
buy, so gating it on a configured buy amount would strand that position
|
||||
whenever a config changed between the two.
|
||||
"""
|
||||
source = (SRC / "trading" / "platform_aware.py").read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
buyer = next(
|
||||
(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ClassDef) and node.name == "PlatformAwareBuyer"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert buyer is not None, "PlatformAwareBuyer not found; update this check"
|
||||
|
||||
execute = next(
|
||||
(
|
||||
node
|
||||
for node in buyer.body
|
||||
if isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef)
|
||||
and node.name == "execute"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert execute is not None, "PlatformAwareBuyer.execute not found"
|
||||
|
||||
calls = _called_names(execute)
|
||||
sizing = [ln for name, ln in calls if name == "quote_units_per_token"]
|
||||
gating = [ln for name, ln in calls if name == "_resolve_quote_amount"]
|
||||
|
||||
assert sizing, "the buy no longer sizes with a quote unit; update this check"
|
||||
assert gating, "the buy no longer resolves a quote amount; update this check"
|
||||
assert min(gating) < min(sizing), (
|
||||
f"the buy sizes at line {min(sizing)} but resolves its quote amount at "
|
||||
f"line {min(gating)}; an unconfigured quote asset must be refused first"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run every check and report."""
|
||||
print("=" * 72)
|
||||
@@ -214,6 +332,9 @@ async def main() -> None:
|
||||
("resolution caches decimals in one read", check_resolution_caches_decimals),
|
||||
("every script resolves before pricing", check_scripts_resolve_before_pricing),
|
||||
("no script assumes a quote mint's decimals", check_no_assumed_quote_decimals),
|
||||
("the bot's unit helper raises too", check_core_unit_helper_raises),
|
||||
("pre-seeded quote tables agree", check_seed_tables_agree),
|
||||
("the buy gates before it sizes", check_buy_resolves_amount_before_sizing),
|
||||
]
|
||||
for label, fn in checks:
|
||||
result = fn()
|
||||
|
||||
Reference in New Issue
Block a user