mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
fix(platforms,tools,cookbook): match the create marker whole, race pump.fun coins only (#231)
The logs create gate tested the marker as a substring. Anchor writes `Instruction: <Name>` for every program, so it also accepted CreateTokenAccount, CreatePool and the rest from whichever programs shared the transaction. The CreateEvent check downstream rejected them, but a CreateTokenAccount veto had been added to suppress them and, being a whole-transaction test, it also dropped genuine creates whose transaction opened a token account. Matching the marker lines whole retires the veto. compare_listeners' PumpPortal lane ignored the payload's `pool` field, so letsbonk creates raced as pump.fun coins against lanes that subscribe to the pump.fun program alone. The lane now reports `pool: "pump"` only. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33b456901c
commit
ca1f8919e6
@@ -192,10 +192,12 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
|
||||
`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.** 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. The bonk trade path past
|
||||
- **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).
|
||||
- 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
|
||||
|
||||
@@ -83,6 +83,12 @@ def print_token_info(token_data, signature=None):
|
||||
# base64 "Program data:" log line, which the RPC has already decoded for us.
|
||||
CREATE_EVENT_DISCRIMINATOR = bytes([27, 114, 169, 77, 222, 235, 99, 118])
|
||||
|
||||
# The two log lines the pump.fun program writes when it creates a coin.
|
||||
CREATE_INSTRUCTION_LOGS = (
|
||||
"Program log: Instruction: Create",
|
||||
"Program log: Instruction: CreateV2",
|
||||
)
|
||||
|
||||
# The CreateEvent layout, in order. Trailing fields were appended by later
|
||||
# upgrades, so a shorter payload stops early rather than failing.
|
||||
_CREATE_EVENT_FIELDS = [
|
||||
@@ -157,7 +163,10 @@ def find_create_event(logs):
|
||||
Returns:
|
||||
The decoded event, or None if the transaction created no coin
|
||||
"""
|
||||
if not any("Program log: Instruction: Create" in log for log in logs):
|
||||
# Matched whole: Anchor writes `Instruction: <Name>` for every program, so
|
||||
# a substring test also accepts CreateTokenAccount, CreatePool and the rest
|
||||
# from whichever programs share the transaction.
|
||||
if not any(log in CREATE_INSTRUCTION_LOGS for log in logs):
|
||||
return None
|
||||
for log in logs:
|
||||
if "Program data:" not in log:
|
||||
|
||||
@@ -54,6 +54,10 @@ ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string(
|
||||
# Calculated using the first 8 bytes of sha256("event:CreateEvent")
|
||||
CREATE_EVENT_DISCRIMINATOR = bytes([27, 114, 169, 77, 222, 235, 99, 118])
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
def print_token_info(
|
||||
token_data, signature=None, associated_bonding_curve: str | None = None
|
||||
@@ -246,15 +250,14 @@ async def listen_for_new_tokens():
|
||||
log_data = data["params"]["result"]["value"]
|
||||
logs = log_data.get("logs", [])
|
||||
|
||||
# Detect both Create and CreateV2 instructions
|
||||
is_create = any(
|
||||
"Program log: Instruction: Create" in log
|
||||
for log in logs
|
||||
)
|
||||
is_create_v2 = any(
|
||||
"Program log: Instruction: CreateV2" in log
|
||||
for log in logs
|
||||
)
|
||||
# Detect both Create and CreateV2 instructions.
|
||||
# Matched whole: Anchor writes
|
||||
# `Instruction: <Name>` for every program, so a
|
||||
# substring test also accepts CreateTokenAccount,
|
||||
# CreatePool and the rest from whichever programs
|
||||
# share the transaction.
|
||||
is_create = CREATE_LOG in logs
|
||||
is_create_v2 = CREATE_V2_LOG in logs
|
||||
|
||||
if is_create or is_create_v2:
|
||||
for log in logs:
|
||||
|
||||
@@ -298,6 +298,12 @@ async def buy_token(
|
||||
# works for every transaction version.
|
||||
CREATE_EVENT_DISCRIMINATOR = bytes([27, 114, 169, 77, 222, 235, 99, 118])
|
||||
|
||||
# The two log lines the pump.fun program writes when it creates a coin.
|
||||
CREATE_INSTRUCTION_LOGS = (
|
||||
"Program log: Instruction: Create",
|
||||
"Program log: Instruction: CreateV2",
|
||||
)
|
||||
|
||||
_CREATE_EVENT_FIELDS = [
|
||||
("name", "string"),
|
||||
("symbol", "string"),
|
||||
@@ -360,7 +366,10 @@ def token_info_from_logs(logs):
|
||||
Returns:
|
||||
The token fields the buy path needs, or None if no coin was created
|
||||
"""
|
||||
if not any("Program log: Instruction: Create" in log for log in logs):
|
||||
# Matched whole: Anchor writes `Instruction: <Name>` for every program, so
|
||||
# a substring test also accepts CreateTokenAccount, CreatePool and the rest
|
||||
# from whichever programs share the transaction.
|
||||
if not any(log in CREATE_INSTRUCTION_LOGS for log in logs):
|
||||
return None
|
||||
for log in logs:
|
||||
if "Program data:" not in log:
|
||||
|
||||
@@ -75,6 +75,21 @@ against mainnet.
|
||||
the installed solders is only ever one version behind. Route on
|
||||
`meta.logMessages`, which the RPC has already decoded and which is
|
||||
version-agnostic; keep the byte decode as a fallback.
|
||||
- **Match a create marker as a whole log line, not a substring.** Anchor writes
|
||||
`Program log: Instruction: <Name>` for every program, so
|
||||
`"Program log: Instruction: Create" in log` also accepts CreateTokenAccount,
|
||||
CreateFeeSharingConfig, CreatePool and CreateConsume from whichever programs
|
||||
share the transaction. pump.fun writes exactly
|
||||
`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
|
||||
|
||||
@@ -19,6 +19,8 @@ individual scripts for a subset.
|
||||
| `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_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 |
|
||||
|
||||
@@ -36,6 +36,16 @@ _CREATE_V2_USER_ACCOUNT_INDEX = 5
|
||||
# Length of a Solana public key in bytes.
|
||||
PUBKEY_BYTE_LENGTH = 32
|
||||
|
||||
# The two log lines the pump.fun program writes when it creates a coin. They are
|
||||
# matched whole: Anchor writes `Instruction: <Name>` for every program, so a
|
||||
# substring test also accepts CreateTokenAccount, CreatePool,
|
||||
# CreateFeeSharingConfig and any other instruction whose name starts with
|
||||
# "Create", from any program sharing the transaction.
|
||||
CREATE_INSTRUCTION_LOGS = (
|
||||
"Program log: Instruction: Create",
|
||||
"Program log: Instruction: CreateV2",
|
||||
)
|
||||
|
||||
|
||||
def _option_bool(value: object) -> bool:
|
||||
"""Read an Anchor OptionBool argument.
|
||||
@@ -146,16 +156,9 @@ class PumpFunEventParser(EventParser):
|
||||
Returns:
|
||||
TokenInfo if token creation found, None otherwise
|
||||
"""
|
||||
# Check if this is a token creation transaction (create or create_v2 for token2022)
|
||||
if not any(
|
||||
"Program log: Instruction: Create" in log
|
||||
or "Program log: Instruction: Create_v2" in log
|
||||
for log in logs
|
||||
):
|
||||
return None
|
||||
|
||||
# Skip swaps as the first condition may pass them
|
||||
if any("Program log: Instruction: CreateTokenAccount" in log for log in logs):
|
||||
# Check if this is a token creation transaction (create, or create_v2
|
||||
# for token2022)
|
||||
if not any(log in CREATE_INSTRUCTION_LOGS for log in logs):
|
||||
return None
|
||||
|
||||
logger.info(f"🔍 Parsing token creation from logs for signature: {signature}")
|
||||
@@ -168,12 +171,9 @@ class PumpFunEventParser(EventParser):
|
||||
|
||||
# First, collect all Program data entries and note when Create instruction happens
|
||||
for i, log in enumerate(logs):
|
||||
if (
|
||||
"Program log: Instruction: Create" in log
|
||||
or "Program log: Instruction: Create_v2" in log
|
||||
):
|
||||
if log in CREATE_INSTRUCTION_LOGS:
|
||||
create_instruction_found = True
|
||||
instruction_type = "Create_v2" if "Create_v2" in log else "Create"
|
||||
instruction_type = log.rsplit(": ", 1)[1]
|
||||
logger.info(
|
||||
f"📝 Found {instruction_type} instruction at log index {i}"
|
||||
)
|
||||
@@ -186,7 +186,7 @@ class PumpFunEventParser(EventParser):
|
||||
)
|
||||
|
||||
if not create_instruction_found:
|
||||
logger.info("❌ No Create or Create_v2 instruction found in logs")
|
||||
logger.info("❌ No Create or CreateV2 instruction found in logs")
|
||||
return None
|
||||
|
||||
if not program_data_entries:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Verify the logs create gate matches pump.fun's own marker and nothing else.
|
||||
|
||||
`parse_token_creation_from_logs` opened with a substring test:
|
||||
|
||||
if not any("Program log: Instruction: Create" in log for log in logs):
|
||||
|
||||
Anchor writes `Instruction: <Name>` for every program, so that line accepted any
|
||||
instruction whose name merely starts with "Create" — CreateTokenAccount,
|
||||
CreateFeeSharingConfig, CreatePool, CreateConsume — from any program sharing the
|
||||
transaction. (The associated token account program is not among them: it writes
|
||||
`Program log: Create`, without Anchor's `Instruction: ` prefix.)
|
||||
|
||||
No false coin reached the bot, because the CreateEvent discriminator check
|
||||
downstream rejects every one of them. The cost was the opposite: a
|
||||
`CreateTokenAccount` veto had been added on top to suppress the loudest of those
|
||||
false hits, and being a whole-transaction test it also dropped any genuine
|
||||
create whose transaction opened a token account — a real coin, silently not
|
||||
detected. Matching the two marker lines whole removes the need for the veto.
|
||||
|
||||
Offline machine checks, no network and no funds moved:
|
||||
|
||||
1. Every create fixture is still detected from its logs alone.
|
||||
2. A genuine create whose transaction also carries `CreateTokenAccount` is
|
||||
detected. This is the check that fails before the fix: the veto returned
|
||||
None for it.
|
||||
3. Log sets carrying only a foreign `Create*` instruction are rejected.
|
||||
4. No listener, tool or cookbook script tests the marker as a substring.
|
||||
|
||||
Usage:
|
||||
uv run tests/regression/verify_create_log_gate.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from interfaces.core import Platform # noqa: E402
|
||||
from platforms.pumpfun.event_parser import PumpFunEventParser # noqa: E402
|
||||
from utils.idl_manager import get_idl_manager # noqa: E402
|
||||
|
||||
DECODE_DIR = PROJECT_ROOT / "cookbook" / "pumpfun" / "decode"
|
||||
|
||||
# Instruction markers the substring test accepted and the whole-line test
|
||||
# does not. None of them creates a coin.
|
||||
FOREIGN_CREATE_MARKERS = (
|
||||
"Program log: Instruction: CreateTokenAccount",
|
||||
"Program log: Instruction: CreateFeeSharingConfig",
|
||||
"Program log: Instruction: CreatePool",
|
||||
"Program log: Instruction: CreateConsume",
|
||||
"Program log: Instruction: CreateIdempotent",
|
||||
)
|
||||
|
||||
# Every site that decides "this transaction created a coin" from the logs.
|
||||
GATE_SITES = (
|
||||
Path("src/platforms/pumpfun/event_parser.py"),
|
||||
Path("tools/compare_listeners.py"),
|
||||
Path("cookbook/pumpfun/listen/pumpfun_listen_tokens_logsubscribe.py"),
|
||||
Path("cookbook/pumpfun/listen/pumpfun_listen_tokens_blocksubscribe.py"),
|
||||
Path("cookbook/pumpfun/trade/pumpfun_snipe_token_blocksubscribe.py"),
|
||||
)
|
||||
|
||||
# A substring test against the marker, in either argument order.
|
||||
SUBSTRING_GATE = re.compile(
|
||||
r'"Program log: Instruction: Create\w*"\s+in\s+log\b'
|
||||
r'|\bin\s+"Program log: Instruction: Create\w*"'
|
||||
)
|
||||
|
||||
|
||||
def _parser() -> PumpFunEventParser:
|
||||
"""Real pump.fun event parser with the vendored IDL, no RPC client needed."""
|
||||
return PumpFunEventParser(
|
||||
idl_parser=get_idl_manager().get_parser(Platform.PUMP_FUN)
|
||||
)
|
||||
|
||||
|
||||
def _logs_from_fixture(path: Path) -> list[str] | None:
|
||||
"""Pull `meta.logMessages` out of a captured transaction fixture.
|
||||
|
||||
Returns:
|
||||
The log lines, or None if the fixture holds no transaction meta.
|
||||
"""
|
||||
|
||||
def walk(node: object) -> Iterator[list[str]]:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key in ("logMessages", "log_messages") and value:
|
||||
yield value
|
||||
else:
|
||||
yield from walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
yield from walk(value)
|
||||
|
||||
return next(walk(json.loads(path.read_text())), None)
|
||||
|
||||
|
||||
def _create_fixtures() -> list[tuple[Path, list[str]]]:
|
||||
"""Every decode fixture whose logs carry a create marker."""
|
||||
found = []
|
||||
for path in sorted(DECODE_DIR.glob("raw_create*.json")):
|
||||
logs = _logs_from_fixture(path)
|
||||
if logs and any("Instruction: Create" in line for line in logs):
|
||||
found.append((path, logs))
|
||||
return found
|
||||
|
||||
|
||||
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
|
||||
print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}")
|
||||
return passed
|
||||
|
||||
|
||||
def check_real_creates_are_still_detected() -> bool:
|
||||
print("\n1. Every create fixture is detected from its logs alone")
|
||||
fixtures = _create_fixtures()
|
||||
if not fixtures:
|
||||
return _check(
|
||||
"fixtures found", passed=False, detail="none under cookbook/pumpfun/decode"
|
||||
)
|
||||
parser = _parser()
|
||||
ok = True
|
||||
for path, logs in fixtures:
|
||||
token_info = parser.parse_token_creation_from_logs(logs, path.stem)
|
||||
ok &= _check(
|
||||
path.name,
|
||||
token_info is not None,
|
||||
f"{token_info.symbol}" if token_info else "not detected",
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
def check_create_alongside_token_account_is_detected() -> bool:
|
||||
print("\n2. A create sharing its transaction with CreateTokenAccount is detected")
|
||||
fixtures = _create_fixtures()
|
||||
parser = _parser()
|
||||
ok = True
|
||||
for path, logs in fixtures:
|
||||
polluted = [*logs, "Program log: Instruction: CreateTokenAccount"]
|
||||
token_info = parser.parse_token_creation_from_logs(polluted, path.stem)
|
||||
ok &= _check(
|
||||
path.name,
|
||||
token_info is not None,
|
||||
f"{token_info.symbol}" if token_info else "dropped by the veto",
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
def check_foreign_create_markers_are_rejected() -> bool:
|
||||
print("\n3. A foreign Create* instruction alone is not a coin creation")
|
||||
parser = _parser()
|
||||
# The event blob is a real one from a fixture, so the rejection is the
|
||||
# gate's doing and not a decode failure further down.
|
||||
_, sample = _create_fixtures()[0]
|
||||
program_data = [line for line in sample if "Program data:" in line]
|
||||
ok = True
|
||||
for marker in FOREIGN_CREATE_MARKERS:
|
||||
logs = [
|
||||
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [1]",
|
||||
marker,
|
||||
*program_data,
|
||||
]
|
||||
token_info = parser.parse_token_creation_from_logs(logs, marker)
|
||||
ok &= _check(
|
||||
marker.split(": ")[-1],
|
||||
token_info is None,
|
||||
"rejected" if token_info is None else f"accepted as {token_info.symbol}",
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
def check_no_site_matches_the_marker_as_a_substring() -> bool:
|
||||
print("\n4. No site tests the create marker as a substring")
|
||||
ok = True
|
||||
for relative in GATE_SITES:
|
||||
path = PROJECT_ROOT / relative
|
||||
hits = [
|
||||
f"{n}: {line.strip()}"
|
||||
for n, line in enumerate(path.read_text().splitlines(), 1)
|
||||
if SUBSTRING_GATE.search(line)
|
||||
]
|
||||
ok &= _check(str(relative), not hits, "clean" if not hits else "; ".join(hits))
|
||||
return ok
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run every check and exit non-zero if any failed."""
|
||||
print("Verifying the logs create gate")
|
||||
results = [
|
||||
check_real_creates_are_still_detected(),
|
||||
check_create_alongside_token_account_is_detected(),
|
||||
check_foreign_create_markers_are_rejected(),
|
||||
check_no_site_matches_the_marker_as_a_substring(),
|
||||
]
|
||||
print(f"\n{sum(results)}/{len(results)} checks passed")
|
||||
sys.exit(0 if all(results) else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""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()
|
||||
+56
-22
@@ -15,7 +15,9 @@ with per-method latency, message counts and coverage:
|
||||
|
||||
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.
|
||||
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.
|
||||
`tools/compare_deshred_latency.py` answers the other question, pairing deshred
|
||||
against executed per signature to measure the lead itself.
|
||||
|
||||
@@ -40,6 +42,7 @@ import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from queue import Empty
|
||||
|
||||
@@ -95,6 +98,15 @@ 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"
|
||||
|
||||
# Sampling window in seconds, overridable with --duration. Short runs see few
|
||||
# coins, and a lane's coverage gap only shows up over enough of them.
|
||||
DEFAULT_DURATION = 60
|
||||
@@ -559,10 +571,7 @@ async def listen_block_subscription(wss_url, provider_name, tracker, known_token
|
||||
# difference against logs and geyser.
|
||||
meta = tx.get("meta") or {}
|
||||
logs = meta.get("logMessages") or []
|
||||
if not any(
|
||||
"Program log: Instruction: Create" in log
|
||||
for log in logs
|
||||
):
|
||||
if not (CREATE_LOG in logs or CREATE_V2_LOG in logs):
|
||||
continue
|
||||
|
||||
decoded = None
|
||||
@@ -585,10 +594,7 @@ async def listen_block_subscription(wss_url, provider_name, tracker, known_token
|
||||
if not mint or mint in known_tokens:
|
||||
continue
|
||||
|
||||
is_v2 = any(
|
||||
"Program log: Instruction: CreateV2" in log
|
||||
for log in logs
|
||||
)
|
||||
is_v2 = CREATE_V2_LOG in logs
|
||||
kind = (
|
||||
"CreateV2 (Token2022)" if is_v2 else "Create (Legacy)"
|
||||
)
|
||||
@@ -668,13 +674,14 @@ async def listen_logs_subscription(wss_url, provider_name, tracker, known_tokens
|
||||
log_data = data["params"]["result"]["value"]
|
||||
logs = log_data.get("logs", [])
|
||||
|
||||
# Detect both Create and CreateV2 instructions
|
||||
is_create = any(
|
||||
"Program log: Instruction: Create" in log for log in logs
|
||||
)
|
||||
is_create_v2 = any(
|
||||
"Program log: Instruction: CreateV2" in log for log in logs
|
||||
)
|
||||
# Detect both Create and CreateV2 instructions. The
|
||||
# lines are matched whole: Anchor writes
|
||||
# `Instruction: <Name>` for every program, so a
|
||||
# substring test also accepts CreateTokenAccount,
|
||||
# CreatePool and the rest from whichever programs share
|
||||
# the transaction.
|
||||
is_create = CREATE_LOG in logs
|
||||
is_create_v2 = CREATE_V2_LOG in logs
|
||||
|
||||
if not (is_create or is_create_v2):
|
||||
continue
|
||||
@@ -997,12 +1004,36 @@ async def listen_deshred_grpc(
|
||||
|
||||
|
||||
async def listen_pumpportal(provider_name, tracker, known_tokens=None):
|
||||
"""Listen for new tokens via PumpPortal WebSocket"""
|
||||
"""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...")
|
||||
@@ -1011,7 +1042,7 @@ async def listen_pumpportal(provider_name, tracker, known_tokens=None):
|
||||
await websocket.send(
|
||||
json.dumps({"method": "subscribeNewToken", "params": []})
|
||||
)
|
||||
print(f"[INFO] PumpPortal listener active for {provider_name}")
|
||||
print(f"[INFO] PumpPortal listener active for {lane}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -1037,6 +1068,11 @@ async def listen_pumpportal(provider_name, tracker, known_tokens=None):
|
||||
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
|
||||
|
||||
@@ -1046,13 +1082,11 @@ async def listen_pumpportal(provider_name, tracker, known_tokens=None):
|
||||
known_tokens.add(mint)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] PumpPortal listener for {provider_name}: {e}")
|
||||
print(f"[ERROR] PumpPortal listener for {lane}: {e}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[ERROR] Connection error in PumpPortal listener for {provider_name}: {e}"
|
||||
)
|
||||
print(f"[ERROR] Connection error in PumpPortal listener for {lane}: {e}")
|
||||
print("[INFO] Reconnecting in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user