feat(trading): zero-RPC extreme-fast buys and listener race hardening (#170) (#183)

Extreme-fast mode submits with no RPC calls between detection and buy
when the listener parsed the on-chain CreateEvent (geyser/logs/blocks):
TokenInfo.state_from_event carries the canonical creator, mayhem/cashback
flags and quote_mint, so the pre-buy curve refresh is skipped. The geyser
listener now delegates to the event-first parser instead of inlining
instruction decoding, which also catches create_v2 transactions that omit
the trailing is_cashback_enabled byte.

Listeners without event data (pumpportal) keep the refresh, hardened per
issue #170: bonding curve derived from the mint instead of the payload's
bondingCurveKey, curve and mint read in one slot-consistent
getMultipleAccounts (correcting the guessed token program), and an
unreadable curve now skips the token within trade.curve_refresh_budget
instead of submitting a buy built from guessed accounts.

Machine-checked by verify_extreme_fast_zero_rpc.py and
verify_pumpportal_buy_path.py; validated live via simulate_bot_buy_path
on both geyser and pumpportal paths.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-29 18:29:03 +02:00
committed by GitHub
parent bc59a2257d
commit 7bc6f25508
19 changed files with 1239 additions and 86 deletions
+39
View File
@@ -118,6 +118,45 @@ Run all three after any pump.fun program upgrade. The simulations report
`unitsConsumed`; use it to retune `get_buy_compute_unit_limit` /
`get_sell_compute_unit_limit` in `platforms/pumpfun/instruction_builder.py`.
### Verifying the listener-to-buy path (issue #170)
```bash
# Offline: bonding curve derived from the mint (payload bondingCurveKey not
# trusted), unreadable curve skips the buy instead of submitting with guessed
# accounts, curve+mint read in one slot-consistent batch
uv run learning-examples/verify_pumpportal_buy_path.py
# Offline: extreme_fast_mode stays at ZERO RPC calls between detection and
# submission for CreateEvent-sourced tokens; pumpportal still refreshes
uv run learning-examples/verify_extreme_fast_zero_rpc.py
```
Fast listeners (pumpportal especially, but geyser too) can announce a token
seconds before every node behind a load-balanced RPC endpoint can read its
accounts — two back-to-back reads on the same endpoint may be served from
nodes at different slots. `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 on-chain
with `NotAuthorized` (6000), `ConstraintSeeds` (2006) or, on letsbonk,
`AccountNotInitialized` (3012). The sell path deliberately keeps the opposite
fallback — proceed with cached values — since skipping a sell strands the
position.
The refresh is skipped entirely — extreme_fast_mode's zero-RPC contract —
when `TokenInfo.state_from_event` is set, i.e. the listener parsed the
**CreateEvent** (geyser/logs/blocks), which carries the canonical creator,
mayhem/cashback flags and quote_mint. Instruction `args.creator` is
user-supplied and post-2026-04-28 may differ from the canonical `BC.creator`
(PFEE PDA delegation), so instruction-parsed TokenInfo deliberately does
**not** set the flag; the geyser parser prefers `meta.log_messages` over
instruction decoding for exactly this reason. `trade.trust_create_event:
false` is the escape hatch back to always-refresh. PumpPortal payloads carry
none of these fields and always refresh. Related pitfall: the strict IDL
instruction decoder rejects `create_v2` transactions that omit the trailing
`is_cashback_enabled` OptionBool (a legal wire form), so the instruction path
alone silently misses those coins — one more reason the log/event path is
preferred everywhere.
### Verifying transaction-status handling
```bash
+29 -1
View File
@@ -90,13 +90,41 @@ Logs land in `logs/{bot_name}_{timestamp}.log`.
The YAML files are commented inline. The sections that matter most:
- **`trade`** — `buy_amount` (in SOL), slippage, `exit_strategy` (`time_based`, `tp_sl`, `manual`), and `extreme_fast_mode`, which skips the bonding-curve price check and buys a fixed token amount instead. Faster, less precise.
- **`trade`** — `buy_amount` (in SOL), slippage, `exit_strategy` (`time_based`, `tp_sl`, `manual`), and `extreme_fast_mode`, which skips the bonding-curve price check and buys a fixed token amount instead. Faster, less precise. See [Extreme fast mode](#extreme-fast-mode-zero-rpc-buys) for the zero-RPC behavior and its two knobs, `trust_create_event` and `curve_refresh_budget`.
- **`priority_fees`** — fixed or dynamic. Dynamic costs an extra RPC call, which slows the buy.
- **`filters`** — `listener_type`, `max_token_age`, name/creator matching, `marry_mode` (buy only, never sell), `yolo_mode` (trade continuously).
- **`retries`** — attempts and the wait windows around creation, buy, and the next token.
- **`cleanup`** — when to close leftover token accounts: `disabled`, `on_fail`, `after_sell`, `post_session`.
- **`node.max_rps`** — cap requests per second to match your provider's plan.
### Extreme fast mode: zero-RPC buys
With `extreme_fast_mode: true` the bot buys a fixed token amount
(`extreme_fast_token_amount`) instead of fetching the curve price first. For
tokens detected through the on-chain **CreateEvent** — the `geyser`, `logs`
and `blocks` listeners — the buy is built entirely from the event: the
canonical creator, mayhem/cashback flags and quote mint are all in it, so
**no RPC call happens between detecting the token and submitting the buy**.
That is the point of the mode; a single account read costs ~4050 ms even on
a good endpoint, a tenth of a slot.
The `pumpportal` listener can't do this — its payload carries none of those
fields — so it performs one batched account read (bonding curve + mint in a
single slot-consistent `getMultipleAccounts`) before buying. If the curve
isn't readable within `trade.curve_refresh_budget` seconds (default 2.0),
the token is **skipped**: a buy built from guessed accounts reverts on-chain
with `NotAuthorized` (6000) or `ConstraintSeeds` (2006) and still costs the
fee. The same skip applies to any token whose event data was incomplete.
`trade.trust_create_event: false` turns the zero-RPC path off and forces the
pre-buy read for every listener — the safe fallback if pump.fun changes what
the CreateEvent carries.
Machine checks: `learning-examples/verify_extreme_fast_zero_rpc.py` (the
zero-RPC contract per listener) and
`learning-examples/verify_pumpportal_buy_path.py` (the refresh/skip path).
Neither moves funds.
### Non-SOL quote assets
pump.fun supports quote assets other than SOL, USDC first. Amounts are in that mint's own whole units, so `usdc: 1.0` is one USDC and is **not** comparable to `buy_amount`:
+18
View File
@@ -47,9 +47,27 @@ trade:
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
# For geyser/logs/blocks listeners the buy is built entirely from the on-chain
# CreateEvent — ZERO RPC calls between detection and submission (see
# trust_create_event below). pumpportal payloads lack the needed fields, so
# that listener does one batched read first (see curve_refresh_budget).
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Pre-buy curve refresh (issue #170). The buyer re-reads the bonding curve /
# pool at `processed` before building the buy so fee_recipient, creator_vault,
# quote_mint and the token program are current. If the account is not readable
# within this budget (seconds), the token is SKIPPED instead of submitting a
# buy built from listener-guessed defaults, which tends to revert on-chain.
curve_refresh_budget: 2.0
# When the create event carried the canonical creator, mayhem/cashback flags
# and quote_mint (geyser/logs/blocks listeners), skip the pre-buy read
# entirely: extreme_fast_mode then submits with ZERO RPC calls after
# detection. pumpportal events never carry these, so they always refresh.
# Set false to force the refresh for every listener.
trust_create_event: true
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
+18
View File
@@ -37,9 +37,27 @@ trade:
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
# For geyser/logs/blocks listeners the buy is built entirely from the on-chain
# CreateEvent — ZERO RPC calls between detection and submission (see
# trust_create_event below). pumpportal payloads lack the needed fields, so
# that listener does one batched read first (see curve_refresh_budget).
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Pre-buy curve refresh (issue #170). The buyer re-reads the bonding curve /
# pool at `processed` before building the buy so fee_recipient, creator_vault,
# quote_mint and the token program are current. If the account is not readable
# within this budget (seconds), the token is SKIPPED instead of submitting a
# buy built from listener-guessed defaults, which tends to revert on-chain.
curve_refresh_budget: 2.0
# When the create event carried the canonical creator, mayhem/cashback flags
# and quote_mint (geyser/logs/blocks listeners), skip the pre-buy read
# entirely: extreme_fast_mode then submits with ZERO RPC calls after
# detection. pumpportal events never carry these, so they always refresh.
# Set false to force the refresh for every listener.
trust_create_event: true
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
+18
View File
@@ -37,9 +37,27 @@ trade:
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
# For geyser/logs/blocks listeners the buy is built entirely from the on-chain
# CreateEvent — ZERO RPC calls between detection and submission (see
# trust_create_event below). pumpportal payloads lack the needed fields, so
# that listener does one batched read first (see curve_refresh_budget).
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Pre-buy curve refresh (issue #170). The buyer re-reads the bonding curve /
# pool at `processed` before building the buy so fee_recipient, creator_vault,
# quote_mint and the token program are current. If the account is not readable
# within this budget (seconds), the token is SKIPPED instead of submitting a
# buy built from listener-guessed defaults, which tends to revert on-chain.
curve_refresh_budget: 2.0
# When the create event carried the canonical creator, mayhem/cashback flags
# and quote_mint (geyser/logs/blocks listeners), skip the pre-buy read
# entirely: extreme_fast_mode then submits with ZERO RPC calls after
# detection. pumpportal events never carry these, so they always refresh.
# Set false to force the refresh for every listener.
trust_create_event: true
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
+18
View File
@@ -35,9 +35,27 @@ trade:
# EXTREME FAST mode configuration
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
# The bot buys the specified number of tokens directly, making the process faster but less precise.
# For geyser/logs/blocks listeners the buy is built entirely from the on-chain
# CreateEvent — ZERO RPC calls between detection and submission (see
# trust_create_event below). pumpportal payloads lack the needed fields, so
# that listener does one batched read first (see curve_refresh_budget).
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# Pre-buy curve refresh (issue #170). The buyer re-reads the bonding curve /
# pool at `processed` before building the buy so fee_recipient, creator_vault,
# quote_mint and the token program are current. If the account is not readable
# within this budget (seconds), the token is SKIPPED instead of submitting a
# buy built from listener-guessed defaults, which tends to revert on-chain.
curve_refresh_budget: 2.0
# When the create event carried the canonical creator, mayhem/cashback flags
# and quote_mint (geyser/logs/blocks listeners), skip the pre-buy read
# entirely: extreme_fast_mode then submits with ZERO RPC calls after
# detection. pumpportal events never carry these, so they always refresh.
# Set false to force the refresh for every listener.
trust_create_event: true
# Priority fee configuration
# Manage transaction speed and cost on the Solana network.
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
@@ -166,6 +166,7 @@ async def main() -> int:
print(f"quote_mint (from CreateEvent): {token_info.quote_mint}")
print(f"token program: {token_info.token_program_id}")
print(f"mayhem={token_info.is_mayhem_mode} cashback={token_info.is_cashback_coin}")
print(f"state_from_event={token_info.state_from_event} (True = zero-RPC buy path)")
print(f"extreme_fast_mode={extreme_fast}\n")
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
@@ -0,0 +1,432 @@
"""Verify extreme_fast_mode makes zero RPC calls for event-sourced tokens.
extreme_fast_mode's contract is that nothing sits between detecting a token
and submitting the buy — no reads, no price fetch. The pump.fun CreateEvent
carries the canonical creator (instruction args.creator is user-supplied and
may differ post-2026-04-28), mayhem/cashback flags and quote_mint, so a
TokenInfo built from it needs no pre-buy curve refresh. PumpPortal payloads
carry none of that, so they keep the refresh.
Offline machine checks, no network and no funds moved:
1. The logs parser marks CreateEvent-sourced TokenInfo as state_from_event.
2. The instruction parser stays conservative (args.creator not canonical).
3. The geyser parser prefers the CreateEvent from meta.log_messages.
4. The geyser LISTENER delegates to that parser (it used to inline
instruction decoding, bypassing the event path).
5. The block parser rides the same CreateEvent logs.
6. The pumpportal processor never sets state_from_event.
7. An event-sourced buy submits with ZERO curve-manager/RPC calls.
8. A pumpportal-sourced buy still refreshes from chain.
9. trade.trust_create_event=false forces the refresh even for event data.
Usage:
uv run learning-examples/verify_extreme_fast_zero_rpc.py
"""
import asyncio
import base64
import json
import sys
from pathlib import Path
from types import SimpleNamespace
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from solders.transaction import VersionedTransaction # noqa: E402
from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms.pumpfun.address_provider import PumpFunAddressProvider # noqa: E402
from platforms.pumpfun.event_parser import PumpFunEventParser # noqa: E402
from platforms.pumpfun.pumpportal_processor import ( # noqa: E402
PumpFunPumpPortalProcessor,
)
from trading import platform_aware # noqa: E402
from trading.platform_aware import PlatformAwareBuyer # noqa: E402
from utils.idl_manager import get_idl_manager # noqa: E402
FIXTURE = (
PROJECT_ROOT
/ "learning-examples"
/ "blocksubscribe-transactions"
/ "raw_create_tx_from_blocksubscribe.json"
)
PROVIDER = PumpFunAddressProvider()
TRADER = Pubkey.from_string("11111111111111111111111111111112")
def _event_parser() -> PumpFunEventParser:
"""Real pump.fun event parser with the vendored IDL, no RPC client needed."""
return PumpFunEventParser(
idl_parser=get_idl_manager().get_parser(Platform.PUMP_FUN)
)
def _fixture() -> dict:
return json.loads(FIXTURE.read_text())
def _event_sourced_token_info() -> TokenInfo:
"""Parse the fixture's CreateEvent through the real logs parser."""
parser = _event_parser()
return parser.parse_token_creation_from_logs(
_fixture()["meta"]["logMessages"], signature="fixture"
)
class _StubClient:
"""Records submissions and account reads; never touches the network."""
def __init__(self) -> None:
self.sent: list = []
self.reads = 0
async def build_and_send_transaction(
self, instructions: list, *_args: object, **_kwargs: object
) -> str:
self.sent.append(instructions)
return "STUB_SIGNATURE"
async def confirm_transaction(self, _signature: str, **_kwargs: object) -> bool:
return False
async def get_account_info(self, *_args: object, **_kwargs: object) -> None:
self.reads += 1
raise ValueError("unexpected RPC read in zero-RPC path") # noqa: TRY003
async def get_multiple_accounts(self, *_args: object, **_kwargs: object) -> None:
self.reads += 1
raise ValueError("unexpected RPC read in zero-RPC path") # noqa: TRY003
class _CountingCurveManager:
"""Counts refresh calls; returns benign state."""
def __init__(self) -> None:
self.calls = 0
async def get_pool_state_and_token_program(
self,
_pool: Pubkey,
_mint: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> tuple[dict, Pubkey]:
self.calls += 1
state = {
"creator": str(TRADER),
"is_mayhem_mode": False,
"is_cashback_coin": False,
"quote_mint": WSOL_MINT,
}
return state, SystemAddresses.TOKEN_2022_PROGRAM
async def get_pool_state(
self,
_pool: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> dict:
self.calls += 1
return {
"creator": str(TRADER),
"is_mayhem_mode": False,
"is_cashback_coin": False,
"quote_mint": WSOL_MINT,
}
def _stub_implementations(curve_manager: object) -> SimpleNamespace:
async def build_buy_instruction(*_args: object, **_kwargs: object) -> list[str]:
return ["stub-instruction"]
instruction_builder = SimpleNamespace(
build_buy_instruction=build_buy_instruction,
get_required_accounts_for_buy=lambda *_a, **_k: [],
get_buy_compute_unit_limit=lambda _override: 100_000,
)
return SimpleNamespace(
address_provider=PROVIDER,
instruction_builder=instruction_builder,
curve_manager=curve_manager,
)
def _make_buyer(client: _StubClient, **kwargs: object) -> PlatformAwareBuyer:
async def no_fee(_accounts: list) -> None:
return None
fee_manager = SimpleNamespace(calculate_priority_fee=no_fee)
return PlatformAwareBuyer(
client,
SimpleNamespace(pubkey=TRADER, keypair=None),
fee_manager,
amount=0.0001,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=20,
extreme_fast_mode=True,
**kwargs,
)
def _run_buy(
token_info: TokenInfo, curve_manager: object, **buyer_kwargs: object
) -> tuple[_StubClient, object]:
client = _StubClient()
buyer = _make_buyer(client, **buyer_kwargs)
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
curve_manager
)
result = asyncio.run(buyer.execute(token_info))
return client, result
def check_logs_parser_marks_event_state() -> bool:
"""CreateEvent-sourced TokenInfo carries everything -> flag set."""
token_info = _event_sourced_token_info()
if token_info is None:
print(" fixture logs did not parse into a TokenInfo")
return False
ok = (
getattr(token_info, "state_from_event", False) is True
and token_info.quote_mint is not None
and token_info.creator is not None
)
if not ok:
print(
f" state_from_event={getattr(token_info, 'state_from_event', None)} "
f"quote_mint={token_info.quote_mint} creator={token_info.creator}"
)
return ok
def check_instruction_parser_stays_conservative() -> bool:
"""args.creator is user-supplied, not canonical -> flag must stay unset."""
fixture = _fixture()
raw = base64.b64decode(fixture["transaction"][0])
tx = VersionedTransaction.from_bytes(raw)
msg = tx.message
account_keys = [bytes(k) for k in msg.account_keys]
parser = _event_parser()
for ix in msg.instructions:
data = bytes(ix.data)
# The fixture's create_v2 omits the trailing is_cashback_enabled
# OptionBool (a legal wire form the strict IDL decoder rejects — see
# the "decode trailing args defensively" gotcha in CLAUDE.md), so
# append the byte to exercise the parser's flag behaviour.
if data.startswith(bytes.fromhex("d6904cec5f8b31b4")):
data += b"\x00"
token_info = parser.parse_token_creation_from_instruction(
data, list(ix.accounts), account_keys
)
if token_info is not None:
ok = getattr(token_info, "state_from_event", False) is False
if not ok:
print(" instruction-sourced TokenInfo must not set the flag")
return ok
print(" fixture create instruction did not parse")
return False
def check_geyser_parser_prefers_event_logs() -> bool:
"""Geyser meta carries log_messages; the CreateEvent there is canonical."""
logs = _fixture()["meta"]["logMessages"]
stub = SimpleNamespace(
transaction=SimpleNamespace(
transaction=SimpleNamespace(
transaction=SimpleNamespace(
message=SimpleNamespace(instructions=[], account_keys=[])
),
meta=SimpleNamespace(log_messages=logs),
)
)
)
parser = _event_parser()
token_info = parser.parse_token_creation_from_geyser(stub)
ok = (
token_info is not None
and getattr(token_info, "state_from_event", False) is True
)
if not ok:
print(f" geyser parse returned {token_info}")
return ok
def check_geyser_listener_delegates_to_parser() -> bool:
"""The listener must route updates through the event-first parser.
Live run showed state_from_event=False on a geyser token: the listener
inlined instruction parsing and never reached the parser's log-preferring
geyser method.
"""
# Imported here: the listener module pulls in grpc, which the other
# checks don't need.
from monitoring.universal_geyser_listener import ( # noqa: PLC0415
UniversalGeyserListener,
)
listener = UniversalGeyserListener(
geyser_endpoint="dummy:443",
geyser_api_token="dummy", # noqa: S106 - offline stub, never connects
geyser_auth_type="x-token",
platforms=[Platform.PUMP_FUN],
)
logs = _fixture()["meta"]["logMessages"]
update = SimpleNamespace(
HasField=lambda field: field == "transaction",
transaction=SimpleNamespace(
transaction=SimpleNamespace(
transaction=SimpleNamespace(
message=SimpleNamespace(instructions=[], account_keys=[])
),
meta=SimpleNamespace(log_messages=logs),
)
),
)
token_info = asyncio.run(listener._process_update(update)) # noqa: SLF001
ok = (
token_info is not None
and getattr(token_info, "state_from_event", False) is True
)
if not ok:
print(f" listener _process_update returned {token_info}")
return ok
def check_block_parser_marks_event_state() -> bool:
"""The block listener's parse path also rides the CreateEvent logs."""
parser = _event_parser()
token_info = parser.parse_token_creation_from_block({"transactions": [_fixture()]})
ok = (
token_info is not None
and getattr(token_info, "state_from_event", False) is True
)
if not ok:
print(f" block parse returned {token_info}")
return ok
def check_pumpportal_never_sets_flag() -> bool:
"""PumpPortal payloads carry no curve state -> flag must stay unset."""
mint = Pubkey.from_string("So11111111111111111111111111111111111111112")
token_info = PumpFunPumpPortalProcessor().process_token_data(
{
"name": "T",
"symbol": "T",
"mint": str(mint),
"bondingCurveKey": str(PROVIDER.derive_pool_address(mint)),
"traderPublicKey": str(TRADER),
"uri": "",
"pool": "pump",
}
)
ok = (
token_info is not None
and getattr(token_info, "state_from_event", False) is False
)
if not ok:
print(" pumpportal TokenInfo must not set state_from_event")
return ok
def check_event_sourced_buy_is_zero_rpc() -> bool:
"""The killer feature: detection -> submission with no reads at all."""
token_info = _event_sourced_token_info()
if token_info is None:
print(" fixture logs did not parse into a TokenInfo")
return False
curve_manager = _CountingCurveManager()
client, _result = _run_buy(token_info, curve_manager)
ok = curve_manager.calls == 0 and client.reads == 0 and len(client.sent) == 1
if not ok:
print(
f" curve_manager.calls={curve_manager.calls} "
f"client.reads={client.reads} submissions={len(client.sent)}"
)
return ok
def check_pumpportal_buy_still_refreshes() -> bool:
"""Listener-guessed data must still be refreshed from chain."""
mint = Pubkey.from_string("So11111111111111111111111111111111111111112")
bonding_curve = PROVIDER.derive_pool_address(mint)
token_info = TokenInfo(
name="T",
symbol="T",
uri="",
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=PROVIDER.derive_associated_bonding_curve(
mint, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM
),
user=TRADER,
creator=TRADER,
creator_vault=PROVIDER.derive_creator_vault(TRADER),
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
)
curve_manager = _CountingCurveManager()
client, _result = _run_buy(token_info, curve_manager)
ok = curve_manager.calls >= 1 and len(client.sent) == 1
if not ok:
print(
f" curve_manager.calls={curve_manager.calls} "
f"submissions={len(client.sent)}"
)
return ok
def check_trust_flag_forces_refresh() -> bool:
"""trust_create_event=false is the escape hatch back to always-refresh."""
token_info = _event_sourced_token_info()
if token_info is None:
print(" fixture logs did not parse into a TokenInfo")
return False
curve_manager = _CountingCurveManager()
_client, _result = _run_buy(token_info, curve_manager, trust_create_event=False)
ok = curve_manager.calls >= 1
if not ok:
print(f" curve_manager.calls={curve_manager.calls} (expected >=1)")
return ok
def main() -> int:
checks = [
("logs parser marks CreateEvent state", check_logs_parser_marks_event_state),
(
"instruction parser stays conservative",
check_instruction_parser_stays_conservative,
),
(
"geyser parser prefers CreateEvent logs",
check_geyser_parser_prefers_event_logs,
),
(
"geyser listener delegates to event-first parser",
check_geyser_listener_delegates_to_parser,
),
("block parser marks CreateEvent state", check_block_parser_marks_event_state),
("pumpportal never sets state_from_event", check_pumpportal_never_sets_flag),
("event-sourced buy makes zero RPC calls", check_event_sourced_buy_is_zero_rpc),
("pumpportal buy still refreshes", check_pumpportal_buy_still_refreshes),
("trust_create_event=false forces refresh", check_trust_flag_forces_refresh),
]
failed = 0
for label, check in checks:
try:
ok = check()
except Exception as error: # noqa: BLE001 - report and continue
print(f"FAIL {label}: {type(error).__name__}: {error}")
failed += 1
continue
print(f"{'PASS' if ok else 'FAIL'} {label}")
failed += 0 if ok else 1
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,345 @@
"""Verify the pumpportal-sourced buy path hardening from issue #170.
Offline machine checks, no network and no funds moved:
A. The pumpportal processor derives bonding_curve from the mint instead of
trusting the payload's bondingCurveKey (observed stale once in #170).
B. In extreme_fast_mode, a buy is SKIPPED when the curve state cannot be
read within the refresh budget, instead of submitting a buy built from
listener-guessed defaults (the "racing a doomed buy" failure).
C. The curve refresh reads curve + mint in one slot-consistent
getMultipleAccounts round trip and corrects token_program_id (pumpportal
cannot know it and guesses Token-2022; legacy coins are SPL Token).
Usage:
uv run learning-examples/verify_pumpportal_buy_path.py
"""
import asyncio
import json
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms.pumpfun.address_provider import PumpFunAddressProvider # noqa: E402
from platforms.pumpfun.curve_manager import PumpFunCurveManager # noqa: E402
from platforms.pumpfun.pumpportal_processor import ( # noqa: E402
PumpFunPumpPortalProcessor,
)
from trading import platform_aware # noqa: E402
from trading.platform_aware import PlatformAwareBuyer # noqa: E402
from utils.idl_manager import get_idl_manager # noqa: E402
MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
TRADER = Pubkey.from_string("11111111111111111111111111111112")
WRONG_BC = Pubkey.from_string("Vote111111111111111111111111111111111111111")
PROVIDER = PumpFunAddressProvider()
def _fabricated_curve_bytes(creator: Pubkey, *, is_mayhem: bool) -> bytes:
"""Build a 151-byte BondingCurve account image matching the IDL layout."""
idl = json.loads((PROJECT_ROOT / "idl" / "pump_fun_idl.json").read_text())
disc = next(
bytes(a["discriminator"])
for a in idl["accounts"]
if a["name"] == "BondingCurve"
)
reserves = struct.pack(
"<QQQQQ",
1_000_000_000_000, # virtual_token_reserves
30_000_000_000, # virtual_quote_reserves
800_000_000_000, # real_token_reserves
0, # real_quote_reserves
1_000_000_000_000, # token_total_supply
)
return (
disc
+ reserves
+ b"\x00" # complete
+ bytes(creator)
+ (b"\x01" if is_mayhem else b"\x00") # is_mayhem_mode
+ b"\x00" # is_cashback_coin
+ bytes(32) # quote_mint = Pubkey::default() (SOL-paired)
+ bytes(36) # reserved padding
)
def _pumpportal_token_info(**overrides: object) -> TokenInfo:
"""TokenInfo shaped like the pumpportal processor's output."""
bonding_curve = PROVIDER.derive_pool_address(MINT)
defaults: dict = {
"name": "T",
"symbol": "T",
"uri": "",
"mint": MINT,
"platform": Platform.PUMP_FUN,
"bonding_curve": bonding_curve,
"associated_bonding_curve": PROVIDER.derive_associated_bonding_curve(
MINT, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM
),
"user": TRADER,
"creator": TRADER,
"creator_vault": PROVIDER.derive_creator_vault(TRADER),
"token_program_id": SystemAddresses.TOKEN_2022_PROGRAM,
}
defaults.update(overrides)
return TokenInfo(**defaults)
class _StubClient:
"""Records submissions; never touches the network."""
def __init__(self) -> None:
self.sent: list = []
async def build_and_send_transaction(
self, instructions: list, *_args: object, **_kwargs: object
) -> str:
self.sent.append(instructions)
return "STUB_SIGNATURE"
async def confirm_transaction(self, _signature: str, **_kwargs: object) -> bool:
return False
def _stub_implementations(curve_manager: object) -> SimpleNamespace:
async def build_buy_instruction(*_args: object, **_kwargs: object) -> list[str]:
return ["stub-instruction"]
instruction_builder = SimpleNamespace(
build_buy_instruction=build_buy_instruction,
get_required_accounts_for_buy=lambda *_a, **_k: [],
get_buy_compute_unit_limit=lambda _override: 100_000,
)
return SimpleNamespace(
address_provider=PROVIDER,
instruction_builder=instruction_builder,
curve_manager=curve_manager,
)
def _make_buyer(client: _StubClient, **kwargs: float) -> PlatformAwareBuyer:
async def no_fee(_accounts: list) -> None:
return None
fee_manager = SimpleNamespace(calculate_priority_fee=no_fee)
return PlatformAwareBuyer(
client,
SimpleNamespace(pubkey=TRADER, keypair=None),
fee_manager,
amount=0.0001,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=20,
extreme_fast_mode=True,
**kwargs,
)
def check_a_processor_derives_bonding_curve() -> bool:
"""A: payload bondingCurveKey is not trusted; the PDA is derived."""
token_data = {
"name": "T",
"symbol": "T",
"mint": str(MINT),
"bondingCurveKey": str(WRONG_BC), # deliberately stale/wrong
"traderPublicKey": str(TRADER),
"uri": "",
"pool": "pump",
}
token_info = PumpFunPumpPortalProcessor().process_token_data(token_data)
expected = PROVIDER.derive_pool_address(MINT)
ok = token_info is not None and token_info.bonding_curve == expected
if not ok:
got = token_info.bonding_curve if token_info else None
print(f" expected derived BC {expected}, got {got}")
return ok
def check_b_skips_when_curve_unreadable() -> bool:
"""B: refresh failure -> buy skipped, nothing submitted."""
class NeverReadable:
async def get_pool_state(
self,
_pool: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> dict:
raise ValueError("Account not found") # noqa: TRY003
client = _StubClient()
buyer = _make_buyer(client, curve_refresh_budget=0.3)
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
NeverReadable()
)
result = asyncio.run(buyer.execute(_pumpportal_token_info()))
ok = not result.success and not client.sent
if not ok:
print(f" success={result.success} submissions={len(client.sent)}")
return ok
def check_b_still_buys_when_curve_readable() -> bool:
"""B guard: a readable curve still reaches submission."""
class Readable:
async def get_pool_state(
self,
_pool: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> dict:
return {
"creator": str(TRADER),
"is_mayhem_mode": False,
"is_cashback_coin": False,
"quote_mint": WSOL_MINT,
}
client = _StubClient()
buyer = _make_buyer(client, curve_refresh_budget=0.3)
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
Readable()
)
asyncio.run(buyer.execute(_pumpportal_token_info()))
ok = len(client.sent) == 1
if not ok:
print(f" submissions={len(client.sent)} (expected 1)")
return ok
def check_c_curve_manager_batch_read() -> bool:
"""C: curve manager reads curve + mint owner in one batch call."""
creator = TRADER
curve_bytes = _fabricated_curve_bytes(creator, is_mayhem=True)
class BatchClient:
def __init__(self) -> None:
self.batch_calls = 0
async def get_multiple_accounts(
self,
pubkeys: list[Pubkey],
commitment: str | None = None, # noqa: ARG002
) -> list[SimpleNamespace]:
self.batch_calls += 1
if len(pubkeys) != 2: # noqa: PLR2004
raise ValueError("expected [curve, mint]") # noqa: TRY003
return [
SimpleNamespace(data=curve_bytes, owner=PROVIDER.program_id),
SimpleNamespace(data=b"", owner=SystemAddresses.TOKEN_PROGRAM),
]
client = BatchClient()
manager = PumpFunCurveManager(
client, get_idl_manager().get_parser(Platform.PUMP_FUN)
)
if not hasattr(manager, "get_pool_state_and_token_program"):
print(" PumpFunCurveManager.get_pool_state_and_token_program missing")
return False
state, token_program = asyncio.run(
manager.get_pool_state_and_token_program(
PROVIDER.derive_pool_address(MINT), MINT, commitment="processed"
)
)
ok = (
client.batch_calls == 1
and token_program == SystemAddresses.TOKEN_PROGRAM
and state.get("is_mayhem_mode") is True
and str(state.get("creator")) == str(creator)
)
if not ok:
print(
f" batch_calls={client.batch_calls} token_program={token_program} state={state}"
)
return ok
def check_c_buyer_corrects_token_program() -> bool:
"""C: buyer applies the batch-read token program and re-derives the ATA."""
class BatchCurveManager:
async def get_pool_state_and_token_program(
self,
_pool: Pubkey,
_mint: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> tuple[dict, Pubkey]:
state = {
"creator": str(TRADER),
"is_mayhem_mode": False,
"is_cashback_coin": False,
"quote_mint": WSOL_MINT,
}
return state, SystemAddresses.TOKEN_PROGRAM
async def get_pool_state(
self,
_pool: Pubkey,
commitment: str | None = None, # noqa: ARG002
) -> dict:
raise AssertionError("batch method should be preferred") # noqa: TRY003
client = _StubClient()
buyer = _make_buyer(client, curve_refresh_budget=0.3)
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
BatchCurveManager()
)
token_info = _pumpportal_token_info()
asyncio.run(buyer.execute(token_info))
expected_ata = PROVIDER.derive_associated_bonding_curve(
MINT, token_info.bonding_curve, SystemAddresses.TOKEN_PROGRAM
)
ok = (
token_info.token_program_id == SystemAddresses.TOKEN_PROGRAM
and token_info.associated_bonding_curve == expected_ata
)
if not ok:
print(
f" token_program_id={token_info.token_program_id} "
f"ata={token_info.associated_bonding_curve} (expected {expected_ata})"
)
return ok
def main() -> int:
checks = [
(
"A: processor derives bonding_curve from mint",
check_a_processor_derives_bonding_curve,
),
("B: unreadable curve -> buy skipped", check_b_skips_when_curve_unreadable),
("B: readable curve -> buy proceeds", check_b_still_buys_when_curve_readable),
(
"C: curve manager batch-reads curve + mint owner",
check_c_curve_manager_batch_read,
),
(
"C: buyer corrects token_program_id and ATA",
check_c_buyer_corrects_token_program,
),
]
failed = 0
for label, check in checks:
try:
ok = check()
except Exception as error: # noqa: BLE001 - report and continue
print(f"FAIL {label}: {type(error).__name__}: {error}")
failed += 1
continue
print(f"{'PASS' if ok else 'FAIL'} {label}")
failed += 0 if ok else 1
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
@@ -144,6 +144,9 @@ async def check_examples_call_a_status_check() -> None:
Path(__file__).name,
# stubs confirm_transaction out; never sends a transaction
"simulate_bot_buy_path.py",
# offline checks with a stub client; never sends a transaction
"verify_pumpportal_buy_path.py",
"verify_extreme_fast_zero_rpc.py",
# uses the bot's SolanaClient wrapper, which folds meta.err into its
# return value; the boolean is read at the call site
"cleanup_accounts.py",
+2
View File
@@ -98,6 +98,8 @@ async def start_bot(config_path: str):
# Extreme fast mode settings
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
curve_refresh_budget=cfg["trade"].get("curve_refresh_budget", 2.0),
trust_create_event=cfg["trade"].get("trust_create_event", True),
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts=cfg["trade"].get("quote_amounts"),
allowed_quote_mints=cfg["filters"].get("allowed_quote_mints"),
+25 -1
View File
@@ -175,6 +175,30 @@ class SolanaClient:
raise ValueError(f"Account {pubkey} not found")
return response.value
async def get_multiple_accounts(
self, pubkeys: list[Pubkey], commitment: str | None = None
) -> list[Any]:
"""Get several accounts in one slot-consistent RPC round trip.
A single getMultipleAccounts response is served by one node at one
slot, unlike back-to-back get_account_info calls which a load-balanced
endpoint may serve from nodes seconds apart (issue #170).
Args:
pubkeys: Public keys of the accounts
commitment: Optional commitment override (default "confirmed")
Returns:
One entry per pubkey, in order; None for accounts that don't exist
"""
await self._rate_limiter.acquire()
client = await self.get_client()
kwargs: dict[str, Any] = {"encoding": "base64"}
if commitment is not None:
kwargs["commitment"] = commitment
response = await client.get_multiple_accounts(pubkeys, **kwargs)
return list(response.value)
async def get_token_account_balance(
self, token_account: Pubkey, commitment: str = "confirmed"
) -> int:
@@ -630,7 +654,7 @@ class SolanaClient:
# timeout fires, and it is not an aiohttp.ClientError — without it
# here every RPC timeout propagated out of post_rpc unretried and
# crashed the caller with an exception whose str() is empty.
except (aiohttp.ClientError, asyncio.TimeoutError):
except (TimeoutError, aiohttp.ClientError):
error_attempts += 1
if error_attempts >= max_retries:
logger.exception(
+8
View File
@@ -56,6 +56,14 @@ class TokenInfo:
quote_token_program_id: Pubkey | None = None
virtual_quote_reserves: int | None = None
# True when creator, mayhem/cashback flags and quote_mint were read from
# the on-chain CreateEvent (canonical at create time), letting
# extreme_fast_mode skip the pre-buy curve refresh entirely — zero RPC
# calls between detection and submission. Listeners that guess any of
# these (pumpportal) or read them from user-supplied instruction args
# must leave it False.
state_from_event: bool = False
# Metadata
creation_timestamp: float | None = None
additional_data: dict[str, Any] | None = None
+13 -25
View File
@@ -181,35 +181,23 @@ class UniversalGeyserListener(BaseTokenListener):
await asyncio.sleep(10)
async def _process_update(self, update) -> TokenInfo | None:
"""Process a Geyser update and extract token creation info."""
"""Process a Geyser update and extract token creation info.
Delegates to each platform parser's geyser method rather than decoding
instructions here: the parser prefers the CreateEvent from
meta.log_messages, which carries the canonical creator (instruction
args.creator is user-supplied) and marks the TokenInfo
state_from_event so extreme_fast_mode can buy with zero RPC calls.
Each parser filters on its own program id internally.
"""
try:
if not update.HasField("transaction"):
return None
tx = update.transaction.transaction.transaction
msg = getattr(tx, "message", None)
if msg is None:
return None
from solders.pubkey import Pubkey
for ix in msg.instructions:
# Check which platform this instruction belongs to
program_idx = ix.program_id_index
if program_idx >= len(msg.account_keys):
continue
program_id = Pubkey.from_bytes(msg.account_keys[program_idx])
# Find the matching platform parser
for platform, parser in self.platform_parsers.items():
if program_id == parser.get_program_id():
# Use the platform's event parser
token_info = parser.parse_token_creation_from_instruction(
ix.data, ix.accounts, msg.account_keys
)
if token_info:
return token_info
for parser in self.platform_parsers.values():
token_info = parser.parse_token_creation_from_geyser(update)
if token_info:
return token_info
return None
+37
View File
@@ -74,6 +74,43 @@ class PumpFunCurveManager(CurveManager):
logger.exception("Failed to get curve state")
raise ValueError(f"Invalid bonding curve state: {e!s}")
async def get_pool_state_and_token_program(
self, pool_address: Pubkey, mint: Pubkey, commitment: str | None = None
) -> tuple[dict[str, Any], Pubkey | None]:
"""Read curve state and the mint's owning token program together.
One getMultipleAccounts round trip, so both values come from the same
node and slot. Listeners that don't carry the token program (pumpportal
guesses Token-2022) can be corrected from the mint account's owner
without a second, possibly inconsistent read (issue #170).
Args:
pool_address: Address of the bonding curve
mint: Token mint whose owner identifies the token program
commitment: Optional commitment override (see get_pool_state)
Returns:
Tuple of (decoded curve state, token program id or None if the
mint account was not readable)
Raises:
ValueError: If the bonding curve account is missing or undecodable
"""
try:
curve_account, mint_account = await self.client.get_multiple_accounts(
[pool_address, mint], commitment=commitment
)
except Exception as e:
logger.exception("Failed to read curve and mint accounts")
raise ValueError(f"Invalid bonding curve state: {e!s}") from e # noqa: TRY003
if curve_account is None or not curve_account.data:
raise ValueError(f"No data in bonding curve account {pool_address}") # noqa: TRY003
curve_state_data = self._decode_curve_state_with_idl(curve_account.data)
token_program = mint_account.owner if mint_account is not None else None
return curve_state_data, token_program
async def calculate_price(self, pool_address: Pubkey) -> float:
"""Calculate current token price from bonding curve state.
+27 -1
View File
@@ -306,6 +306,15 @@ class PumpFunEventParser(EventParser):
_coerce_pubkey(fields.get("quote_mint"))
)
# The event's creator is canonical (unlike instruction
# args.creator) and the flags/quote_mint are authoritative
# at create time, so extreme_fast_mode can trade on them
# without a curve read. Old-format events missing the
# trailing fields stay conservative.
state_from_event = (
"quote_mint" in fields and "is_mayhem_mode" in fields
)
return TokenInfo(
name=fields["name"],
symbol=fields["symbol"],
@@ -323,6 +332,7 @@ class PumpFunEventParser(EventParser):
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
virtual_quote_reserves=fields.get("virtual_quote_reserves"),
state_from_event=state_from_event,
creation_timestamp=monotonic(),
)
@@ -466,7 +476,23 @@ class PumpFunEventParser(EventParser):
if not hasattr(transaction_info, "transaction"):
return None
tx = transaction_info.transaction.transaction.transaction
# Prefer the CreateEvent from meta.log_messages, same as the block
# parser: the event carries the canonical creator (instruction
# args.creator is user-supplied and may differ post-2026-04-28)
# plus mayhem/cashback/quote_mint, which marks the TokenInfo
# state_from_event so extreme_fast_mode can buy with zero RPC
# calls. Fall back to instruction decoding when logs are absent.
tx_info = transaction_info.transaction.transaction
meta = getattr(tx_info, "meta", None)
log_messages = list(getattr(meta, "log_messages", []) or [])
if log_messages:
token_info = self.parse_token_creation_from_logs(
log_messages, signature=""
)
if token_info:
return token_info
tx = tx_info.transaction
msg = getattr(tx, "message", None)
if msg is None:
return None
+12 -1
View File
@@ -74,9 +74,20 @@ class PumpFunPumpPortalProcessor:
# Convert string addresses to Pubkey objects
mint = Pubkey.from_string(mint_str)
bonding_curve = Pubkey.from_string(bonding_curve_str)
user = Pubkey.from_string(creator_str)
# Derive the bonding curve from the mint rather than trusting the
# payload: PumpPortal's bondingCurveKey was observed pointing at a
# different mint's curve (issue #170), and the PDA derivation is
# free. A mismatch is logged as a data-quality signal only.
bonding_curve = self.address_provider.derive_pool_address(mint)
if str(bonding_curve) != bonding_curve_str:
logger.warning(
f"PumpPortal bondingCurveKey {bonding_curve_str} does not "
f"match curve {bonding_curve} derived from mint {mint}; "
f"using the derived address"
)
# For PumpPortal, we assume the creator is the same as the user
# since PumpPortal doesn't distinguish between them
creator = user
+189 -57
View File
@@ -4,6 +4,7 @@ Final cleanup removing all platform-specific hardcoding.
"""
import asyncio
from time import monotonic
from solders.pubkey import Pubkey
@@ -12,6 +13,7 @@ from core.priority_fee.manager import PriorityFeeManager
from core.pubkeys import (
TOKEN_DECIMALS,
WSOL_MINT,
SystemAddresses,
is_sol_paired,
normalize_quote_mint,
quote_units_per_token,
@@ -43,37 +45,60 @@ def _quote_symbol(quote_mint: Pubkey) -> str:
async def _read_pool_state_with_retry(
curve_manager: object,
pool_address: Pubkey,
attempts: int = 4,
mint: Pubkey | None = None,
budget_seconds: float = 2.0,
delay_seconds: float = 0.15,
) -> dict:
"""Read bonding curve state, retrying briefly on a lagging RPC node.
) -> tuple[dict, Pubkey | None]:
"""Read bonding curve state, retrying within a time budget on a lagging node.
A freshly created curve may not be visible at `confirmed` yet, and a node
can momentarily serve a slot that predates it both surface as "account
not found". Reading at `processed` and retrying a few times costs a handful
of RPC calls, which is far cheaper than trading on stale account data.
not found". Reading at `processed` and retrying costs a handful of RPC
calls, which is far cheaper than trading on stale account data. Issue #170
measured individual reads on a load-balanced endpoint lagging several
seconds behind a fast listener, hence a time budget rather than a fixed
attempt count.
When `mint` is given and the curve manager supports it, the curve and the
mint are read in one slot-consistent batch so the mint's owning token
program comes back for free (pumpportal listeners can only guess it).
Args:
curve_manager: Platform curve manager
pool_address: Bonding curve / pool address
attempts: How many reads to try before giving up
mint: Optional token mint to read alongside the curve
budget_seconds: Total time to keep retrying before giving up
delay_seconds: Pause between attempts
Returns:
Decoded pool state
Tuple of (decoded pool state, token program id or None if unknown)
Raises:
Exception: The last read error if every attempt fails
"""
batch_read = mint is not None and hasattr(
curve_manager, "get_pool_state_and_token_program"
)
deadline = monotonic() + budget_seconds
last_error: Exception | None = None
for _ in range(attempts):
while True:
try:
return await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
if batch_read:
result = await curve_manager.get_pool_state_and_token_program(
pool_address, mint, commitment="processed"
)
else:
state = await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
result = (state, None)
except Exception as error: # noqa: BLE001
last_error = error
if monotonic() + delay_seconds > deadline:
break
await asyncio.sleep(delay_seconds)
else:
return result
raise last_error or RuntimeError("pool_state unavailable after retries")
@@ -113,6 +138,9 @@ class PlatformAwareBuyer(Trader):
extreme_fast_mode: bool = False,
compute_units: dict | None = None,
quote_amounts: dict[Pubkey, float] | None = None,
curve_refresh_budget: float = 2.0,
*,
trust_create_event: bool = True,
):
"""Initialize platform-aware token buyer.
@@ -130,6 +158,15 @@ class PlatformAwareBuyer(Trader):
for coins paired against something other than SOL. A coin whose
quote mint is absent from this map is skipped rather than
traded with a SOL-denominated amount.
curve_refresh_budget: Seconds to keep retrying the pre-buy curve
read before skipping the token. A buy built without fresh curve
state guesses fee_recipient/creator_vault and tends to revert
on-chain (issue #170), so skipping beats racing.
trust_create_event: Skip the pre-buy curve read entirely for
TokenInfo marked state_from_event (creator/flags/quote_mint
read from the on-chain CreateEvent) extreme_fast_mode then
makes zero RPC calls between detection and submission. Set
False to force the refresh for every listener.
"""
self.client = client
self.wallet = wallet
@@ -140,6 +177,8 @@ class PlatformAwareBuyer(Trader):
self.extreme_fast_mode = extreme_fast_mode
self.extreme_fast_token_amount = extreme_fast_token_amount
self.compute_units = compute_units or {}
self.curve_refresh_budget = curve_refresh_budget
self.trust_create_event = trust_create_event
# SOL-paired coins always use `amount`; other quotes need an explicit
# per-mint amount because 0.0001 USDC and 0.0001 SOL are not comparable.
self.quote_amounts: dict[Pubkey, float] = {
@@ -174,53 +213,22 @@ class PlatformAwareBuyer(Trader):
quote_mint = normalize_quote_mint(token_info.quote_mint)
if self.extreme_fast_mode:
# Skip the price check; the token count is fixed by config and
# sizing is finished once the quote amount is resolved below.
# Even in extreme_fast_mode, refresh mayhem/cashback/creator from
# chain — listeners (especially pumpportal) often don't carry
# these, and the program rejects with NotAuthorized (0x1770) /
# ConstraintSeeds (0x7d6) when fee_recipient or creator_vault
# is wrong. PumpPortal often notifies before the BC account is
# readable, so retry briefly. One handful of RPC calls is cheap
# relative to a failed buy.
try:
pool_address = self._get_pool_address(token_info, address_provider)
# Geyser/logs fire on processed, so the BC is typically
# readable in the same slot; pumpportal occasionally races
# the on-chain commit, hence the retries.
pool_state = await _read_pool_state_with_retry(
curve_manager, pool_address
# Zero-RPC hot path — the point of extreme_fast_mode. When the
# CreateEvent already carried the canonical creator, the
# mayhem/cashback flags and quote_mint, nothing sits between
# detection and submission. Otherwise (pumpportal, old-format
# events) refresh from chain or skip.
if not self._can_skip_refresh(token_info):
skip_reason = await self._refresh_curve_state(
token_info, address_provider, curve_manager
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
# The quote asset decides which balance we spend and how
# amounts are scaled, so it must come from the curve rather
# than a listener guess.
quote_mint = _refresh_quote_mint(token_info, pool_state)
fresh_creator = pool_state.get("creator")
if fresh_creator and hasattr(
address_provider, "derive_creator_vault"
):
from solders.pubkey import Pubkey as _Pubkey
new_creator = (
_Pubkey.from_string(fresh_creator)
if isinstance(fresh_creator, str)
else fresh_creator
if skip_reason is not None:
return TradeResult(
success=False,
platform=token_info.platform,
error_message=skip_reason,
)
token_info.creator = new_creator
token_info.creator_vault = (
address_provider.derive_creator_vault(new_creator)
)
except Exception as e: # noqa: BLE001
logger.warning(
f"extreme_fast_mode buy: could not refresh curve flags "
f"({e}); proceeding with token_info defaults"
)
quote_mint = normalize_quote_mint(token_info.quote_mint)
else:
# Get pool address based on platform using platform-agnostic method
pool_address = self._get_pool_address(token_info, address_provider)
@@ -396,6 +404,130 @@ class PlatformAwareBuyer(Trader):
# Fallback to deriving the address using platform provider
return address_provider.derive_pool_address(token_info.mint)
def _can_skip_refresh(self, token_info: TokenInfo) -> bool:
"""Whether the pre-buy curve read can be skipped entirely.
True when the listener read creator, mayhem/cashback and quote_mint
from the on-chain CreateEvent (canonical at create time), keeping
extreme_fast_mode at zero RPC calls between detection and submission.
Args:
token_info: Token information from the listener
Returns:
True if the buy can be built from token_info as-is
"""
return (
self.trust_create_event
and token_info.state_from_event
and token_info.quote_mint is not None
)
async def _refresh_curve_state(
self,
token_info: TokenInfo,
address_provider: AddressProvider,
curve_manager: object,
) -> str | None:
"""Refresh mayhem/cashback/creator/quote_mint/token program from chain.
Listeners that guess these (pumpportal carries none of them) produce
buys the program rejects with NotAuthorized (0x1770) / ConstraintSeeds
(0x7d6) when fee_recipient or creator_vault is wrong. PumpPortal also
notifies before the BC account is readable on a lagging node, so the
read retries within curve_refresh_budget.
Args:
token_info: Token information, mutated in place on success
address_provider: Platform address provider
curve_manager: Platform curve manager
Returns:
None on success; on failure a reason to skip the buy a buy built
from listener-guessed defaults tends to revert on-chain
(issue #170: 0x1770 / 0x7d6 / pool 3012), which still costs the fee
"""
try:
pool_address = self._get_pool_address(token_info, address_provider)
# Geyser/logs fire on processed, so the BC is typically readable in
# the same slot; pumpportal occasionally races the on-chain commit,
# hence the retries.
pool_state, fresh_token_program = await _read_pool_state_with_retry(
curve_manager,
pool_address,
mint=token_info.mint,
budget_seconds=self.curve_refresh_budget,
)
except Exception as e: # noqa: BLE001
return (
f"Curve state unreadable within {self.curve_refresh_budget:.1f}s "
f"({e}); skipping buy rather than submitting with guessed accounts"
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
# The quote asset decides which balance we spend and how amounts are
# scaled, so it must come from the curve rather than a listener guess.
_refresh_quote_mint(token_info, pool_state)
fresh_creator = pool_state.get("creator")
if fresh_creator and hasattr(address_provider, "derive_creator_vault"):
new_creator = (
Pubkey.from_string(fresh_creator)
if isinstance(fresh_creator, str)
else fresh_creator
)
token_info.creator = new_creator
token_info.creator_vault = address_provider.derive_creator_vault(
new_creator
)
self._apply_token_program(token_info, fresh_token_program, address_provider)
return None
def _apply_token_program(
self,
token_info: TokenInfo,
token_program: Pubkey | None,
address_provider: AddressProvider,
) -> None:
"""Correct a listener-guessed token program from the mint's real owner.
PumpPortal payloads carry no token program, so the processor defaults
to Token-2022; a legacy-`create` coin is SPL Token and the ATA-create
instruction then fails with IncorrectProgramId. The associated bonding
curve is an ordinary ATA, so it must be re-derived under the corrected
program too.
Args:
token_info: Token information, mutated in place
token_program: Owner of the mint account, or None if unknown
address_provider: Platform address provider for ATA derivation
"""
known_programs = (
SystemAddresses.TOKEN_PROGRAM,
SystemAddresses.TOKEN_2022_PROGRAM,
)
if token_program is None or token_program not in known_programs:
return
if token_info.token_program_id == token_program:
return
logger.info(
f"Correcting token program for {token_info.mint}: "
f"{token_info.token_program_id} -> {token_program}"
)
token_info.token_program_id = token_program
if token_info.bonding_curve and hasattr(
address_provider, "derive_associated_bonding_curve"
):
token_info.associated_bonding_curve = (
address_provider.derive_associated_bonding_curve(
token_info.mint, token_info.bonding_curve, token_program
)
)
def _get_sol_destination(
self, token_info: TokenInfo, address_provider: AddressProvider
) -> Pubkey:
@@ -521,7 +653,7 @@ class PlatformAwareSeller(Trader):
# slightly stale slot reports the curve as missing, and silently
# falling back to create-time values risks a wrong creator_vault
# (ConstraintSeeds 0x7d6) or wrong mayhem fee_recipient.
pool_state = await _read_pool_state_with_retry(
pool_state, _ = await _read_pool_state_with_retry(
curve_manager, pool_address
)
token_info.is_mayhem_mode = pool_state.get(
+5
View File
@@ -102,6 +102,9 @@ class UniversalTrader:
# Trading configuration
extreme_fast_mode: bool = False,
extreme_fast_token_amount: int = 30,
curve_refresh_budget: float = 2.0,
*,
trust_create_event: bool = True,
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts: dict[str, float] | None = None,
allowed_quote_mints: list[str] | None = None,
@@ -193,6 +196,8 @@ class UniversalTrader:
extreme_fast_mode,
compute_units=self.compute_units,
quote_amounts=self.quote_amounts,
curve_refresh_budget=curve_refresh_budget,
trust_create_event=trust_create_event,
),
PlatformAwareSeller(
self.solana_client,