chore(monitoring,platforms,tools): retire the pumpportal listener (#232)

* chore(monitoring,platforms,tools): retire the pumpportal listener

Remove the third-party feed from the bot, the cookbook and the listener
race. It sampled rather than covered the chain, and was the one listener
that could never set state_from_event, so every buy paid the pre-buy
curve read.

Rescope the two buy-path verifiers that used it as a stand-in for a
TokenInfo without state_from_event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(trading): rewrap a docstring paragraph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-09-26 08:47:31 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent ca1f8919e6
commit b7aae5e333
33 changed files with 95 additions and 1568 deletions
+4 -12
View File
@@ -153,8 +153,7 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
parsers set it; instruction-parsed `TokenInfo` does not, because `args.creator`
is user-supplied and may differ from the canonical `BC.creator`. The `shreds`
listener sets the flag itself, having no curve to read.
`trade.trust_create_event: false` forces the refresh back on; PumpPortal
payloads always refresh.
`trade.trust_create_event: false` forces the refresh back on.
**Selling**
@@ -189,16 +188,9 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
`GEYSER_*`.
- `src/config_loader.py` (package root, not under `core/`) validates the
platform/listener pairing before startup: pump.fun supports `logs`, `blocks`,
`geyser`, `shreds`, `pumpportal`; letsbonk.fun supports `blocks`, `geyser`,
`pumpportal` — not `logs`, and not `shreds` (issue #201). Adding a listener
means updating `PLATFORM_LISTENER_COMPATIBILITY` there too.
- **PumpPortal is a sampling feed, not a complete one, and not one launchpad.**
It never sends a coin created in a transaction v1, and a bonk `create` carries
no `name`, `symbol` or `uri` — so only `mint` and `traderPublicKey` are
required, and `filters.match_string` can never match a bonk token. Each
payload names its launchpad in `pool`; a consumer that ignores it counts bonk
coins as pump.fun ones. The bonk trade path past
detection is unverified (issue #201).
`geyser`, `shreds`; letsbonk.fun supports `blocks` and `geyser` — not `logs`,
and not `shreds` (issue #201). Adding a listener means updating
`PLATFORM_LISTENER_COMPATIBILITY` there too.
- Bots with `separate_process: true` run in their own process, one log file each.
- `pump_bot` runs **every** `bots/*.yaml`, and three of the four committed configs
ship `enabled: false`. With all of them disabled it prints nothing and exits 0,
+6 -8
View File
@@ -71,12 +71,9 @@ Each YAML file in `bots/` is one bot instance. They ship with commented defaults
| `bot-sniper-1-geyser.yaml` | `geyser` — fastest, needs a Geyser endpoint | `pump_fun` |
| `bot-sniper-2-logs.yaml` | `logs` — `logsSubscribe`, supported everywhere | `pump_fun` |
| `bot-sniper-3-blocks.yaml` | `blocks` — `blockSubscribe`, not supported by every provider | `pump_fun` |
| `bot-sniper-4-pp.yaml` | `pumpportal` — third-party aggregator, misses some coins | `lets_bonk` |
| `bot-sniper-5-shreds.yaml` | `shreds` — pre-execution, ahead of `geyser`, cannot see router-created coins | `pump_fun` |
Set `platform: "pump_fun"` or `platform: "lets_bonk"`. pump.fun supports all five listeners; letsbonk.fun supports `blocks`, `geyser`, and `pumpportal` but **not** `logs` or `shreds`. The bot validates the pairing at startup and refuses to run an invalid one.
`pumpportal` is a third-party feed and only reports what it indexes — it does not push coins whose creation landed in a Solana transaction v1, so it sees a sample rather than everything. `geyser`, `logs` and `blocks` read the chain directly and are unaffected.
Set `platform: "pump_fun"` or `platform: "lets_bonk"`. pump.fun supports all four listeners; letsbonk.fun supports `blocks` and `geyser` but **not** `logs` or `shreds`. The bot validates the pairing at startup and refuses to run an invalid one.
Set `enabled: false` to keep a config around without running it. Every bot with `enabled: true` starts when you run the bot.
@@ -106,9 +103,10 @@ With `extreme_fast_mode: true` the bot buys a fixed token amount
(`extreme_fast_token_amount`) instead of reading the curve price first. You give
up knowing what you paid per token; you get the buy submitted sooner.
How much sooner depends on the listener. With `geyser`, `logs` or `blocks` the
bot makes **no RPC call at all between detecting the token and submitting the
buy**. `pumpportal` does one account read first, because its payload is missing
How much sooner depends on what the listener could decode. When it read the
coin's `CreateEvent`, the bot makes **no RPC call at all between detecting the
token and submitting the buy**. When it fell back to decoding the create
instruction, it does one account read first, because the instruction is missing
fields the buy needs.
Two knobs:
@@ -143,7 +141,7 @@ its own with `uv run <path>`, reads `.env` directly, and needs no bot config.
| Directory | What is in it |
|---|---|
| `cookbook/pumpfun/listen/` | One script per detection method — `logs`, `blocks`, `geyser`, `pumpportal` — plus wallet watching |
| `cookbook/pumpfun/listen/` | One script per detection method — `logs`, `blocks`, `geyser`, `shreds` — plus wallet watching |
| `cookbook/pumpfun/read/` | Price, curve state, graduation progress, address derivation |
| `cookbook/pumpfun/trade/` | Buy, sell, create, and the two sniping variants |
| `cookbook/pumpfun/graduation/` | Coins approaching graduation, and migrations to PumpSwap |
+1 -1
View File
@@ -49,7 +49,7 @@ trade:
# EXTREME FAST mode: skip the curve-stabilization wait and the price check,
# buying a fixed token amount instead. Faster, but you don't learn the price
# you paid. With geyser/logs/blocks the bot makes no RPC call at all between
# detecting the coin and sending the buy; pumpportal needs one read first.
# detecting the coin and sending the buy.
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
+1 -1
View File
@@ -39,7 +39,7 @@ trade:
# EXTREME FAST mode: skip the curve-stabilization wait and the price check,
# buying a fixed token amount instead. Faster, but you don't learn the price
# you paid. With geyser/logs/blocks the bot makes no RPC call at all between
# detecting the coin and sending the buy; pumpportal needs one read first.
# detecting the coin and sending the buy.
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
+1 -1
View File
@@ -39,7 +39,7 @@ trade:
# EXTREME FAST mode: skip the curve-stabilization wait and the price check,
# buying a fixed token amount instead. Faster, but you don't learn the price
# you paid. With geyser/logs/blocks the bot makes no RPC call at all between
# detecting the coin and sending the buy; pumpportal needs one read first.
# detecting the coin and sending the buy.
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
-111
View File
@@ -1,111 +0,0 @@
# Parameters for one bot instance. Review the values before running.
# Bot identification and connection settings
name: "bot-sniper-pumpportal"
env_file: ".env"
rpc_endpoint: "${SOLANA_NODE_RPC_ENDPOINT}"
wss_endpoint: "${SOLANA_NODE_WSS_ENDPOINT}"
private_key: "${SOLANA_PRIVATE_KEY}"
enabled: false # You can turn off the bot w/o removing its config
separate_process: true
# Options: "pump_fun" (default), "lets_bonk"
platform: "lets_bonk"
# PumpPortal configuration (optional - uses default URL if not specified)
pumpportal:
url: "wss://pumpportal.fun/api/data" # Default PumpPortal WebSocket URL
# Trade execution: amount per trade and acceptable price deviation
trade:
buy_amount: 0.0001 # Amount of SOL to spend when buying (in SOL)
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
stop_loss_percentage: 0.1 # Stop loss at 10% loss (0.1 = 10%)
max_hold_time: 15 # Maximum hold time in seconds for TP/SL strategy, for time_based - see wait_after_buy
price_check_interval: 2 # Check price every 2 seconds
# A tp/sl exit sell can revert when the price moves under it. The bot re-reads
# the price and retries; after this many attempts it gives up and leaves the
# position open, tokens still held.
max_exit_sell_attempts: 3
# EXTREME FAST mode: skip the curve-stabilization wait and the price check,
# buying a fixed token amount instead. Faster, but you don't learn the price
# you paid. With geyser/logs/blocks the bot makes no RPC call at all between
# detecting the coin and sending the buy; pumpportal needs one read first.
extreme_fast_mode: true
extreme_fast_token_amount: 20 # Amount of tokens to buy
# How long that read may take, in seconds, before the coin is SKIPPED. Raise
# it to buy more coins on a slow endpoint, lower it to skip faster. Buying
# without it reverts on-chain and still costs the fee.
curve_refresh_budget: 2.0
# Set false to make every listener do the pre-buy read, giving up the
# zero-RPC path above.
trust_create_event: true
# Priority fees: transaction speed vs cost.
# Note: dynamic mode costs an extra RPC call, which slows the buy.
priority_fees:
enable_dynamic: false # Use latest transactions to estimate required fee (getRecentPrioritizationFees)
enable_fixed: true # Use fixed amount below
fixed_amount: 200_000 # Base fee in microlamports
extra_percentage: 0.0 # Percentage increase on riority fee regardless of the calculation method (0.1 = 10%)
hard_cap: 200_000 # Maximum allowable fee in microlamports to prevent excessive spending
# Compute unit limits for transaction processing
# Operation-specific defaults are used if not specified: buy=100K, sell=60K
compute_units:
# Override default CU limits for this platform
# buy: 100_000 # Buy operations (ATA creation + trading)
# sell: 60_000 # Sell operations (just trading)
# Account data size optimization (reduces CU cost and improves tx priority)
# Reduces CU cost from 16k to ~128 CU by limiting loaded account data.
# Default is 64MB (16k CU). Setting to 512KB significantly reduces overhead.
# Note: Savings don't show in "consumed CU" but improve tx priority/cost.
# Note (Nov 23, 2025): with data size set to 512KB, transactions fail - increasing to 12.5MB resolves the issue.
# Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
# account_data_size: 12_500_000 # Disabled: causes MaxLoadedAccountsDataSizeExceeded with Token-2022
# Filters for token selection
filters:
match_string: null # Only process tokens with this string in name/symbol
bro_address: null # Only trade tokens created by this user address
# PumpPortal is a third-party feed and only reports what it indexes. It does
# not report coins whose create landed in a Solana transaction v1, while its
# coverage of v0 creates is near-complete, and nothing in this repo can
# recover those — the coin is never pushed. Use "geyser" or "logs" if you need
# every coin; see tests/regression/verify_transaction_v1.py.
listener_type: "pumpportal" # Method for detecting new tokens: "logs", "blocks", "geyser", or "pumpportal"
max_token_age: 0.001 # Maximum token age in seconds for processing
marry_mode: false # Only buy tokens, skip selling
yolo_mode: false # Continuously trade tokens
# Retry and timeout settings
retries:
max_attempts: 1 # Number of attempts for transaction submission
wait_after_creation: 15 # Seconds to wait after token creation (only if EXTREME FAST is disabled)
wait_after_buy: 15 # Holding period after buy transaction
wait_before_new_token: 15 # Pause between token trades
# Token and account management
cleanup:
# Cleanup mode determines when to manage token accounts. Options:
# "disabled": no cleanup will occur.
# "on_fail": only clean up if a buy transaction fails.
# "after_sell": clean up after selling.
# "post_session": clean up all empty accounts after a trading session ends.
mode: "post_session"
force_close_with_burn: false # Force burning remaining tokens before closing account
with_priority_fee: false # Use priority fees for cleanup transactions
# Node provider configuration
node:
max_rps: 25 # Maximum requests per second
+1 -1
View File
@@ -108,7 +108,7 @@ filters:
# Accepts "sol"/"usdc" aliases or raw mint addresses.
#allowed_quote_mints: ["sol"]
listener_type: "shreds" # Pre-execution detection; see the header of this file.
# Supported on pump_fun only. Other options: "logs", "blocks", "geyser", "pumpportal".
# Supported on pump_fun only. Other options: "logs", "blocks", "geyser".
max_token_age: 0.001 # Maximum token age in seconds for processing
marry_mode: false # Only buy tokens, skip selling
yolo_mode: false # Continuously trade tokens
+1 -2
View File
@@ -20,7 +20,7 @@ letsbonk_sell_token_exact_out.py sell for a fixed amount received
The variant is either an instruction version (`v1`, `v2`, `exact_in`, `exact_out`)
or the transport (`geyser`, `logsubscribe`, `blocksubscribe`, `programsubscribe`,
`pumpportal`, `gettransaction`, `getaccountinfo`).
`gettransaction`, `getaccountinfo`).
## Running them
@@ -62,7 +62,6 @@ Not specific to any of the launchpads below.
| [`pumpfun/listen/pumpfun_listen_tokens_blocksubscribe.py`](pumpfun/listen/pumpfun_listen_tokens_blocksubscribe.py) | New coins over `blockSubscribe` — whole blocks, slower, not on every provider |
| [`pumpfun/listen/pumpfun_listen_tokens_geyser.py`](pumpfun/listen/pumpfun_listen_tokens_geyser.py) | New coins over Geyser gRPC — the fastest of the executed-stream listeners |
| [`pumpfun/listen/pumpfun_listen_tokens_deshred.py`](pumpfun/listen/pumpfun_listen_tokens_deshred.py) | New coins over Geyser `SubscribeDeshred` — earlier, but pre-execution: no logs, no CreateEvent, no outcome, and router-created coins are invisible |
| [`pumpfun/listen/pumpfun_listen_tokens_pumpportal.py`](pumpfun/listen/pumpfun_listen_tokens_pumpportal.py) | New coins from PumpPortal's feed — third party, misses some coins |
| [`pumpfun/listen/pumpfun_listen_wallet_trades.py`](pumpfun/listen/pumpfun_listen_wallet_trades.py) | One wallet's bonding-curve buys and sells — copy trading |
| [`pumpfun/listen/pumpfun_capture_transactions_blocksubscribe.py`](pumpfun/listen/pumpfun_capture_transactions_blocksubscribe.py) | Save live transactions to disk, to build a fixture |
@@ -1,116 +0,0 @@
"""Listens for new Pump.fun token creations via PumpPortal WebSocket.
Usage:
uv run cookbook/pumpfun/listen/pumpfun_listen_tokens_pumpportal.py
Performance: Fast, real-time data via third-party API.
This script uses PumpPortal's WebSocket API, a third-party service that aggregates
and provides real-time Pump.fun token creation events. This provides additional
market data like initial buy amounts and market cap that aren't available in
raw blockchain data.
PumpPortal API: https://pumpportal.fun/
Note: This is a third-party service and requires trust in the data provider.
For trustless monitoring, use the direct blockchain listeners (logs, block, geyser).
"""
import asyncio
import json
import websockets
# PumpPortal WebSocket URL
WS_URL = "wss://pumpportal.fun/api/data"
def print_token_info(token_data):
"""Print token information in a consistent, user-friendly format.
Args:
token_data: Dictionary containing token fields from PumpPortal
"""
print("\n" + "=" * 80)
print("🎯 NEW TOKEN DETECTED (via PumpPortal)")
print("=" * 80)
print(f"Name: {token_data.get('name', 'N/A')}")
print(f"Symbol: {token_data.get('symbol', 'N/A')}")
print(f"Mint: {token_data.get('mint', 'N/A')}")
# PumpPortal-specific fields
if "initialBuy" in token_data:
initial_buy_sol = token_data['initialBuy']
print(f"Initial Buy: {initial_buy_sol:.6f} SOL")
if "marketCapSol" in token_data:
market_cap_sol = token_data['marketCapSol']
print(f"Market Cap: {market_cap_sol:.6f} SOL")
if "bondingCurveKey" in token_data:
print(f"Bonding Curve: {token_data['bondingCurveKey']}")
if "traderPublicKey" in token_data:
print(f"Creator: {token_data['traderPublicKey']}")
# Virtual reserves
if "vSolInBondingCurve" in token_data:
v_sol = token_data['vSolInBondingCurve']
print(f"Virtual SOL: {v_sol:.6f} SOL")
if "vTokensInBondingCurve" in token_data:
v_tokens = token_data['vTokensInBondingCurve']
print(f"Virtual Tokens: {v_tokens:,.0f}")
if "uri" in token_data:
print(f"URI: {token_data['uri']}")
if "signature" in token_data:
print(f"Signature: {token_data['signature']}")
print("=" * 80 + "\n")
async def listen_for_new_tokens():
async with websockets.connect(WS_URL) as websocket:
# Subscribe to new token events
await websocket.send(json.dumps({"method": "subscribeNewToken", "params": []}))
print("Listening for new token creations...")
while True:
try:
message = await websocket.recv()
data = json.loads(message)
if "method" in data and data["method"] == "newToken":
token_info = data.get("params", [{}])[0]
elif "signature" in data and "mint" in data:
token_info = data
else:
continue
# Print token information in consistent format
print_token_info(token_info)
except websockets.exceptions.ConnectionClosed:
print("\nWebSocket connection closed. Reconnecting...")
break
except json.JSONDecodeError:
print(f"\nReceived non-JSON message: {message}")
except Exception as e:
print(f"\nAn error occurred: {e}")
async def main():
while True:
try:
await listen_for_new_tokens()
except Exception as e:
print(f"\nAn error occurred: {e}")
print("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
if __name__ == "__main__":
asyncio.run(main())
@@ -1,38 +0,0 @@
{
"_comment": [
"Verbatim PumpPortal subscribeNewToken payloads, captured from",
"wss://pumpportal.fun/api/data on 2026-09-16. Over 90 seconds the feed",
"carried 24 pump creates and 7 bonk creates.",
"The two shapes differ: a bonk create has no name, symbol or uri, which is",
"why requiring them rejected every bonk token that ever arrived."
],
"pump": {
"signature": "55oY4Pe8WHGFxwZqyw5FE5j97pNGjEDAc2wxLXk9Ezafs4cWh1VHT3UbdfxDG9iMsB9KF9YunagvAb2HyJzVapxn",
"mint": "JA4Hrx9MitYmwBXm4XzRJ2XHSRz9SNwWcVKj3jTdpump",
"traderPublicKey": "9hmCtnEhF4f1xuy7PM4D8ZgZxRh9r5Jgs7sN8XkoMwkm",
"txType": "create",
"initialBuy": 1767779.855019,
"solAmount": 0.049506909,
"bondingCurveKey": "8AR7AAKSifEhSHBNKcXqx6k7vTxn5d2dVLPuVqNzFr3o",
"vTokensInBondingCurve": 1071232220.144981,
"vSolInBondingCurve": 30.04950690863592,
"marketCapSol": 28.051347171548862,
"name": "kick ",
"symbol": "kick",
"uri": "https://ipfs.io/ipfs/QmRrAng41mJ4dMyHmjf2j5ePL4Q1sBfPPW7t72aEuf7ddf",
"is_mayhem_mode": false,
"pool": "pump"
},
"bonk": {
"signature": "JpEno6KSxXdwfhszJMBfTagqAHZgwN3jsetuczeA28iGrY2mRvmQVwmuirqz9XB8wi1hN8SSvv76Ac8VjMqS3yi",
"traderPublicKey": "9sHpTfmVpCfP2zexRNK6j38NBchMv1RWpdXPK5NEcZan",
"txType": "create",
"mint": "AMD8XwJXgQ9WV45Wyj9yFLejxzf2J6VM1PJY8bJEjeES",
"tokensInPool": 0.54209,
"initialBuy": 999999999.45791,
"solAmount": 0,
"newTokenBalance": 0,
"marketCapSol": 6036.297497991049,
"pool": "bonk"
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ Rules for adding or changing a script under `cookbook/`.
- the two non-runnable helpers take no verb, because they do nothing
- **RPC and service names lowercase into one token**, never camelCase:
`blocksubscribe`, `logsubscribe`, `programsubscribe`, `getaccountinfo`,
`gettransaction`, `pumpportal`.
`gettransaction`.
- **Anything not specific to a launchpad belongs under `solana/`**, not `pumpfun/`.
- **Input is a command-line argument, never a constant you edit.** Every script
builds an `ArgumentParser` in `main()`: required values are positionals,
-7
View File
@@ -83,13 +83,6 @@ against mainnet.
`Program log: Instruction: Create` or `... CreateV2`. (The associated
token account program writes `Program log: Create`, with no `Instruction: `
prefix, so it never matched either way.)
- **PumpPortal's `subscribeNewToken` is not one launchpad.** Each payload names
its launchpad in `pool` (`pump`, `bonk`), and a consumer that ignores the
field treats letsbonk creates as pump.fun coins, each then reading as a
simultaneous miss by every on-chain lane. The `mint` field is also not always
the coin: on a bonk pool quoted in something other than SOL it carries the
quote mint instead, which resolves to a token with its own trading history
rather than a fresh one.
- **Resolve v0 lookup-table accounts before indexing them.** An instruction's
account indices can point past `message.account_keys` into the address lookup
table, which geyser reports in `meta.loaded_writable_addresses` then
+3 -2
View File
@@ -141,8 +141,9 @@ The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-publi
- `extreme_fast_mode` skips the curve-state price fetch. Whether it also reads the
curve for mayhem/cashback/creator/**quote_mint** depends on provenance (see the
Invariants in CLAUDE.md): CreateEvent-sourced tokens (`state_from_event`) trade on the event
data with zero RPC calls, while pumpportal/incomplete-event tokens refresh from
chain — the wrong quote mint means spending the wrong balance entirely. Event
data with zero RPC calls, while instruction-parsed and incomplete-event tokens
refresh from chain — the wrong quote mint means spending the wrong balance
entirely. Event
parsers populate `quote_mint` from `CreateEvent`, which carries `quote_mint` and
`virtual_quote_reserves` as trailing fields.
+1 -3
View File
@@ -18,11 +18,9 @@ individual scripts for a subset.
| `verify_block_null_guard.py` | a `blockSubscribe` frame with `value.block: null` is skipped, not logged as an error |
| `verify_ping_loop_close_is_quiet.py` | a WebSocket closing normally ends the ping loop quietly in every listener, while an unexpected failure is still logged |
| `verify_listener_cancellation.py` | a cancelled WebSocket listener stops, even when `websockets` reports cancellation as `AssertionError` |
| `verify_pumpportal_buy_path.py` | curve derived from the mint, unreadable curve skips the buy, curve+mint read in one slot-consistent batch |
| `verify_create_log_gate.py` | the create marker is matched as a whole log line, so a foreign `Create*` instruction is not a coin and a real create sharing its transaction with one is still detected |
| `verify_pumpportal_pool_filter.py` | `compare_listeners`' PumpPortal lane reports `pool: "pump"` coins only, still counts every frame, and tallies the launchpads it ignored |
| `verify_pumpportal_bonk_fields.py` | bonk payloads (no name/symbol/uri) still produce a `TokenInfo`; `--live` re-checks the real feed |
| `verify_extreme_fast_zero_rpc.py` | zero RPC calls between detection and submission for CreateEvent-sourced tokens |
| `verify_curve_refresh_buy_path.py` | a TokenInfo without `state_from_event` refreshes first: an unreadable curve skips the buy, and curve + mint are read in one slot-consistent batch that corrects the token program |
| `verify_cleanup_survives_shutdown.py` | shutdown cleanup runs to completion through the cancellation that triggered it, bounded, and a cleanup that hangs or raises still lets the process exit |
| `verify_token_queue_shutdown.py` | cancelling the token queue processor ends it without raising, and `task_done()` stays balanced on the paths that took an item |
| `verify_buy_result_not_lost.py` | a landed buy is never reported failed, and a reverted one never reported landed |
-4
View File
@@ -121,10 +121,6 @@ async def start_bot(config_path: str):
geyser_endpoint=cfg.get("geyser", {}).get("endpoint"),
geyser_api_token=cfg.get("geyser", {}).get("api_token"),
geyser_auth_type=cfg.get("geyser", {}).get("auth_type", "x-token"),
# PumpPortal configuration (if applicable)
pumpportal_url=cfg.get("pumpportal", {}).get(
"url", "wss://pumpportal.fun/api/data"
),
# Priority fee configuration
enable_dynamic_priority_fee=cfg.get("priority_fees", {}).get(
"enable_dynamic", False
+3 -3
View File
@@ -78,7 +78,7 @@ CONFIG_VALIDATION_RULES = [
# Valid values for enum-like fields
VALID_VALUES = {
"filters.listener_type": ["logs", "blocks", "geyser", "shreds", "pumpportal"],
"filters.listener_type": ["logs", "blocks", "geyser", "shreds"],
"cleanup.mode": ["disabled", "on_fail", "after_sell", "post_session"],
"trade.exit_strategy": ["time_based", "tp_sl", "manual"],
"platform": ["pump_fun", "lets_bonk"],
@@ -86,8 +86,8 @@ VALID_VALUES = {
# Platform-specific listener compatibility
PLATFORM_LISTENER_COMPATIBILITY = {
Platform.PUMP_FUN: ["logs", "blocks", "geyser", "shreds", "pumpportal"],
Platform.LETS_BONK: ["blocks", "geyser", "pumpportal"],
Platform.PUMP_FUN: ["logs", "blocks", "geyser", "shreds"],
Platform.LETS_BONK: ["blocks", "geyser"],
}
+6 -50
View File
@@ -17,18 +17,16 @@ class ListenerFactory:
geyser_endpoint: str | None = None,
geyser_api_token: str | None = None,
geyser_auth_type: str = "x-token",
pumpportal_url: str = "wss://pumpportal.fun/api/data",
platforms: list[Platform] | None = None,
) -> BaseTokenListener:
"""Create a token listener based on the specified type.
Args:
listener_type: Type of listener ('logs', 'blocks', 'geyser',
'shreds' or 'pumpportal')
listener_type: Type of listener ('logs', 'blocks', 'geyser'
or 'shreds')
wss_endpoint: WebSocket endpoint URL (for logs/blocks listeners)
geyser_endpoint: Geyser gRPC endpoint URL (geyser/shreds listeners)
geyser_api_token: Geyser API token (geyser/shreds listeners)
pumpportal_url: PumpPortal WebSocket URL (for pumpportal listener)
platforms: List of platforms to monitor (if None, monitor all)
Returns:
@@ -99,47 +97,10 @@ class ListenerFactory:
logger.info("Created Universal Block listener for token monitoring")
return listener
elif listener_type == "pumpportal":
from monitoring.universal_pumpportal_listener import (
UniversalPumpPortalListener,
)
# Validate that requested platforms support PumpPortal
supported_pumpportal_platforms = [Platform.PUMP_FUN, Platform.LETS_BONK]
if platforms:
unsupported = [
p for p in platforms if p not in supported_pumpportal_platforms
]
if unsupported:
logger.warning(
f"Platforms {[p.value for p in unsupported]} do not support PumpPortal"
)
# Filter to only supported platforms
filtered_platforms = [
p for p in platforms if p in supported_pumpportal_platforms
]
if not filtered_platforms:
raise ValueError(
"No supported platforms specified for PumpPortal listener"
)
platforms = filtered_platforms
listener = UniversalPumpPortalListener(
pumpportal_url=pumpportal_url,
platforms=platforms,
)
logger.info(
f"Created Universal PumpPortal listener for platforms: {[p.value for p in (platforms or supported_pumpportal_platforms)]}"
)
return listener
else:
raise ValueError(
f"Invalid listener type '{listener_type}'. "
f"Must be one of: 'logs', 'blocks', 'geyser', 'shreds', "
f"'pumpportal'"
f"Must be one of: 'logs', 'blocks', 'geyser', 'shreds'"
)
@staticmethod
@@ -149,7 +110,7 @@ class ListenerFactory:
Returns:
List of supported listener type strings
"""
return ["logs", "blocks", "geyser", "shreds", "pumpportal"]
return ["logs", "blocks", "geyser", "shreds"]
@staticmethod
def get_platform_compatible_listeners(platform: Platform) -> list[str]:
@@ -159,13 +120,8 @@ class ListenerFactory:
platform: Platform to check compatibility for
"""
if platform == Platform.PUMP_FUN:
return ["logs", "blocks", "geyser", "shreds", "pumpportal"]
return ["logs", "blocks", "geyser", "shreds"]
elif platform == Platform.LETS_BONK:
return ["blocks", "geyser", "pumpportal"] # Added pumpportal support
return ["blocks", "geyser"]
else:
return ["blocks", "geyser"] # Default universal listeners
@staticmethod
def get_pumpportal_supported_platforms() -> list[Platform]:
"""Get list of platforms that support PumpPortal listener."""
return [Platform.PUMP_FUN, Platform.LETS_BONK]
@@ -1,232 +0,0 @@
"""Universal PumpPortal listener that works with multiple platforms."""
import asyncio
import json
from collections.abc import Awaitable, Callable
import websockets
from interfaces.core import Platform, TokenInfo
from monitoring.base_listener import BaseTokenListener
from utils.logger import get_logger
logger = get_logger(__name__)
class UniversalPumpPortalListener(BaseTokenListener):
"""Universal PumpPortal listener that works with multiple platforms."""
def __init__(
self,
pumpportal_url: str = "wss://pumpportal.fun/api/data",
platforms: list[Platform] | None = None,
):
"""Initialize universal PumpPortal listener.
Args:
pumpportal_url: PumpPortal WebSocket URL
platforms: List of platforms to monitor (if None, monitor all supported platforms)
"""
super().__init__()
self.pumpportal_url = pumpportal_url
self.ping_interval = 20 # seconds
# Get platform-specific processors
from platforms.letsbonk.pumpportal_processor import LetsBonkPumpPortalProcessor
from platforms.pumpfun.pumpportal_processor import PumpFunPumpPortalProcessor
# Create processor instances
all_processors = [
PumpFunPumpPortalProcessor(),
LetsBonkPumpPortalProcessor(),
]
# Filter processors based on requested platforms
if platforms is None:
self.processors = all_processors
else:
self.processors = [p for p in all_processors if p.platform in platforms]
# Build mapping of pool names to processors for quick lookup
self.pool_to_processors: dict[str, list] = {}
for processor in self.processors:
for pool_name in processor.supported_pool_names:
if pool_name not in self.pool_to_processors:
self.pool_to_processors[pool_name] = []
self.pool_to_processors[pool_name].append(processor)
logger.info(
f"Initialized Universal PumpPortal listener for platforms: {[p.platform.value for p in self.processors]}"
)
logger.info(f"Monitoring pools: {list(self.pool_to_processors.keys())}")
async def listen_for_tokens(
self,
token_callback: Callable[[TokenInfo], Awaitable[None]],
match_string: str | None = None,
creator_address: str | None = None,
) -> None:
"""Listen for new token creations using PumpPortal WebSocket.
Args:
token_callback: Callback function for new tokens
match_string: Optional string to match in token name/symbol
creator_address: Optional creator address to filter by
"""
while True:
try:
async with websockets.connect(self.pumpportal_url) as websocket:
await self._subscribe_to_new_tokens(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
try:
while True:
token_info = await self._wait_for_token_creation(websocket)
if not token_info:
continue
logger.info(
f"New token detected: {token_info.name} ({token_info.symbol}) on {token_info.platform.value}"
)
# Apply filters
if match_string and not (
match_string.lower() in token_info.name.lower()
or match_string.lower() in token_info.symbol.lower()
):
logger.info(
f"Token does not match filter '{match_string}'. Skipping..."
)
continue
if creator_address:
creator_str = (
str(token_info.creator)
if token_info.creator
else ""
)
user_str = (
str(token_info.user) if token_info.user else ""
)
if creator_address not in [creator_str, user_str]:
logger.info(
f"Token not created by {creator_address}. Skipping..."
)
continue
await token_callback(token_info)
except websockets.exceptions.ConnectionClosed:
logger.warning(
"PumpPortal WebSocket connection closed. Reconnecting..."
)
finally:
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("PumpPortal WebSocket connection error")
logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def _subscribe_to_new_tokens(self, websocket) -> None:
"""Subscribe to new token events from PumpPortal.
Args:
websocket: Active WebSocket connection
"""
subscription_message = json.dumps({"method": "subscribeNewToken", "params": []})
await websocket.send(subscription_message)
logger.info("Subscribed to PumpPortal new token events")
async def _ping_loop(self, websocket) -> None:
"""Keep connection alive with pings.
Args:
websocket: Active WebSocket connection
"""
try:
while True:
await asyncio.sleep(self.ping_interval)
try:
pong_waiter = await websocket.ping()
await asyncio.wait_for(pong_waiter, timeout=10)
except TimeoutError:
logger.warning("Ping timeout - PumpPortal server not responding")
# Force reconnection
await websocket.close()
return
except asyncio.CancelledError:
pass
except websockets.exceptions.ConnectionClosed:
# The connection going away is the expected end of a ping loop's
# life, not a failure. `ping()` raises ConnectionClosedOK on a clean
# 1000 close and ConnectionClosedError when no close frame comes
# back; both are normal shutdowns and neither is a CancelledError,
# so without this they reach the broad handler and print a traceback
# at ERROR on every good run.
logger.debug("Ping loop ending: the connection closed")
except Exception:
logger.exception("Ping error")
async def _wait_for_token_creation(self, websocket) -> TokenInfo | None:
"""Wait for token creation event from PumpPortal.
Args:
websocket: Active WebSocket connection
Returns:
TokenInfo if a token creation is found, None otherwise
"""
try:
response = await asyncio.wait_for(websocket.recv(), timeout=30)
data = json.loads(response)
# Handle different message formats from PumpPortal
token_data = None
if "method" in data and data["method"] == "newToken":
# Standard newToken method format
params = data.get("params", [])
if params and len(params) > 0:
token_data = params[0]
elif "signature" in data and "mint" in data and "pool" in data:
# Direct token data format
token_data = data
if not token_data:
return None
# Get pool name to determine which processor to use
pool_name = token_data.get("pool", "").lower()
if pool_name not in self.pool_to_processors:
logger.debug(f"Ignoring token from unsupported pool: {pool_name}")
return None
# Try each processor that supports this pool
for processor in self.pool_to_processors[pool_name]:
if processor.can_process(token_data):
token_info = processor.process_token_data(token_data)
if token_info:
logger.debug(
f"Successfully processed token using {processor.platform.value} processor"
)
return token_info
logger.debug(f"No processor could handle token data from pool {pool_name}")
return None
except TimeoutError:
logger.debug("No data received from PumpPortal for 30 seconds")
except websockets.exceptions.ConnectionClosed:
logger.warning("PumpPortal WebSocket connection closed")
raise
except json.JSONDecodeError:
logger.exception("Failed to decode PumpPortal message")
except Exception:
logger.exception("Error processing PumpPortal WebSocket message")
return None
-2
View File
@@ -4,7 +4,6 @@ from .address_provider import LetsBonkAddressProvider
from .curve_manager import LetsBonkCurveManager
from .event_parser import LetsBonkEventParser
from .instruction_builder import LetsBonkInstructionBuilder
from .pumpportal_processor import LetsBonkPumpPortalProcessor
# Export implementations for direct use if needed
__all__ = [
@@ -12,5 +11,4 @@ __all__ = [
"LetsBonkCurveManager",
"LetsBonkEventParser",
"LetsBonkInstructionBuilder",
"LetsBonkPumpPortalProcessor",
]
@@ -1,130 +0,0 @@
"""letsbonk.fun PumpPortal event processor."""
from solders.pubkey import Pubkey
from core.pubkeys import SystemAddresses
from interfaces.core import Platform, TokenInfo
from platforms.letsbonk.address_provider import LetsBonkAddressProvider
from utils.logger import get_logger
logger = get_logger(__name__)
class LetsBonkPumpPortalProcessor:
"""PumpPortal processor for LetsBonk tokens."""
def __init__(self):
"""Initialize the processor with address provider."""
self.address_provider = LetsBonkAddressProvider()
@property
def platform(self) -> Platform:
"""Get the platform this processor handles."""
return Platform.LETS_BONK
@property
def supported_pool_names(self) -> list[str]:
"""Get the pool names this processor supports from PumpPortal."""
return ["bonk"] # PumpPortal pool name for LetsBonk/bonk pools
def can_process(self, token_data: dict) -> bool:
"""Check if this processor can handle the given token data.
Args:
token_data: Token data from PumpPortal
Returns:
True if this processor can handle the token data
"""
pool = token_data.get("pool", "").lower()
return pool in self.supported_pool_names
def process_token_data(self, token_data: dict) -> TokenInfo | None:
"""Process LetsBonk token data from PumpPortal.
Args:
token_data: Token data from PumpPortal WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
try:
# PumpPortal sends a thinner payload for bonk pools than for pump
# ones: a bonk `create` carries no name, symbol or uri. Requiring
# them rejected every bonk token that ever arrived, so only the two
# fields that cannot be derived are required; everything else the
# trade path needs comes from the mint.
mint_str = token_data.get("mint")
creator_str = token_data.get("traderPublicKey")
if not all([mint_str, creator_str]):
logger.warning(
f"Missing required fields in PumpPortal LetsBonk token data "
f"(keys present: {sorted(token_data)})"
)
return None
# Cosmetic only - used for logging and for the match_string filter.
# A bonk token from this feed has no name or symbol to match on, so
# `filters.match_string` can never match one; fetch the metadata
# from `uri` if that matters to you.
name = token_data.get("name") or ""
symbol = token_data.get("symbol") or f"{mint_str[:4]}..{mint_str[-4:]}"
uri = token_data.get("uri", "")
# Convert string addresses to Pubkey objects
mint = Pubkey.from_string(mint_str)
user = Pubkey.from_string(creator_str)
creator = user
# Derive LetsBonk-specific addresses
pool_state = self.address_provider.derive_pool_address(mint)
# For LetsBonk, vault addresses might need to be derived differently
# or provided in the PumpPortal data. For now, we'll derive them
# using the standard pattern, but this might need adjustment
additional_accounts = self.address_provider.get_additional_accounts(
# Create a minimal TokenInfo to get additional accounts
TokenInfo(
name=name,
symbol=symbol,
uri=uri,
mint=mint,
platform=Platform.LETS_BONK,
pool_state=pool_state,
user=user,
creator=creator,
base_vault=None, # Will be filled from additional_accounts
quote_vault=None, # Will be filled from additional_accounts
)
)
# Extract vault addresses if available
base_vault = additional_accounts.get("base_vault")
quote_vault = additional_accounts.get("quote_vault")
# If vaults aren't available from additional_accounts,
# we might need to derive them or leave them None
# and let the trading logic handle the derivation
return TokenInfo(
name=name,
symbol=symbol,
uri=uri,
mint=mint,
platform=Platform.LETS_BONK,
pool_state=pool_state,
base_vault=base_vault,
quote_vault=quote_vault,
user=user,
creator=creator,
# PumpPortal carries no token program, and letsbonk tokens are
# predominantly SPL Token. Without this default the universal
# builder picks Token-2022 and the buy fails with
# IncorrectProgramId on GetAccountDataSize.
token_program_id=SystemAddresses.TOKEN_PROGRAM,
)
except Exception:
logger.exception("Failed to process PumpPortal LetsBonk token data")
return None
-2
View File
@@ -4,7 +4,6 @@ from .address_provider import PumpFunAddressProvider
from .curve_manager import PumpFunCurveManager
from .event_parser import PumpFunEventParser
from .instruction_builder import PumpFunInstructionBuilder
from .pumpportal_processor import PumpFunPumpPortalProcessor
# Export implementations for direct use if needed
__all__ = [
@@ -12,5 +11,4 @@ __all__ = [
"PumpFunCurveManager",
"PumpFunEventParser",
"PumpFunInstructionBuilder",
"PumpFunPumpPortalProcessor",
]
+3 -3
View File
@@ -74,9 +74,9 @@ class PumpFunCurveManager(CurveManager):
"""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.
node and slot. A TokenInfo whose token program was guessed (the logs
CreateEvent path defaults to Token-2022) can be corrected from the mint
account's owner without a second, possibly inconsistent read.
Args:
pool_address: Address of the bonding curve
@@ -1,114 +0,0 @@
"""Pump.fun PumpPortal event processor."""
from solders.pubkey import Pubkey
from core.pubkeys import SystemAddresses
from interfaces.core import Platform, TokenInfo
from platforms.pumpfun.address_provider import PumpFunAddressProvider
from utils.logger import get_logger
logger = get_logger(__name__)
class PumpFunPumpPortalProcessor:
"""PumpPortal processor for pump.fun tokens."""
def __init__(self):
"""Initialize the processor with address provider."""
self.address_provider = PumpFunAddressProvider()
@property
def platform(self) -> Platform:
"""Get the platform this processor handles."""
return Platform.PUMP_FUN
@property
def supported_pool_names(self) -> list[str]:
"""Get the pool names this processor supports from PumpPortal."""
return ["pump"] # PumpPortal pool name for pump.fun
def can_process(self, token_data: dict) -> bool:
"""Check if this processor can handle the given token data.
Args:
token_data: Token data from PumpPortal
Returns:
True if this processor can handle the token data
"""
pool = token_data.get("pool", "").lower()
return pool in self.supported_pool_names
def process_token_data(self, token_data: dict) -> TokenInfo | None:
"""Process pump.fun token data from PumpPortal.
Args:
token_data: Token data from PumpPortal WebSocket
Returns:
TokenInfo if token creation found, None otherwise
"""
try:
# Extract required fields
name = token_data.get("name", "")
symbol = token_data.get("symbol", "")
mint_str = token_data.get("mint")
bonding_curve_str = token_data.get("bondingCurveKey")
creator_str = token_data.get("traderPublicKey") # Maps to user field
uri = token_data.get("uri", "")
# Unused PumpPortal fields: initialBuy, solAmount,
# vSolInBondingCurve, vTokensInBondingCurve, marketCapSol, signature.
if not all([name, symbol, mint_str, bonding_curve_str, creator_str]):
logger.warning("Missing required fields in PumpPortal token data")
return None
# Convert string addresses to Pubkey objects
mint = Pubkey.from_string(mint_str)
user = Pubkey.from_string(creator_str)
# Derive the bonding curve from the mint rather than trusting the
# payload: PumpPortal's bondingCurveKey has been observed pointing
# at a different mint's curve, 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
# PumpPortal does not distinguish Token from Token-2022, so default
# to Token-2022, which create_v2 uses for all new coins.
token_program_id = SystemAddresses.TOKEN_2022_PROGRAM
associated_bonding_curve = (
self.address_provider.derive_associated_bonding_curve(
mint, bonding_curve, token_program_id
)
)
creator_vault = self.address_provider.derive_creator_vault(creator)
return TokenInfo(
name=name,
symbol=symbol,
uri=uri,
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=associated_bonding_curve,
user=user,
creator=creator,
creator_vault=creator_vault,
token_program_id=token_program_id,
)
except Exception:
logger.exception("Failed to process PumpPortal token data")
return None
+15 -15
View File
@@ -54,7 +54,7 @@ async def _read_pool_state_with_retry(
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).
program comes back for free (the logs CreateEvent path can only guess it).
Args:
curve_manager: Platform curve manager
@@ -99,12 +99,11 @@ async def _read_pool_state_with_retry(
def _refresh_quote_mint(token_info: TokenInfo, pool_state: dict) -> Pubkey:
"""Sync token_info's quote asset from freshly-read curve state.
Listeners do not all carry quote_mint (pumpportal carries none of the
per-coin flags), and the curve is authoritative. `quote_token_program_id` is
re-derived alongside it, because a stale non-None value would win the `or` in
`AddressProvider.resolve_quote` and the corrected program would never be
looked up. `cached_quote_token_program` is a synchronous dict read, so this
stays zero-RPC on the extreme_fast_mode path.
Listeners do not all carry quote_mint, and the curve is authoritative.
`quote_token_program_id` is re-derived alongside it, because a stale non-None
value would win the `or` in `AddressProvider.resolve_quote` and the corrected
program would never be looked up. `cached_quote_token_program` is a
synchronous dict read, so this stays zero-RPC on the extreme_fast_mode path.
Args:
token_info: Token information, mutated in place
@@ -467,10 +466,11 @@ class PlatformAwareBuyer(Trader):
) -> 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) or ConstraintSeeds
(0x7d6). PumpPortal also notifies before the curve is readable on a
lagging node, so the read retries within curve_refresh_budget.
Instruction-parsed TokenInfo takes creator from user-supplied args and
an old-format CreateEvent carries no mayhem/cashback/quote_mint; a buy
built on either is rejected with NotAuthorized (0x1770) or
ConstraintSeeds (0x7d6). A lagging node may not serve the curve yet, so
the read retries within curve_refresh_budget.
Args:
token_info: Token information, mutated in place on success
@@ -484,7 +484,7 @@ class PlatformAwareBuyer(Trader):
try:
pool_address = self._get_pool_address(token_info, address_provider)
# Geyser/logs fire on processed, so the curve is usually readable in
# the same slot; pumpportal can race the on-chain commit.
# the same slot.
pool_state, fresh_token_program = await _read_pool_state_with_retry(
curve_manager,
pool_address,
@@ -528,9 +528,9 @@ class PlatformAwareBuyer(Trader):
) -> 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
A CreateEvent does not say which create variant ran, so the logs path
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 is re-derived under the corrected program.
Args:
-2
View File
@@ -151,7 +151,6 @@ class UniversalTrader:
geyser_endpoint: str | None = None,
geyser_api_token: str | None = None,
geyser_auth_type: str = "x-token",
pumpportal_url: str = "wss://pumpportal.fun/api/data",
# Trading configuration
extreme_fast_mode: bool = False,
extreme_fast_token_amount: int = 30,
@@ -270,7 +269,6 @@ class UniversalTrader:
geyser_endpoint=geyser_endpoint,
geyser_api_token=geyser_api_token,
geyser_auth_type=geyser_auth_type,
pumpportal_url=pumpportal_url,
platforms=[self.platform], # Only listen for our platform
)
@@ -45,7 +45,6 @@ NO_INPUT = {
"pumpfun_listen_tokens_blocksubscribe.py",
"pumpfun_listen_tokens_geyser.py",
"pumpfun_listen_tokens_deshred.py",
"pumpfun_listen_tokens_pumpportal.py",
"pumpfun_listen_migrations_logsubscribe.py",
"pumpfun_listen_migrations_programsubscribe.py",
"pumpfun_capture_transactions_blocksubscribe.py",
@@ -1,18 +1,19 @@
"""Verify the pumpportal-sourced buy path reads its own curve state.
"""Verify a buy off unflagged TokenInfo reads its own curve state.
A TokenInfo the listener did not mark `state_from_event` carries guessed
creator/flags/token program, so the buy path refreshes them from chain first.
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, which has been observed stale.
B. In extreme_fast_mode, a buy is SKIPPED when the curve state cannot be
A. 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).
B. The curve refresh reads curve + mint in one slot-consistent
getMultipleAccounts round trip and corrects token_program_id (the logs
CreateEvent path guesses Token-2022; legacy coins are SPL Token).
Usage:
uv run tests/regression/verify_pumpportal_buy_path.py
uv run tests/regression/verify_curve_refresh_buy_path.py
"""
import asyncio
@@ -31,16 +32,12 @@ 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()
@@ -87,8 +84,8 @@ def _fabricated_curve_bytes(
return account + bytes(curve_len - len(account))
def _pumpportal_token_info(**overrides: object) -> TokenInfo:
"""TokenInfo shaped like the pumpportal processor's output."""
def _unflagged_token_info(**overrides: object) -> TokenInfo:
"""TokenInfo shaped like a listener decode that set no state_from_event."""
bonding_curve = PROVIDER.derive_pool_address(MINT)
defaults: dict = {
"name": "T",
@@ -159,28 +156,8 @@ def _make_buyer(client: _StubClient, **kwargs: float) -> PlatformAwareBuyer:
)
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."""
def check_a_skips_when_curve_unreadable() -> bool:
"""A: refresh failure -> buy skipped, nothing submitted."""
class NeverReadable:
async def get_pool_state(
@@ -195,15 +172,15 @@ def check_b_skips_when_curve_unreadable() -> bool:
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
NeverReadable()
)
result = asyncio.run(buyer.execute(_pumpportal_token_info()))
result = asyncio.run(buyer.execute(_unflagged_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."""
def check_a_still_buys_when_curve_readable() -> bool:
"""A guard: a readable curve still reaches submission."""
class Readable:
async def get_pool_state(
@@ -223,7 +200,7 @@ def check_b_still_buys_when_curve_readable() -> bool:
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
Readable()
)
asyncio.run(buyer.execute(_pumpportal_token_info()))
asyncio.run(buyer.execute(_unflagged_token_info()))
ok = len(client.sent) == 1
if not ok:
print(f" submissions={len(client.sent)} (expected 1)")
@@ -231,7 +208,7 @@ def check_b_still_buys_when_curve_readable() -> bool:
def _check_curve_manager_batch_read(curve_len: int) -> bool:
"""C: curve manager reads curve + mint owner in one batch call.
"""B: curve manager reads curve + mint owner in one batch call.
Args:
curve_len: Bonding curve account length to fabricate (125 or 151)
@@ -282,18 +259,18 @@ def _check_curve_manager_batch_read(curve_len: int) -> bool:
return ok
def check_c_curve_manager_batch_read() -> bool:
"""C: curve manager decodes a curve at its as-created length (125 bytes)."""
def check_b_curve_manager_batch_read() -> bool:
"""B: curve manager decodes a curve at its as-created length (125 bytes)."""
return _check_curve_manager_batch_read(125)
def check_c_curve_manager_batch_read_extended_curve() -> bool:
"""C: curve manager decodes a curve extend_account has grown to 151 bytes."""
def check_b_curve_manager_batch_read_extended_curve() -> bool:
"""B: curve manager decodes a curve extend_account has grown to 151 bytes."""
return _check_curve_manager_batch_read(151)
def check_c_buyer_corrects_token_program() -> bool:
"""C: buyer applies the batch-read token program and re-derives the ATA."""
def check_b_buyer_corrects_token_program() -> bool:
"""B: buyer applies the batch-read token program and re-derives the ATA."""
class BatchCurveManager:
async def get_pool_state_and_token_program(
@@ -322,7 +299,7 @@ def check_c_buyer_corrects_token_program() -> bool:
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
BatchCurveManager()
)
token_info = _pumpportal_token_info()
token_info = _unflagged_token_info()
asyncio.run(buyer.execute(token_info))
expected_ata = PROVIDER.derive_associated_bonding_curve(
MINT, token_info.bonding_curve, SystemAddresses.TOKEN_PROGRAM
@@ -341,23 +318,19 @@ def check_c_buyer_corrects_token_program() -> bool:
def main() -> int:
checks = [
("A: unreadable curve -> buy skipped", check_a_skips_when_curve_unreadable),
("A: readable curve -> buy proceeds", check_a_still_buys_when_curve_readable),
(
"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,
"B: curve manager batch-reads curve + mint owner",
check_b_curve_manager_batch_read,
),
(
"C: curve manager batch-reads a 151-byte extended curve",
check_c_curve_manager_batch_read_extended_curve,
"B: curve manager batch-reads a 151-byte extended curve",
check_b_curve_manager_batch_read_extended_curve,
),
(
"C: buyer corrects token_program_id and ATA",
check_c_buyer_corrects_token_program,
"B: buyer corrects token_program_id and ATA",
check_b_buyer_corrects_token_program,
),
]
failed = 0
@@ -4,7 +4,8 @@ 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),
the 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 it.
pre-buy curve refresh. A TokenInfo decoded from the create instruction carries
none of that, so it keeps the refresh.
Offline machine checks, no network and no funds moved:
@@ -14,10 +15,9 @@ Offline machine checks, no network and no funds moved:
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.
6. An event-sourced buy submits with ZERO curve-manager/RPC calls.
7. A buy from a TokenInfo without the flag still refreshes from chain.
8. trade.trust_create_event=false forces the refresh even for event data.
Usage:
uv run tests/regression/verify_extreme_fast_zero_rpc.py
@@ -40,9 +40,6 @@ 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
@@ -305,29 +302,6 @@ def check_block_parser_marks_event_state() -> bool:
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()
@@ -345,7 +319,7 @@ def check_event_sourced_buy_is_zero_rpc() -> bool:
return ok
def check_pumpportal_buy_still_refreshes() -> bool:
def check_unflagged_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)
@@ -405,9 +379,8 @@ def main() -> int:
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),
("unflagged buy still refreshes", check_unflagged_buy_still_refreshes),
("trust_create_event=false forces refresh", check_trust_flag_forces_refresh),
]
failed = 0
@@ -49,14 +49,10 @@ sys.path.insert(0, str(PROJECT_ROOT / "src"))
import monitoring # noqa: E402
from monitoring.universal_block_listener import UniversalBlockListener # noqa: E402
from monitoring.universal_logs_listener import UniversalLogsListener # noqa: E402
from monitoring.universal_pumpportal_listener import ( # noqa: E402
UniversalPumpPortalListener,
)
LISTENERS = (
UniversalLogsListener,
UniversalBlockListener,
UniversalPumpPortalListener,
)
NORMAL_CLOSE = Close(1000, "OK")
@@ -1,287 +0,0 @@
"""Verify the PumpPortal listener accepts lets_bonk tokens.
`listener_type: "pumpportal"` on `platform: "lets_bonk"` detected nothing, while
the same feed kept the pump.fun side busy. The tokens were arriving and parsing
fine; they failed a required-field check in
`LetsBonkPumpPortalProcessor.process_token_data` before a `TokenInfo` was built,
showing up only as a repeating "Missing required fields".
The two payloads are not the same shape: a bonk create carries no `name`, no
`symbol` and no `uri`, where a pump create has all three, so requiring name and
symbol rejected every bonk token that ever arrived.
Only `mint` and `traderPublicKey` cannot be derived from something else, so only
those two are required. `name` and `symbol` feed logging and the
`filters.match_string` filter, so a bonk token gets a mint-derived placeholder
symbol and an empty name — pinned by the last check below.
Offline machine checks against committed fixtures, no network and no funds
moved. `--live` adds a read-only 90-second listen against the real feed:
1. A real bonk create payload produces a TokenInfo.
2. Its pool_state matches a pool address derived independently from the mint.
3. A real pump create payload is unaffected.
4. A payload missing mint or traderPublicKey is still rejected.
5. Each payload is routed to the processor for its own pool.
6. A bonk token gets a non-empty symbol, so logs stay readable, and
match_string cannot match it - a documented consequence, not an accident.
Usage:
uv run tests/regression/verify_pumpportal_bonk_fields.py
uv run tests/regression/verify_pumpportal_bonk_fields.py --live
"""
import asyncio
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
import websockets # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from platforms.letsbonk.address_provider import LetsBonkAddressProvider # noqa: E402
from platforms.letsbonk.pumpportal_processor import ( # noqa: E402
LetsBonkPumpPortalProcessor,
)
from platforms.pumpfun.pumpportal_processor import ( # noqa: E402
PumpFunPumpPortalProcessor,
)
FIXTURE = (
PROJECT_ROOT
/ "cookbook"
/ "pumpfun"
/ "listen"
/ "raw_new_token_from_pumpportal.json"
)
PUMPPORTAL_WS_URL = "wss://pumpportal.fun/api/data"
LIVE_LISTEN_SECONDS = 90
# The fields a bonk create does not carry. Asserted explicitly so a change in
# the feed that starts sending them is noticed rather than silently absorbed.
ABSENT_FROM_BONK = ("name", "symbol", "uri")
def _load_fixtures() -> dict:
"""Load the committed PumpPortal payloads.
Returns:
Mapping of pool name to raw payload
"""
with FIXTURE.open() as handle:
return json.load(handle)
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
"""Print one check result.
Args:
label: What was checked
passed: Whether it held
detail: Evidence behind the verdict
Returns:
The value of `passed`
"""
print(f"{'PASS' if passed else 'FAIL'} {label}\n {detail}")
return passed
def check_bonk_payload_is_accepted(fixtures: dict) -> bool:
"""A real bonk create produces a TokenInfo.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
payload = fixtures["bonk"]
missing = [field for field in ABSENT_FROM_BONK if field not in payload]
token_info = LetsBonkPumpPortalProcessor().process_token_data(payload)
return _check(
"a real bonk create payload produces a TokenInfo",
token_info is not None and missing == list(ABSENT_FROM_BONK),
f"payload carries no {missing}; "
f"result: {'TokenInfo' if token_info else 'None'} "
f"mint={token_info.mint if token_info else '-'}",
)
def check_pool_state_is_derived(fixtures: dict) -> bool:
"""The derived pool address matches an independent derivation.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
payload = fixtures["bonk"]
token_info = LetsBonkPumpPortalProcessor().process_token_data(payload)
expected = LetsBonkAddressProvider().derive_pool_address(
Pubkey.from_string(payload["mint"])
)
return _check(
"the pool address comes from the mint, not from the payload",
token_info is not None and token_info.pool_state == expected,
f"pool_state={token_info.pool_state if token_info else '-'}, "
f"derived from mint={expected}",
)
def check_pump_payload_unaffected(fixtures: dict) -> bool:
"""The pump.fun side still works.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
payload = fixtures["pump"]
token_info = PumpFunPumpPortalProcessor().process_token_data(payload)
return _check(
"a real pump create payload is unaffected",
token_info is not None and token_info.symbol == payload["symbol"],
f"symbol={token_info.symbol if token_info else '-'}, "
f"bonding_curve={token_info.bonding_curve if token_info else '-'}",
)
def check_undeliverable_payloads_rejected(fixtures: dict) -> bool:
"""Payloads that cannot be traded are still rejected.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
processor = LetsBonkPumpPortalProcessor()
no_mint = {k: v for k, v in fixtures["bonk"].items() if k != "mint"}
no_creator = {k: v for k, v in fixtures["bonk"].items() if k != "traderPublicKey"}
return _check(
"a payload missing mint or traderPublicKey is still rejected",
processor.process_token_data(no_mint) is None
and processor.process_token_data(no_creator) is None,
f"without mint: {processor.process_token_data(no_mint)}, "
f"without traderPublicKey: {processor.process_token_data(no_creator)}",
)
def check_routing(fixtures: dict) -> bool:
"""Each payload is claimed by the processor for its own pool.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
bonk, pump = LetsBonkPumpPortalProcessor(), PumpFunPumpPortalProcessor()
return _check(
"each payload is routed to the processor for its own pool",
bonk.can_process(fixtures["bonk"])
and not bonk.can_process(fixtures["pump"])
and pump.can_process(fixtures["pump"])
and not pump.can_process(fixtures["bonk"]),
f"bonk processor claims bonk={bonk.can_process(fixtures['bonk'])} "
f"pump={bonk.can_process(fixtures['pump'])}; pump processor claims "
f"pump={pump.can_process(fixtures['pump'])} "
f"bonk={pump.can_process(fixtures['bonk'])}",
)
def check_placeholder_symbol(fixtures: dict) -> bool:
"""A bonk token is loggable, and match_string cannot match it.
Args:
fixtures: Committed payloads
Returns:
Whether the check passed
"""
payload = fixtures["bonk"]
token_info = LetsBonkPumpPortalProcessor().process_token_data(payload)
mint = payload["mint"]
expected_symbol = f"{mint[:4]}..{mint[-4:]}"
return _check(
"the symbol is a mint-derived placeholder, so match_string cannot match",
token_info is not None
and token_info.symbol == expected_symbol
and token_info.name == "",
f"symbol={token_info.symbol if token_info else '-'!r} "
f"name={token_info.name if token_info else '-'!r} - "
f"filters.match_string has nothing to match on for bonk tokens",
)
async def check_live_feed() -> bool:
"""Listen to the real feed and confirm bonk creates now yield TokenInfo.
Returns:
Whether the check passed
"""
bonk_seen = bonk_accepted = pump_seen = 0
processor = LetsBonkPumpPortalProcessor()
print(f"\nListening to {PUMPPORTAL_WS_URL} for {LIVE_LISTEN_SECONDS}s...")
async with websockets.connect(PUMPPORTAL_WS_URL) as websocket:
await websocket.send(json.dumps({"method": "subscribeNewToken"}))
try:
async with asyncio.timeout(LIVE_LISTEN_SECONDS):
while True:
payload = json.loads(await websocket.recv())
if "mint" not in payload:
continue
if processor.can_process(payload):
bonk_seen += 1
if processor.process_token_data(payload) is not None:
bonk_accepted += 1
elif (payload.get("pool") or "").lower() == "pump":
pump_seen += 1
except TimeoutError:
pass
return _check(
"live feed: every bonk create is accepted",
bonk_seen > 0 and bonk_accepted == bonk_seen,
f"{bonk_accepted}/{bonk_seen} bonk create(s) accepted over "
f"{LIVE_LISTEN_SECONDS}s ({pump_seen} pump create(s) alongside)",
)
async def main() -> None:
"""Run every check and exit non-zero if any failed."""
live = "--live" in sys.argv
print("=" * 72)
print("Verifying PumpPortal lets_bonk detection")
print("=" * 72)
fixtures = _load_fixtures()
results = [
check_bonk_payload_is_accepted(fixtures),
check_pool_state_is_derived(fixtures),
check_pump_payload_unaffected(fixtures),
check_undeliverable_payloads_rejected(fixtures),
check_routing(fixtures),
check_placeholder_symbol(fixtures),
]
if live:
results.append(await check_live_feed())
else:
print("\n(skipping the live feed check; pass --live to include it)")
print("\n" + "=" * 72)
if all(results):
print(f"ALL {len(results)} CHECKS PASSED")
else:
print(f"{results.count(False)}/{len(results)} CHECKS FAILED")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
@@ -1,211 +0,0 @@
"""Verify the compare_listeners PumpPortal lane reports pump.fun coins only.
`tools/compare_listeners.py` races five lanes for coverage. Four of them
subscribe to the pump.fun program; the fifth reads PumpPortal's
`subscribeNewToken`, which aggregates launchpads and names each coin's one in
the payload's `pool` field. The lane ignored that field, so every letsbonk
create entered the race as though it were a pump.fun coin — detected by
PumpPortal and by nobody else, because nobody else was listening for it, and so
scored as a simultaneous miss by all four on-chain lanes.
Offline machine checks, no network and no funds moved. A stub websocket replays
scripted payloads into the real `listen_pumpportal`:
1. A `pool: "pump"` payload is reported.
2. A `pool: "bonk"` payload is not. This is the check that fails before the
fix.
3. A payload naming no pool is not reported either — an unknown launchpad is
not assumed to be pump.fun.
4. Every payload still counts towards the lane's frame total, so filtering
them out does not read as a starved lane.
5. The ignored launchpads are tallied on the way out, so a run says what it
dropped rather than dropping it silently.
Usage:
uv run tests/regression/verify_pumpportal_pool_filter.py
"""
import asyncio
import contextlib
import importlib.util
import io
import json
import sys
from collections.abc import Iterable
from pathlib import Path
from types import ModuleType
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
PUMP_PAYLOAD = {
"signature": "1" * 88,
"mint": "PumpCoin1111111111111111111111111111111111",
"traderPublicKey": "Trader11111111111111111111111111111111111",
"txType": "create",
"name": "Pump Coin",
"symbol": "PUMP",
"pool": "pump",
}
BONK_PAYLOAD = {
"signature": "2" * 88,
"mint": "BonkCoin1111111111111111111111111111111111",
"traderPublicKey": "Trader11111111111111111111111111111111111",
"txType": "create",
"pool": "bonk",
}
# A bonk create carries no name, symbol or uri; a payload naming no pool at all
# is the shape a launchpad added to the feed later would arrive in.
POOLLESS_PAYLOAD = {
"signature": "3" * 88,
"mint": "Unknown11111111111111111111111111111111111",
"traderPublicKey": "Trader11111111111111111111111111111111111",
"txType": "create",
}
PAYLOADS = (PUMP_PAYLOAD, BONK_PAYLOAD, POOLLESS_PAYLOAD)
def _load_compare_listeners() -> ModuleType:
"""Import tools/compare_listeners.py by path; it is not an installed module."""
path = PROJECT_ROOT / "tools" / "compare_listeners.py"
spec = importlib.util.spec_from_file_location("compare_listeners", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class StubWebSocket:
"""Serves the scripted payloads, then cancels to end the lane."""
def __init__(self, payloads: Iterable[dict]) -> None:
self.payloads = list(payloads)
self.sent: list[dict] = []
async def send(self, message: str) -> None:
self.sent.append(json.loads(message))
async def recv(self) -> str:
if not self.payloads:
raise asyncio.CancelledError
return json.dumps(self.payloads.pop(0))
async def __aenter__(self) -> "StubWebSocket":
return self
async def __aexit__(self, *_exc: object) -> bool:
return False
class RecordingTracker:
"""Stands in for QueueTracker, keeping what the lane reported."""
def __init__(self) -> None:
self.tokens: list[tuple] = []
self.frames = 0
def add_token(
self,
mint: str,
name: str,
symbol: str,
lane: str,
timestamp: float,
) -> None:
self.tokens.append((mint, name, symbol, lane, timestamp))
def increment_messages(self, _lane: str) -> None:
self.frames += 1
def _run_lane() -> tuple[RecordingTracker, str]:
"""Drive the real lane over the scripted payloads.
Returns:
Tuple of (tracker holding what was reported, the lane's stdout)
"""
module = _load_compare_listeners()
tracker = RecordingTracker()
module.websockets.connect = lambda *_a, **_kw: StubWebSocket(PAYLOADS)
output = io.StringIO()
async def drive() -> None:
with contextlib.suppress(asyncio.CancelledError):
await module.listen_pumpportal("pumpportal", tracker, set())
with contextlib.redirect_stdout(output):
asyncio.run(drive())
return tracker, output.getvalue()
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}")
return passed
def main() -> None:
"""Run every check and exit non-zero if any failed."""
print("Verifying the PumpPortal lane's launchpad filter")
tracker, output = _run_lane()
reported = {token[0] for token in tracker.tokens}
print("\n1. A pump.fun coin is reported")
results = [
_check(
PUMP_PAYLOAD["mint"],
PUMP_PAYLOAD["mint"] in reported,
"reported" if PUMP_PAYLOAD["mint"] in reported else "missing",
)
]
print("\n2. A letsbonk coin is not")
results.append(
_check(
BONK_PAYLOAD["mint"],
BONK_PAYLOAD["mint"] not in reported,
"ignored"
if BONK_PAYLOAD["mint"] not in reported
else "counted against the on-chain lanes",
)
)
print("\n3. A payload naming no pool is not reported")
results.append(
_check(
POOLLESS_PAYLOAD["mint"],
POOLLESS_PAYLOAD["mint"] not in reported,
"ignored" if POOLLESS_PAYLOAD["mint"] not in reported else "reported",
)
)
print("\n4. Every payload still counts as a frame")
results.append(
_check(
"frames",
tracker.frames == len(PAYLOADS),
f"{tracker.frames} of {len(PAYLOADS)}",
)
)
print("\n5. The ignored launchpads are tallied")
tallied = "ignored coins from other launchpads" in output and "bonk=1" in output
results.append(
_check(
"shutdown tally",
tallied,
next(
(line for line in output.splitlines() if "launchpads" in line),
"not printed",
),
)
)
print(f"\n{sum(results)}/{len(results)} checks passed")
sys.exit(0 if all(results) else 1)
if __name__ == "__main__":
main()
+3 -106
View File
@@ -1,6 +1,6 @@
"""Race the pump.fun token-detection methods against each other.
Compares five listeners in real time and reports which detects each coin first,
Compares four listeners in real time and reports which detects each coin first,
with per-method latency, message counts and coverage:
1. `blockSubscribe` — whole blocks mentioning the program; slowest.
@@ -11,13 +11,11 @@ with per-method latency, message counts and coverage:
https://docs.triton.one/rpc-pool/grpc-subscriptions
4. Geyser `SubscribeDeshred` — the same endpoint pre-execution; earliest, and
the only lane that cannot see a coin created through a router.
5. PumpPortal — third-party aggregated WebSocket feed, pre-processed.
This races on *coverage*: which lane saw a given mint, and how much later than
the winner. A lane that never reports a mint is the finding, not a rounding
error — `shreds` misses router creates and PumpPortal samples its feed. Every
lane is restricted to pump.fun coins, PumpPortal included: it aggregates other
launchpads, and their coins would read as a miss by all four on-chain lanes.
error — `shreds` misses router creates. Every lane is restricted to pump.fun
coins.
`tools/compare_deshred_latency.py` answers the other question, pairing deshred
against executed per signature to measure the lead itself.
@@ -42,7 +40,6 @@ import os
import struct
import sys
import time
from collections import Counter
from pathlib import Path
from queue import Empty
@@ -95,14 +92,6 @@ STARVED_LANE_SHARE = 0.6
# final frame count is not lost to the shutdown.
LANE_SHUTDOWN_GRACE = 10
# PumpPortal WebSocket endpoint (third-party service)
PUMPPORTAL_WS_URL = "wss://pumpportal.fun/api/data"
# PumpPortal aggregates several launchpads and names each coin's one in `pool`.
# The four on-chain lanes subscribe to the pump.fun program alone, so anything
# else is a coin they are not watching for, not one they missed.
PUMPPORTAL_POOL = "pump"
# The two log lines the pump.fun program writes when it creates a coin.
CREATE_LOG = "Program log: Instruction: Create"
CREATE_V2_LOG = "Program log: Instruction: CreateV2"
@@ -1003,94 +992,6 @@ async def listen_deshred_grpc(
await asyncio.sleep(5)
async def listen_pumpportal(provider_name, tracker, known_tokens=None):
"""Listen for new tokens via PumpPortal WebSocket.
Only pump.fun coins are reported. `subscribeNewToken` also carries
letsbonk creates, and counting those against lanes that never subscribed to
that program turns a launchpad this race does not cover into a hole in
every on-chain lane at once.
"""
if known_tokens is None:
known_tokens = set()
lane = f"{provider_name}_pumpportal"
skipped_by_pool = Counter()
try:
await _pumpportal_loop(lane, tracker, known_tokens, skipped_by_pool)
finally:
if skipped_by_pool:
# A payload naming no pool sorts and prints as "unknown" rather than
# being left out: an unrecognised launchpad is the case worth seeing.
tally = ", ".join(
f"{pool or 'unknown'}={count}"
for pool, count in sorted(
skipped_by_pool.items(), key=lambda item: str(item[0])
)
)
print(f"[INFO] {lane} ignored coins from other launchpads: {tally}")
async def _pumpportal_loop(lane, tracker, known_tokens, skipped_by_pool):
"""Drain the PumpPortal feed until cancelled, reconnecting as needed."""
while True:
try:
print("[INFO] Connecting to PumpPortal WebSocket...")
async with websockets.connect(PUMPPORTAL_WS_URL) as websocket:
# Subscribe to new token events
await websocket.send(
json.dumps({"method": "subscribeNewToken", "params": []})
)
print(f"[INFO] PumpPortal listener active for {lane}")
while True:
try:
message = await websocket.recv()
data = json.loads(message)
tracker.increment_messages(lane)
# Extract token information
token_info = None
if "method" in data and data["method"] == "newToken":
token_info = data.get("params", [{}])[0]
elif "signature" in data and "mint" in data:
token_info = data
if not token_info:
continue
# Get token details
mint = token_info.get("mint")
name = token_info.get("name", "Unknown")
symbol = token_info.get("symbol", "UNK")
if not mint:
continue
pool = token_info.get("pool")
if pool != PUMPPORTAL_POOL:
skipped_by_pool[pool] += 1
continue
if mint in known_tokens:
continue
# Record the token detection
ts = time.time()
tracker.add_token(mint, name, symbol, lane, ts)
known_tokens.add(mint)
except Exception as e:
print(f"[ERROR] PumpPortal listener for {lane}: {e}")
break
except Exception as e:
print(f"[ERROR] Connection error in PumpPortal listener for {lane}: {e}")
print("[INFO] Reconnecting in 5 seconds...")
await asyncio.sleep(5)
# ============ MAIN TEST RUNNER ============
# Each lane is built in the child process, so the parent only ships a kind and
@@ -1104,7 +1005,6 @@ LANE_COROUTINES = {
"shreds": lambda a, t, k: listen_deshred_grpc(
a["endpoint"], a["token"], a["provider"], t, k
),
"pumpportal": lambda a, t, k: listen_pumpportal(a["provider"], t, k),
}
@@ -1179,9 +1079,6 @@ def build_lane_specs(providers):
}
lanes.append(("geyser", grpc_args))
lanes.append(("shreds", dict(grpc_args)))
# PumpPortal is a single third-party feed, not a per-provider lane.
lanes.append(("pumpportal", {"provider": "pumpportal"}))
return lanes
+5 -4
View File
@@ -2,14 +2,15 @@
WARNING: this submits real transactions and spends real funds.
For each listener (geyser, logs, blocks, pumpportal) it detects a live token,
For each listener (geyser, logs, blocks, shreds) it detects a live token,
buys it, sells it, then closes the base-token ATA so the ~0.002 SOL of rent is
reclaimed rather than stranded. Tests run sequentially so the same funds are
recycled across listeners.
Each listener exercises a different event-parsing path into the same v2 trade
code, which is the point: pumpportal in particular carries no mayhem/cashback/
quote_mint flags, so it relies entirely on the on-chain curve refresh.
code, which is the point: a listener that decodes an instruction rather than a
CreateEvent carries no canonical creator, so it relies on the on-chain curve
refresh.
Usage:
uv run tools/live_listener_matrix.py --yes
@@ -89,7 +90,7 @@ def make_listener(listener_type: str):
"""Build a listener of the given type wired to this project's endpoints.
Args:
listener_type: One of geyser, logs, blocks, pumpportal
listener_type: One of geyser, logs, blocks, shreds
Returns:
Configured listener instance