mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-18 09:48:05 +00:00
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:
co-authored by
Claude Fable 5
parent
bc59a2257d
commit
7bc6f25508
@@ -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` /
|
`unitsConsumed`; use it to retune `get_buy_compute_unit_limit` /
|
||||||
`get_sell_compute_unit_limit` in `platforms/pumpfun/instruction_builder.py`.
|
`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
|
### Verifying transaction-status handling
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -90,13 +90,41 @@ Logs land in `logs/{bot_name}_{timestamp}.log`.
|
|||||||
|
|
||||||
The YAML files are commented inline. The sections that matter most:
|
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.
|
- **`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).
|
- **`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.
|
- **`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`.
|
- **`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.
|
- **`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
|
### 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`:
|
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`:
|
||||||
|
|||||||
@@ -47,9 +47,27 @@ trade:
|
|||||||
# EXTREME FAST mode configuration
|
# EXTREME FAST mode configuration
|
||||||
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
|
# 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.
|
# 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_mode: true
|
||||||
extreme_fast_token_amount: 20 # Amount of tokens to buy
|
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
|
# Priority fee configuration
|
||||||
# Manage transaction speed and cost on the Solana network.
|
# Manage transaction speed and cost on the Solana network.
|
||||||
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
||||||
|
|||||||
@@ -37,9 +37,27 @@ trade:
|
|||||||
# EXTREME FAST mode configuration
|
# EXTREME FAST mode configuration
|
||||||
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
|
# 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.
|
# 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_mode: true
|
||||||
extreme_fast_token_amount: 20 # Amount of tokens to buy
|
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
|
# Priority fee configuration
|
||||||
# Manage transaction speed and cost on the Solana network.
|
# Manage transaction speed and cost on the Solana network.
|
||||||
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
||||||
|
|||||||
@@ -37,9 +37,27 @@ trade:
|
|||||||
# EXTREME FAST mode configuration
|
# EXTREME FAST mode configuration
|
||||||
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
|
# 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.
|
# 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_mode: true
|
||||||
extreme_fast_token_amount: 20 # Amount of tokens to buy
|
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
|
# Priority fee configuration
|
||||||
# Manage transaction speed and cost on the Solana network.
|
# Manage transaction speed and cost on the Solana network.
|
||||||
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
||||||
|
|||||||
@@ -35,9 +35,27 @@ trade:
|
|||||||
# EXTREME FAST mode configuration
|
# EXTREME FAST mode configuration
|
||||||
# When enabled, skips waiting for the bonding curve to stabilize and RPC price check.
|
# 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.
|
# 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_mode: true
|
||||||
extreme_fast_token_amount: 20 # Amount of tokens to buy
|
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
|
# Priority fee configuration
|
||||||
# Manage transaction speed and cost on the Solana network.
|
# Manage transaction speed and cost on the Solana network.
|
||||||
# Note: dynamic mode requires an additional RPC call, which slows down the buying process.
|
# 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"quote_mint (from CreateEvent): {token_info.quote_mint}")
|
||||||
print(f"token program: {token_info.token_program_id}")
|
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"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")
|
print(f"extreme_fast_mode={extreme_fast}\n")
|
||||||
|
|
||||||
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
|
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,
|
Path(__file__).name,
|
||||||
# stubs confirm_transaction out; never sends a transaction
|
# stubs confirm_transaction out; never sends a transaction
|
||||||
"simulate_bot_buy_path.py",
|
"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
|
# uses the bot's SolanaClient wrapper, which folds meta.err into its
|
||||||
# return value; the boolean is read at the call site
|
# return value; the boolean is read at the call site
|
||||||
"cleanup_accounts.py",
|
"cleanup_accounts.py",
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ async def start_bot(config_path: str):
|
|||||||
# Extreme fast mode settings
|
# Extreme fast mode settings
|
||||||
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
|
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
|
||||||
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
|
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 asset configuration (pump.fun non-SOL pairs)
|
||||||
quote_amounts=cfg["trade"].get("quote_amounts"),
|
quote_amounts=cfg["trade"].get("quote_amounts"),
|
||||||
allowed_quote_mints=cfg["filters"].get("allowed_quote_mints"),
|
allowed_quote_mints=cfg["filters"].get("allowed_quote_mints"),
|
||||||
|
|||||||
+25
-1
@@ -175,6 +175,30 @@ class SolanaClient:
|
|||||||
raise ValueError(f"Account {pubkey} not found")
|
raise ValueError(f"Account {pubkey} not found")
|
||||||
return response.value
|
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(
|
async def get_token_account_balance(
|
||||||
self, token_account: Pubkey, commitment: str = "confirmed"
|
self, token_account: Pubkey, commitment: str = "confirmed"
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -630,7 +654,7 @@ class SolanaClient:
|
|||||||
# timeout fires, and it is not an aiohttp.ClientError — without it
|
# timeout fires, and it is not an aiohttp.ClientError — without it
|
||||||
# here every RPC timeout propagated out of post_rpc unretried and
|
# here every RPC timeout propagated out of post_rpc unretried and
|
||||||
# crashed the caller with an exception whose str() is empty.
|
# crashed the caller with an exception whose str() is empty.
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
except (TimeoutError, aiohttp.ClientError):
|
||||||
error_attempts += 1
|
error_attempts += 1
|
||||||
if error_attempts >= max_retries:
|
if error_attempts >= max_retries:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ class TokenInfo:
|
|||||||
quote_token_program_id: Pubkey | None = None
|
quote_token_program_id: Pubkey | None = None
|
||||||
virtual_quote_reserves: int | 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
|
# Metadata
|
||||||
creation_timestamp: float | None = None
|
creation_timestamp: float | None = None
|
||||||
additional_data: dict[str, Any] | None = None
|
additional_data: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -181,35 +181,23 @@ class UniversalGeyserListener(BaseTokenListener):
|
|||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
async def _process_update(self, update) -> TokenInfo | None:
|
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:
|
try:
|
||||||
if not update.HasField("transaction"):
|
if not update.HasField("transaction"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
tx = update.transaction.transaction.transaction
|
for parser in self.platform_parsers.values():
|
||||||
msg = getattr(tx, "message", None)
|
token_info = parser.parse_token_creation_from_geyser(update)
|
||||||
if msg is None:
|
if token_info:
|
||||||
return None
|
return token_info
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,43 @@ class PumpFunCurveManager(CurveManager):
|
|||||||
logger.exception("Failed to get curve state")
|
logger.exception("Failed to get curve state")
|
||||||
raise ValueError(f"Invalid bonding curve state: {e!s}")
|
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:
|
async def calculate_price(self, pool_address: Pubkey) -> float:
|
||||||
"""Calculate current token price from bonding curve state.
|
"""Calculate current token price from bonding curve state.
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,15 @@ class PumpFunEventParser(EventParser):
|
|||||||
_coerce_pubkey(fields.get("quote_mint"))
|
_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(
|
return TokenInfo(
|
||||||
name=fields["name"],
|
name=fields["name"],
|
||||||
symbol=fields["symbol"],
|
symbol=fields["symbol"],
|
||||||
@@ -323,6 +332,7 @@ class PumpFunEventParser(EventParser):
|
|||||||
quote_mint=quote_mint,
|
quote_mint=quote_mint,
|
||||||
quote_token_program_id=quote_token_program(quote_mint),
|
quote_token_program_id=quote_token_program(quote_mint),
|
||||||
virtual_quote_reserves=fields.get("virtual_quote_reserves"),
|
virtual_quote_reserves=fields.get("virtual_quote_reserves"),
|
||||||
|
state_from_event=state_from_event,
|
||||||
creation_timestamp=monotonic(),
|
creation_timestamp=monotonic(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -466,7 +476,23 @@ class PumpFunEventParser(EventParser):
|
|||||||
if not hasattr(transaction_info, "transaction"):
|
if not hasattr(transaction_info, "transaction"):
|
||||||
return None
|
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)
|
msg = getattr(tx, "message", None)
|
||||||
if msg is None:
|
if msg is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -74,9 +74,20 @@ class PumpFunPumpPortalProcessor:
|
|||||||
|
|
||||||
# Convert string addresses to Pubkey objects
|
# Convert string addresses to Pubkey objects
|
||||||
mint = Pubkey.from_string(mint_str)
|
mint = Pubkey.from_string(mint_str)
|
||||||
bonding_curve = Pubkey.from_string(bonding_curve_str)
|
|
||||||
user = Pubkey.from_string(creator_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
|
# For PumpPortal, we assume the creator is the same as the user
|
||||||
# since PumpPortal doesn't distinguish between them
|
# since PumpPortal doesn't distinguish between them
|
||||||
creator = user
|
creator = user
|
||||||
|
|||||||
+189
-57
@@ -4,6 +4,7 @@ Final cleanup removing all platform-specific hardcoding.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from solders.pubkey import Pubkey
|
from solders.pubkey import Pubkey
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ from core.priority_fee.manager import PriorityFeeManager
|
|||||||
from core.pubkeys import (
|
from core.pubkeys import (
|
||||||
TOKEN_DECIMALS,
|
TOKEN_DECIMALS,
|
||||||
WSOL_MINT,
|
WSOL_MINT,
|
||||||
|
SystemAddresses,
|
||||||
is_sol_paired,
|
is_sol_paired,
|
||||||
normalize_quote_mint,
|
normalize_quote_mint,
|
||||||
quote_units_per_token,
|
quote_units_per_token,
|
||||||
@@ -43,37 +45,60 @@ def _quote_symbol(quote_mint: Pubkey) -> str:
|
|||||||
async def _read_pool_state_with_retry(
|
async def _read_pool_state_with_retry(
|
||||||
curve_manager: object,
|
curve_manager: object,
|
||||||
pool_address: Pubkey,
|
pool_address: Pubkey,
|
||||||
attempts: int = 4,
|
mint: Pubkey | None = None,
|
||||||
|
budget_seconds: float = 2.0,
|
||||||
delay_seconds: float = 0.15,
|
delay_seconds: float = 0.15,
|
||||||
) -> dict:
|
) -> tuple[dict, Pubkey | None]:
|
||||||
"""Read bonding curve state, retrying briefly on a lagging RPC node.
|
"""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
|
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
|
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
|
not found". Reading at `processed` and retrying costs a handful of RPC
|
||||||
of RPC calls, which is far cheaper than trading on stale account data.
|
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:
|
Args:
|
||||||
curve_manager: Platform curve manager
|
curve_manager: Platform curve manager
|
||||||
pool_address: Bonding curve / pool address
|
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
|
delay_seconds: Pause between attempts
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded pool state
|
Tuple of (decoded pool state, token program id or None if unknown)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: The last read error if every attempt fails
|
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
|
last_error: Exception | None = None
|
||||||
for _ in range(attempts):
|
while True:
|
||||||
try:
|
try:
|
||||||
return await curve_manager.get_pool_state(
|
if batch_read:
|
||||||
pool_address, commitment="processed"
|
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
|
except Exception as error: # noqa: BLE001
|
||||||
last_error = error
|
last_error = error
|
||||||
|
if monotonic() + delay_seconds > deadline:
|
||||||
|
break
|
||||||
await asyncio.sleep(delay_seconds)
|
await asyncio.sleep(delay_seconds)
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
raise last_error or RuntimeError("pool_state unavailable after retries")
|
raise last_error or RuntimeError("pool_state unavailable after retries")
|
||||||
|
|
||||||
@@ -113,6 +138,9 @@ class PlatformAwareBuyer(Trader):
|
|||||||
extreme_fast_mode: bool = False,
|
extreme_fast_mode: bool = False,
|
||||||
compute_units: dict | None = None,
|
compute_units: dict | None = None,
|
||||||
quote_amounts: dict[Pubkey, float] | 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.
|
"""Initialize platform-aware token buyer.
|
||||||
|
|
||||||
@@ -130,6 +158,15 @@ class PlatformAwareBuyer(Trader):
|
|||||||
for coins paired against something other than SOL. A coin whose
|
for coins paired against something other than SOL. A coin whose
|
||||||
quote mint is absent from this map is skipped rather than
|
quote mint is absent from this map is skipped rather than
|
||||||
traded with a SOL-denominated amount.
|
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.client = client
|
||||||
self.wallet = wallet
|
self.wallet = wallet
|
||||||
@@ -140,6 +177,8 @@ class PlatformAwareBuyer(Trader):
|
|||||||
self.extreme_fast_mode = extreme_fast_mode
|
self.extreme_fast_mode = extreme_fast_mode
|
||||||
self.extreme_fast_token_amount = extreme_fast_token_amount
|
self.extreme_fast_token_amount = extreme_fast_token_amount
|
||||||
self.compute_units = compute_units or {}
|
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
|
# 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.
|
# per-mint amount because 0.0001 USDC and 0.0001 SOL are not comparable.
|
||||||
self.quote_amounts: dict[Pubkey, float] = {
|
self.quote_amounts: dict[Pubkey, float] = {
|
||||||
@@ -174,53 +213,22 @@ class PlatformAwareBuyer(Trader):
|
|||||||
quote_mint = normalize_quote_mint(token_info.quote_mint)
|
quote_mint = normalize_quote_mint(token_info.quote_mint)
|
||||||
|
|
||||||
if self.extreme_fast_mode:
|
if self.extreme_fast_mode:
|
||||||
# Skip the price check; the token count is fixed by config and
|
# Zero-RPC hot path — the point of extreme_fast_mode. When the
|
||||||
# sizing is finished once the quote amount is resolved below.
|
# CreateEvent already carried the canonical creator, the
|
||||||
# Even in extreme_fast_mode, refresh mayhem/cashback/creator from
|
# mayhem/cashback flags and quote_mint, nothing sits between
|
||||||
# chain — listeners (especially pumpportal) often don't carry
|
# detection and submission. Otherwise (pumpportal, old-format
|
||||||
# these, and the program rejects with NotAuthorized (0x1770) /
|
# events) refresh from chain or skip.
|
||||||
# ConstraintSeeds (0x7d6) when fee_recipient or creator_vault
|
if not self._can_skip_refresh(token_info):
|
||||||
# is wrong. PumpPortal often notifies before the BC account is
|
skip_reason = await self._refresh_curve_state(
|
||||||
# readable, so retry briefly. One handful of RPC calls is cheap
|
token_info, address_provider, curve_manager
|
||||||
# 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
|
|
||||||
)
|
)
|
||||||
token_info.is_mayhem_mode = pool_state.get(
|
if skip_reason is not None:
|
||||||
"is_mayhem_mode", token_info.is_mayhem_mode
|
return TradeResult(
|
||||||
)
|
success=False,
|
||||||
token_info.is_cashback_coin = pool_state.get(
|
platform=token_info.platform,
|
||||||
"is_cashback_coin", token_info.is_cashback_coin
|
error_message=skip_reason,
|
||||||
)
|
|
||||||
# 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
|
|
||||||
)
|
)
|
||||||
token_info.creator = new_creator
|
quote_mint = normalize_quote_mint(token_info.quote_mint)
|
||||||
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"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Get pool address based on platform using platform-agnostic method
|
# Get pool address based on platform using platform-agnostic method
|
||||||
pool_address = self._get_pool_address(token_info, address_provider)
|
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
|
# Fallback to deriving the address using platform provider
|
||||||
return address_provider.derive_pool_address(token_info.mint)
|
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(
|
def _get_sol_destination(
|
||||||
self, token_info: TokenInfo, address_provider: AddressProvider
|
self, token_info: TokenInfo, address_provider: AddressProvider
|
||||||
) -> Pubkey:
|
) -> Pubkey:
|
||||||
@@ -521,7 +653,7 @@ class PlatformAwareSeller(Trader):
|
|||||||
# slightly stale slot reports the curve as missing, and silently
|
# slightly stale slot reports the curve as missing, and silently
|
||||||
# falling back to create-time values risks a wrong creator_vault
|
# falling back to create-time values risks a wrong creator_vault
|
||||||
# (ConstraintSeeds 0x7d6) or wrong mayhem fee_recipient.
|
# (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
|
curve_manager, pool_address
|
||||||
)
|
)
|
||||||
token_info.is_mayhem_mode = pool_state.get(
|
token_info.is_mayhem_mode = pool_state.get(
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ class UniversalTrader:
|
|||||||
# Trading configuration
|
# Trading configuration
|
||||||
extreme_fast_mode: bool = False,
|
extreme_fast_mode: bool = False,
|
||||||
extreme_fast_token_amount: int = 30,
|
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 asset configuration (pump.fun non-SOL pairs)
|
||||||
quote_amounts: dict[str, float] | None = None,
|
quote_amounts: dict[str, float] | None = None,
|
||||||
allowed_quote_mints: list[str] | None = None,
|
allowed_quote_mints: list[str] | None = None,
|
||||||
@@ -193,6 +196,8 @@ class UniversalTrader:
|
|||||||
extreme_fast_mode,
|
extreme_fast_mode,
|
||||||
compute_units=self.compute_units,
|
compute_units=self.compute_units,
|
||||||
quote_amounts=self.quote_amounts,
|
quote_amounts=self.quote_amounts,
|
||||||
|
curve_refresh_budget=curve_refresh_budget,
|
||||||
|
trust_create_event=trust_create_event,
|
||||||
),
|
),
|
||||||
PlatformAwareSeller(
|
PlatformAwareSeller(
|
||||||
self.solana_client,
|
self.solana_client,
|
||||||
|
|||||||
Reference in New Issue
Block a user