mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* fix(trading): balance task_done against get in the token queue processor
`_process_token_queue` called `task_done()` from a `finally` that also runs
when `queue.get()` itself is cancelled, at which point no item was taken, so
the call was unbalanced and raised:
ValueError: task_done() called too many times
Cancelling that task is how `start()` shuts yolo mode down, and the
cancellation almost always lands while parked on `get()`. `start()` catches
only `CancelledError` around the await, so the ValueError reached the outer
handler and every clean yolo-mode shutdown was logged as `Trading stopped due
to error` with a traceback. Nothing was lost, since `_cleanup_resources()`
still ran, but the log said a good run had failed.
Track whether an item was taken and call `task_done()` only then. The
stale-token `continue` path did take one and still calls it, or `join()` would
never return.
Closes #210
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(monitoring): stop logging a normal WebSocket close as a ping error
Every WebSocket listener runs a `_ping_loop` that caught `CancelledError` and
then fell through to a broad `except Exception: logger.exception("Ping error")`.
When the connection closes normally, `websocket.ping()` raises
`ConnectionClosedOK` or `ConnectionClosedError` — neither is a `CancelledError`,
so both landed in the broad handler and printed a traceback at ERROR:
ERROR:monitoring.universal_logs_listener:Ping error
websockets.exceptions.ConnectionClosedError: sent 1000 (OK); no close frame received
Close code 1000 is a normal closure. Nothing was wrong, but a completed run
reported a failure to anyone reading the logs or alerting on ERROR.
Catch `ConnectionClosed` before the broad handler and exit the loop at debug.
The broad handler stays for anything genuinely unexpected.
#206 had narrowed the race by cancelling the ping task in a `finally` on the
read loop, but the ping can still fire between the connection closing and the
cancellation landing, and a server-initiated close always reached the loop this
way. The pumpportal listener has the same loop and is fixed here too.
Closes #203
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook,tools): reject payloads that are not the migration event
`parse_migrate_instruction` skipped the first 8 bytes as a discriminator but
never checked them, so every `Program data:` line in a migration transaction
was decoded against the migration schema whatever event it actually was. The
schema needs 301 bytes; any payload at least that long came back as a fully
populated dict of meaningless values rather than as a failure — a
`baseMintDecimals` of 176 where the field can only legitimately be 0-9. Shorter
payloads happened to fail inside `struct.unpack`, which is why the underrun was
never the visible symptom.
The event is the wrapper program's `CreatePoolEvent`, not the
`CompletePumpAmmMigrationEvent` in `idl/pump_fun_idl.json` — confirmed against
mainnet, where every payload the program emits carries discriminator
`b1310cd2a076a774`, which is `sha256("event:CreatePoolEvent")[:8]`. Assert it
before reading any field. The field layout itself was already correct.
Both copies of the decoder are fixed. In the cookbook script, `parsed_data` was
also reassigned on every `Program data:` line, so a later non-matching line
would wipe a good parse now that the guard returns None; keep the first match.
Closes #198
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cookbook): declare BondingCurveState fields instead of __dict__.update
The class assigned its parsed fields through `self.__dict__.update(...)`. The
fields were correct at runtime, but nothing declared them, so type checkers and
editors could not see them: reading `state.virtual_token_reserves` in any
consumer produced an "attribute is unknown" warning, with no autocomplete and
no go-to-definition for any reserve field.
Declare the fields and assign them from the parse result. Where several struct
versions share the leading fields, pick the struct in the branch and assign
once after it, so the optional trailing fields resolve through one `.get` each
rather than through a pre-set default that a later update may or may not
overwrite. This also drops construct's `_io` from the instance dict.
No behaviour change: parsed state for a live curve is identical field for field
before and after, across all three classes. `src/` is unaffected — its curve
manager works on plain dicts and never used this class.
Closes #204
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(trading): finish shutdown cleanup through the cancellation that triggered it
`start()` reclaims account rent from a `finally` that awaited
`_cleanup_resources`. Shutdown reaches that coroutine as a cancellation, and
cleanup awaits once per account — a settle wait, then an RPC read — so the first
of those re-raised `CancelledError` and every account after it kept its rent.
The run then ended without even logging that it shut down, so nothing in the log
said cleanup had been cut short.
Seen on a live run interrupted during the settle wait: it stopped after two of
its accounts. Three other runs interrupted a moment earlier finished cleanly, so
this is a race rather than a certainty.
Run cleanup as its own task and wait through the cancellation with
`asyncio.wait`, which neither cancels the task on timeout nor re-raises what it
failed with. Bounded by `_CLEANUP_SHUTDOWN_BUDGET` so a stuck RPC cannot hold
the process open; rent left behind is recoverable, so the warning names
`tools/cleanup_accounts.py` rather than retrying here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
7.8 KiB
Python
215 lines
7.8 KiB
Python
"""Verify the migration decoder rejects payloads that are not its event.
|
|
|
|
`parse_migrate_instruction` skipped the first 8 bytes as a discriminator but never
|
|
checked them, so every `Program data:` line in a migration transaction was decoded
|
|
against the migration schema whatever event it actually was. The schema needs 301
|
|
bytes; any payload at least that long came back as a fully populated dict of
|
|
meaningless values rather than as a failure — a `baseMintDecimals` of 176 where the
|
|
field can only legitimately be 0-9. Shorter payloads happened to fail inside
|
|
`struct.unpack`, which is why the underrun was never the visible symptom.
|
|
|
|
Silently wrong output is worse than a crash here: the values look structurally
|
|
plausible, so anything reading the example inherits bad data with no signal.
|
|
|
|
The event is the wrapper program's `CreatePoolEvent`, not the
|
|
`CompletePumpAmmMigrationEvent` in `idl/pump_fun_idl.json`, which is why the layout
|
|
is hand-rolled and the discriminator has to be asserted rather than inherited from
|
|
an IDL parser.
|
|
|
|
Offline machine checks, no network and no funds moved. The fixture is a real
|
|
344-byte payload captured from the wrapper program on mainnet, slot 450319072:
|
|
|
|
1. The real payload still decodes, and its decimals and pool bump are in range.
|
|
2. The same payload with a foreign discriminator is rejected — same length, same
|
|
body, so only the discriminator can be doing the work.
|
|
3. A 344-byte payload of foreign bytes is rejected, so length is not the guard.
|
|
4. A truncated payload is rejected rather than underrunning the buffer.
|
|
5. Both copies of the decoder carry the check, so a third copy cannot skip it.
|
|
|
|
Usage:
|
|
uv run tests/regression/verify_migration_event_discriminator.py
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
DECODER_FILES = (
|
|
PROJECT_ROOT
|
|
/ "cookbook/pumpfun/graduation/pumpfun_listen_migrations_logsubscribe.py",
|
|
PROJECT_ROOT / "tools/compare_migration_listeners.py",
|
|
)
|
|
|
|
# Captured from 64XX5RvH8tuUH8xcjCLWxpKbV76XEvvy4emA7jLqBpQhizKidXA2JpBskoPmXSF339h9GKKvEw7SYitYfnhscEV8
|
|
REAL_EVENT_BASE64 = (
|
|
"sTEM0qB2p3TFRbZqAAAAAAAAgu92lXybnLAhVPxEgUNwWsHZjf0HhGpKPxZqwqzSRAo7PCcIdzHz"
|
|
"bjOi+1BCWfvrYYBHT3/jky9BlSkjKrvRvwabiFf+q4GE+2h/Y0YYwDXaxDncGus7VZig8AAAAAAB"
|
|
"BgkACAGpLLwAABD20ckTAAAAAAgBqSy8AAAQ9tHJEwAAAGQAAAAAAAAA7EJrWdADAACIQmtZ0AMA"
|
|
"AP09v2FQlhz3OoZ2ArMAVANd/SfIvh9dnU1NIsN3S31arf81CJ5nJ3q/uNV/kSaz+qZ783cc3dOa"
|
|
"HYPIrTZorNDHLxoLcdVoeJ9ZN0Q/1Vlm+brLQrSUbJWZpMPVORZ1JDq2eGxgCgysRkkiPlx1Y50a"
|
|
"aCkd3znC9PiWNSNIFXnoG3Ov/WZxIeqJuAtGZYzOJz4hPPh+bkt2br4Eb8DEn7RwAAAAAAAAAAAA"
|
|
"AAA="
|
|
)
|
|
REAL_EVENT = base64.b64decode(REAL_EVENT_BASE64)
|
|
|
|
EXPECTED = {
|
|
"baseMint": "4zEFhAkgZhjXfZeMaNsvxufgTkFvrz2trYYKDqnnpump",
|
|
"quoteMint": "So11111111111111111111111111111111111111112",
|
|
"baseMintDecimals": 6,
|
|
"quoteMintDecimals": 9,
|
|
"poolBump": 253,
|
|
}
|
|
|
|
# The smallest payload the schema can read to the end of: 8 discriminator, three
|
|
# 8-byte ints, a u16, seven pubkeys, four u8s and six more 8-byte ints.
|
|
SCHEMA_MIN_LENGTH = 301
|
|
|
|
EXPECTED_DISCRIMINATOR = hashlib.sha256(b"event:CreatePoolEvent").digest()[:8]
|
|
|
|
|
|
def _load(path: Path) -> ModuleType:
|
|
"""Import a standalone script by path, without adding it to sys.modules."""
|
|
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
DECODERS = {path.name: _load(path).parse_migrate_instruction for path in DECODER_FILES}
|
|
|
|
|
|
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
|
|
print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}")
|
|
return passed
|
|
|
|
|
|
def _rejects(payload: bytes, label: str) -> bool:
|
|
"""Every decoder must return None for this payload."""
|
|
results = []
|
|
for name, decode in DECODERS.items():
|
|
parsed = decode(payload)
|
|
results.append(
|
|
_check(
|
|
name,
|
|
parsed is None,
|
|
"rejected"
|
|
if parsed is None
|
|
else f"ACCEPTED — baseMintDecimals={parsed.get('baseMintDecimals')}, "
|
|
f"poolBump={parsed.get('poolBump')}",
|
|
)
|
|
)
|
|
print(f" ({label})")
|
|
return all(results)
|
|
|
|
|
|
def check_real_event_decodes() -> bool:
|
|
print("\n1. A real CreatePoolEvent still decodes")
|
|
results = []
|
|
for name, decode in DECODERS.items():
|
|
parsed = decode(REAL_EVENT)
|
|
if parsed is None:
|
|
results.append(_check(name, passed=False, detail="rejected a real event"))
|
|
continue
|
|
wrong = {k: parsed.get(k) for k, v in EXPECTED.items() if parsed.get(k) != v}
|
|
results.append(
|
|
_check(
|
|
name,
|
|
not wrong,
|
|
f"baseMintDecimals={parsed['baseMintDecimals']}, "
|
|
f"quoteMintDecimals={parsed['quoteMintDecimals']}, "
|
|
f"poolBump={parsed['poolBump']}"
|
|
if not wrong
|
|
else f"mismatched fields: {wrong}",
|
|
)
|
|
)
|
|
return all(results)
|
|
|
|
|
|
def check_discriminator_is_the_expected_one() -> bool:
|
|
print("\n2. The payload's discriminator is sha256('event:CreatePoolEvent')[:8]")
|
|
actual = REAL_EVENT[:8]
|
|
return _check(
|
|
"leading 8 bytes",
|
|
actual == EXPECTED_DISCRIMINATOR,
|
|
f"{actual.hex()} (expected {EXPECTED_DISCRIMINATOR.hex()})",
|
|
)
|
|
|
|
|
|
def check_foreign_discriminator_rejected() -> bool:
|
|
print("\n3. The same payload with a foreign discriminator is rejected")
|
|
foreign = bytes([0xBD, 0xDB, 0x7F, 0xD3, 0x4E, 0xE6, 0x61, 0xEE]) + REAL_EVENT[8:]
|
|
return _rejects(foreign, "same length and body, different event")
|
|
|
|
|
|
def check_long_foreign_payload_rejected() -> bool:
|
|
print("\n4. A foreign payload long enough for the schema is rejected")
|
|
# Deterministic filler; the point is the length, not the content.
|
|
filler = bytes((i * 7 + 13) % 256 for i in range(len(REAL_EVENT) - 8))
|
|
foreign = bytes([0xFF] * 8) + filler
|
|
passed = _rejects(
|
|
foreign, f"{len(foreign)} bytes, schema needs {SCHEMA_MIN_LENGTH}"
|
|
)
|
|
return passed and _check(
|
|
"payload long enough to matter",
|
|
len(foreign) >= SCHEMA_MIN_LENGTH,
|
|
f"{len(foreign)} >= {SCHEMA_MIN_LENGTH}",
|
|
)
|
|
|
|
|
|
def check_truncated_payload_rejected() -> bool:
|
|
print("\n5. A truncated payload is rejected rather than underrunning")
|
|
return _rejects(REAL_EVENT[:64], "64 bytes of a real event")
|
|
|
|
|
|
def check_every_decoder_is_guarded() -> bool:
|
|
print("\n6. Every copy of the decoder carries the check")
|
|
results = []
|
|
for path in DECODER_FILES:
|
|
source = path.read_text()
|
|
guarded = (
|
|
"CREATE_POOL_EVENT_DISCRIMINATOR" in source
|
|
and "b1310cd2a076a774" in source
|
|
and "data[:8] != CREATE_POOL_EVENT_DISCRIMINATOR" in source
|
|
)
|
|
results.append(
|
|
_check(
|
|
path.relative_to(PROJECT_ROOT).as_posix(),
|
|
guarded,
|
|
"checks the discriminator before decoding"
|
|
if guarded
|
|
else "no discriminator check found",
|
|
)
|
|
)
|
|
return all(results)
|
|
|
|
|
|
def main() -> None:
|
|
print("=" * 72)
|
|
print("Verifying the migration event decoder asserts its discriminator")
|
|
print("=" * 72)
|
|
|
|
results = [
|
|
check_real_event_decodes(),
|
|
check_discriminator_is_the_expected_one(),
|
|
check_foreign_discriminator_rejected(),
|
|
check_long_foreign_payload_rejected(),
|
|
check_truncated_payload_rejected(),
|
|
check_every_decoder_is_guarded(),
|
|
]
|
|
|
|
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__":
|
|
main()
|