From 7bc6f25508100d59d3b0cf475f63c24381c4f601 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk <43762166+smypmsa@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:29:03 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 39 ++ README.md | 30 +- bots/bot-sniper-1-geyser.yaml | 18 + bots/bot-sniper-2-logs.yaml | 18 + bots/bot-sniper-3-blocks.yaml | 18 + bots/bot-sniper-4-pp.yaml | 18 + learning-examples/simulate_bot_buy_path.py | 1 + .../verify_extreme_fast_zero_rpc.py | 432 ++++++++++++++++++ .../verify_pumpportal_buy_path.py | 345 ++++++++++++++ learning-examples/verify_tx_status_checks.py | 3 + src/bot_runner.py | 2 + src/core/client.py | 26 +- src/interfaces/core.py | 8 + src/monitoring/universal_geyser_listener.py | 38 +- src/platforms/pumpfun/curve_manager.py | 37 ++ src/platforms/pumpfun/event_parser.py | 28 +- src/platforms/pumpfun/pumpportal_processor.py | 13 +- src/trading/platform_aware.py | 246 +++++++--- src/trading/universal_trader.py | 5 + 19 files changed, 1239 insertions(+), 86 deletions(-) create mode 100644 learning-examples/verify_extreme_fast_zero_rpc.py create mode 100644 learning-examples/verify_pumpportal_buy_path.py diff --git a/CLAUDE.md b/CLAUDE.md index 5028859..63c1da9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 4449461..2d4e435 100644 --- a/README.md +++ b/README.md @@ -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 ~40–50 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`: diff --git a/bots/bot-sniper-1-geyser.yaml b/bots/bot-sniper-1-geyser.yaml index 8ca4d86..5b77eb2 100644 --- a/bots/bot-sniper-1-geyser.yaml +++ b/bots/bot-sniper-1-geyser.yaml @@ -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. diff --git a/bots/bot-sniper-2-logs.yaml b/bots/bot-sniper-2-logs.yaml index e7113cc..52bcea5 100644 --- a/bots/bot-sniper-2-logs.yaml +++ b/bots/bot-sniper-2-logs.yaml @@ -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. diff --git a/bots/bot-sniper-3-blocks.yaml b/bots/bot-sniper-3-blocks.yaml index ba35a07..351875c 100644 --- a/bots/bot-sniper-3-blocks.yaml +++ b/bots/bot-sniper-3-blocks.yaml @@ -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. diff --git a/bots/bot-sniper-4-pp.yaml b/bots/bot-sniper-4-pp.yaml index 8c0498d..8b38339 100644 --- a/bots/bot-sniper-4-pp.yaml +++ b/bots/bot-sniper-4-pp.yaml @@ -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. diff --git a/learning-examples/simulate_bot_buy_path.py b/learning-examples/simulate_bot_buy_path.py index 696ddae..e591312 100644 --- a/learning-examples/simulate_bot_buy_path.py +++ b/learning-examples/simulate_bot_buy_path.py @@ -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"]) diff --git a/learning-examples/verify_extreme_fast_zero_rpc.py b/learning-examples/verify_extreme_fast_zero_rpc.py new file mode 100644 index 0000000..cc6171d --- /dev/null +++ b/learning-examples/verify_extreme_fast_zero_rpc.py @@ -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()) diff --git a/learning-examples/verify_pumpportal_buy_path.py b/learning-examples/verify_pumpportal_buy_path.py new file mode 100644 index 0000000..2a66902 --- /dev/null +++ b/learning-examples/verify_pumpportal_buy_path.py @@ -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( + " 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()) diff --git a/learning-examples/verify_tx_status_checks.py b/learning-examples/verify_tx_status_checks.py index 9267762..12158b6 100644 --- a/learning-examples/verify_tx_status_checks.py +++ b/learning-examples/verify_tx_status_checks.py @@ -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", diff --git a/src/bot_runner.py b/src/bot_runner.py index ebf4133..2415624 100644 --- a/src/bot_runner.py +++ b/src/bot_runner.py @@ -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"), diff --git a/src/core/client.py b/src/core/client.py index 92327d8..1af6002 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -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( diff --git a/src/interfaces/core.py b/src/interfaces/core.py index d79eda2..a26ea42 100644 --- a/src/interfaces/core.py +++ b/src/interfaces/core.py @@ -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 diff --git a/src/monitoring/universal_geyser_listener.py b/src/monitoring/universal_geyser_listener.py index 0bdd2ec..d10e667 100644 --- a/src/monitoring/universal_geyser_listener.py +++ b/src/monitoring/universal_geyser_listener.py @@ -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 diff --git a/src/platforms/pumpfun/curve_manager.py b/src/platforms/pumpfun/curve_manager.py index 5eace49..112c7d8 100644 --- a/src/platforms/pumpfun/curve_manager.py +++ b/src/platforms/pumpfun/curve_manager.py @@ -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. diff --git a/src/platforms/pumpfun/event_parser.py b/src/platforms/pumpfun/event_parser.py index 3493df5..0163cbd 100644 --- a/src/platforms/pumpfun/event_parser.py +++ b/src/platforms/pumpfun/event_parser.py @@ -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 diff --git a/src/platforms/pumpfun/pumpportal_processor.py b/src/platforms/pumpfun/pumpportal_processor.py index 76035ab..0d6b621 100644 --- a/src/platforms/pumpfun/pumpportal_processor.py +++ b/src/platforms/pumpfun/pumpportal_processor.py @@ -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 diff --git a/src/trading/platform_aware.py b/src/trading/platform_aware.py index af66208..e32ae45 100644 --- a/src/trading/platform_aware.py +++ b/src/trading/platform_aware.py @@ -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( diff --git a/src/trading/universal_trader.py b/src/trading/universal_trader.py index 7b5577e..35470bf 100644 --- a/src/trading/universal_trader.py +++ b/src/trading/universal_trader.py @@ -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,