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:
Anton Sauchyk
2026-09-24 14:43:54 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent b04a375506
commit 01edbbf8c4
6 changed files with 214 additions and 23 deletions
+7 -3
View File
@@ -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),
+1 -1
View File
@@ -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
View File
@@ -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)
+26 -4
View File
@@ -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
+13 -6
View File
@@ -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()