mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
"""Verify the blocks listener survives a blockSubscribe notification with a null block.
|
|
|
|
`blockSubscribe` delivers a notification whose `value.block` is `null` for a
|
|
skipped or otherwise unavailable slot — the key is present, the value is not.
|
|
The guard in `UniversalBlockListener._wait_for_token_creation` only checked that
|
|
the key existed, so the next line ran a membership test against `None`:
|
|
|
|
TypeError: argument of type 'NoneType' is not iterable
|
|
|
|
The per-message handler catches it, so the listener keeps running, but the
|
|
notification is dropped and logged as an ERROR with a traceback. Any coin created
|
|
in that slot is never detected, which reads as the `blocks` listener quietly
|
|
finding fewer coins than `logs` or `geyser` over the same window.
|
|
|
|
Offline machine checks, no network and no funds moved. A stub websocket replays
|
|
scripted frames into the real `_wait_for_token_creation`:
|
|
|
|
1. A null block returns None instead of raising TypeError.
|
|
2. Nothing is logged at ERROR while handling it — a skipped slot is routine.
|
|
3. A block with no `transactions` key still returns None (unchanged).
|
|
4. A well-formed block is still processed, so the guard did not swallow
|
|
real notifications.
|
|
|
|
Usage:
|
|
uv run tests/regression/verify_block_null_guard.py
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
|
|
|
from interfaces.core import Platform # noqa: E402
|
|
from monitoring.universal_block_listener import UniversalBlockListener # noqa: E402
|
|
|
|
NULL_BLOCK_FRAME = {
|
|
"jsonrpc": "2.0",
|
|
"method": "blockNotification",
|
|
"params": {
|
|
"result": {"context": {"slot": 372_000_000}, "value": {"block": None}},
|
|
"subscription": 1,
|
|
},
|
|
}
|
|
|
|
BLOCK_WITHOUT_TRANSACTIONS_FRAME = {
|
|
"jsonrpc": "2.0",
|
|
"method": "blockNotification",
|
|
"params": {
|
|
"result": {
|
|
"context": {"slot": 372_000_001},
|
|
"value": {"block": {"blockhash": "stub", "parentSlot": 372_000_000}},
|
|
},
|
|
"subscription": 1,
|
|
},
|
|
}
|
|
|
|
BLOCK_WITH_TRANSACTIONS_FRAME = {
|
|
"jsonrpc": "2.0",
|
|
"method": "blockNotification",
|
|
"params": {
|
|
"result": {
|
|
"context": {"slot": 372_000_002},
|
|
"value": {"block": {"transactions": []}},
|
|
},
|
|
"subscription": 1,
|
|
},
|
|
}
|
|
|
|
|
|
class StubWebSocket:
|
|
"""Serves one scripted frame, then blocks so recv() never returns twice."""
|
|
|
|
def __init__(self, frame: dict) -> None:
|
|
self.frame = frame
|
|
self.sent = False
|
|
|
|
async def recv(self) -> str:
|
|
if self.sent:
|
|
await asyncio.sleep(3600)
|
|
self.sent = True
|
|
return json.dumps(self.frame)
|
|
|
|
|
|
class ErrorCapture(logging.Handler):
|
|
"""Collects records logged at ERROR or above."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(level=logging.ERROR)
|
|
self.records: list[logging.LogRecord] = []
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
self.records.append(record)
|
|
|
|
|
|
def _make_listener() -> UniversalBlockListener:
|
|
"""Build a listener without touching the network."""
|
|
return UniversalBlockListener(
|
|
wss_endpoint="wss://stub.invalid", platforms=[Platform.PUMP_FUN]
|
|
)
|
|
|
|
|
|
async def _feed(frame: dict) -> tuple[object, list[logging.LogRecord]]:
|
|
"""Run one frame through the real handler, capturing ERROR logs.
|
|
|
|
Returns:
|
|
Tuple of (handler return value, ERROR records emitted)
|
|
"""
|
|
listener = _make_listener()
|
|
capture = ErrorCapture()
|
|
logger = logging.getLogger("monitoring.universal_block_listener")
|
|
logger.addHandler(capture)
|
|
try:
|
|
result = await listener._wait_for_token_creation( # noqa: SLF001
|
|
SimpleNamespace(recv=StubWebSocket(frame).recv)
|
|
)
|
|
finally:
|
|
logger.removeHandler(capture)
|
|
return result, capture.records
|
|
|
|
|
|
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
|
|
print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}")
|
|
return passed
|
|
|
|
|
|
async def check_null_block_returns_none() -> bool:
|
|
print("\n1. A null block is skipped, not crashed on")
|
|
result, _ = await _feed(NULL_BLOCK_FRAME)
|
|
return _check(
|
|
"handler return value", result is None, f"{result!r} (no TypeError raised)"
|
|
)
|
|
|
|
|
|
async def check_null_block_is_not_an_error() -> bool:
|
|
print("\n2. A skipped slot is not logged as an ERROR")
|
|
_, records = await _feed(NULL_BLOCK_FRAME)
|
|
messages = [r.getMessage() for r in records]
|
|
return _check(
|
|
"ERROR records emitted",
|
|
not records,
|
|
"none" if not records else f"{len(records)}: {messages}",
|
|
)
|
|
|
|
|
|
async def check_block_without_transactions_returns_none() -> bool:
|
|
print("\n3. A block carrying no transactions still returns None")
|
|
result, records = await _feed(BLOCK_WITHOUT_TRANSACTIONS_FRAME)
|
|
return _check(
|
|
"handler return value",
|
|
result is None and not records,
|
|
f"{result!r}, {len(records)} ERROR record(s)",
|
|
)
|
|
|
|
|
|
async def check_real_block_is_still_processed() -> bool:
|
|
print("\n4. A well-formed block still reaches transaction processing")
|
|
listener = _make_listener()
|
|
seen: list[list] = []
|
|
listener._process_block_transactions = lambda txs: seen.append(txs) # noqa: SLF001
|
|
await listener._wait_for_token_creation( # noqa: SLF001
|
|
SimpleNamespace(recv=StubWebSocket(BLOCK_WITH_TRANSACTIONS_FRAME).recv)
|
|
)
|
|
return _check(
|
|
"_process_block_transactions called",
|
|
len(seen) == 1,
|
|
f"{len(seen)} call(s) — the guard did not swallow a real notification",
|
|
)
|
|
|
|
|
|
async def main() -> None:
|
|
print("=" * 72)
|
|
print("Verifying the blocks listener null-block guard")
|
|
print("=" * 72)
|
|
|
|
results = [
|
|
await check_null_block_returns_none(),
|
|
await check_null_block_is_not_an_error(),
|
|
await check_block_without_transactions_returns_none(),
|
|
await check_real_block_is_still_processed(),
|
|
]
|
|
|
|
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())
|