mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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:
co-authored by
Claude Opus 5
parent
ca1f8919e6
commit
b7aae5e333
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+33
-60
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user