Files
pumpfun-bonkfun-bot_github/tests/regression/verify_creator_migration_from_logs.py
T
9c733d3401 fix: skip reverted creates and use the rewritten bonding-curve creator (#233)
* fix(monitoring): skip token creates whose transaction reverted

logsSubscribe delivers the logs a transaction emitted before it reverted,
so a CreateEvent decoded from one describes a mint that was never created.
The buy then fails at the ATA-create instruction with IncorrectProgramId,
which reads as a token-program fault rather than a detection one.

The blocks listener already guarded on meta.err; geyser excludes failed
transactions server-side. Shreds is pre-execution and has no meta.

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

* fix(platforms): build the buy from the migrated bonding-curve creator

A coin with creator-fee sharing has its BondingCurve.creator overwritten
with the sharing-config PDA in the same transaction that creates it, while
CreateEvent.creator still reports the submitting wallet. buy_v2 derives
creator_vault from the curve's value, so a buy built from the event passed
the wrong vault and reverted with ConstraintSeeds.

The migration announces what it wrote in the same log set the CreateEvent
came from, so reading it there costs no RPC call and extreme_fast_mode
keeps its zero-RPC path. Covers the logs, blocks and geyser listeners,
which share the parser; shreds sees no logs and is unchanged.

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

* fix(platforms): honour only pump.fun's own create and creator events

Program data lines name no program, so any program sharing the
transaction could emit a matching discriminator. A forged CreateEvent
makes the bot buy a mint that was never created; a forged creator
rewrite picks the account the buy derives its vault from and reverts it.
Attribute each log line to the invocation that emitted it and ignore
the rest, falling back to the old behaviour only for a reconstructed
log list that carries no frames.

The ordinary-create check compared the parser against its own output;
it now decodes CreateEvent.creator from the logs independently.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:49:31 +02:00

345 lines
14 KiB
Python

"""Verify a fee-sharing coin's buy is built from the migrated bonding-curve creator.
`CreateEvent.creator` is the wallet that submitted the create. For an ordinary
coin that is also what `BondingCurve.creator` ends up holding, so the parser
treated it as canonical and marked the TokenInfo `state_from_event`, letting
`extreme_fast_mode` buy with no RPC call.
A coin with **creator-fee sharing** breaks that. The same transaction goes on to
call `migrate_bonding_curve_creator`, which overwrites the curve's creator with
the sharing-config PDA — `PDA(["sharing-config", mint], FEE_PROGRAM)`. Despite
the name this is not graduation: `migrate`/`migrate_v2` move a completed curve
to PumpSwap, while this rewrites one field on a curve that has just been
created, and the bot still only trades launches. `buy_v2` derives account 16
`creator_vault` from `bonding_curve.creator`, so a buy built from the
CreateEvent value passes the wrong vault and the transaction reverts with
`ConstraintSeeds` (2006), costing one buy fee.
Only a rewrite inside the launch transaction is covered. A coin whose creator
is rewritten a few slots later still reverts: the launch logs cannot carry an
event that has not happened yet, and seeing it would cost the RPC read that
extreme_fast_mode exists to avoid.
The fix reads `MigrateBondingCurveCreatorEvent` out of the same log set the
CreateEvent came from, so the correct creator costs no RPC call and the zero-RPC
path is kept.
Offline machine checks, no network and no funds moved. The fixture is the real
create transaction of mint `FdCYwtez…`, whose buy reverted 2006 live:
1. The fee-sharing fixture parses to the migrated creator, not the event's.
This is the check that fails before the fix.
2. That creator equals the independently derived sharing-config PDA, so the
parser is not just echoing a number out of the log.
3. `creator_vault` is derived from the migrated creator.
4. An ordinary create fixture is unaffected: its parsed creator equals the
`CreateEvent.creator` decoded independently from the same logs.
5. `state_from_event` is still set, so the coin keeps the zero-RPC path.
7. A CreateEvent emitted by another program is not a coin. Trading on one
buys a mint that was never created, the same end state as a reverted
create. Detection from a pump.fun frame is unchanged.
6. A migration payload emitted by a program other than pump.fun is ignored.
`Program data:` names no program, so a launcher could otherwise emit eight
matching bytes and choose the creator every sniper builds its buy from.
The same payload emitted inside a pump.fun frame IS honoured, which is
what shows check 6 rejects on the emitter and not on the payload.
Usage:
uv run tests/regression/verify_creator_migration_from_logs.py
"""
import base64
import json
import struct
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 solders.pubkey import Pubkey # noqa: E402
from interfaces.core import Platform # noqa: E402
from platforms.pumpfun.address_provider import PumpFunAddresses # 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"
SHARING_FIXTURE = DECODE_DIR / "raw_create_fee_sharing_from_gettransaction.json"
# The coin whose live buy reverted 2006, and the two creators involved.
SHARING_MINT = Pubkey.from_string("FdCYwtezFn1vhzsSPXbLZiAJungb8LETKSnV1iVdK5Xi")
EVENT_CREATOR = Pubkey.from_string("B6ZrojFMviC3grvpEGCrtuK85B55YU9y6t3aNT7NM7wz")
MIGRATED_CREATOR = Pubkey.from_string("6HcRAKoJodAYWV6rJFWAzHU4shsudmCCjpc4i5DyShWN")
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."""
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 _ordinary_fixture() -> tuple[Path, list[str]]:
"""A create fixture that carries no creator migration."""
parser = _parser()
for path in sorted(DECODE_DIR.glob("raw_create*.json")):
if path == SHARING_FIXTURE:
continue
logs = _logs_from_fixture(path)
if not logs:
continue
token_info = parser.parse_token_creation_from_logs(logs, path.stem)
if token_info:
return path, logs
raise SystemExit("No ordinary create fixture found") # noqa: TRY003
def _check(label: str, passed: bool, detail: str) -> bool: # noqa: FBT001
print(f" [{'PASS' if passed else 'FAIL'}] {label}: {detail}")
return passed
def check_migrated_creator_is_used() -> bool:
print("\n1. A fee-sharing create parses to the migrated creator")
logs = _logs_from_fixture(SHARING_FIXTURE)
token_info = _parser().parse_token_creation_from_logs(logs, "fee_sharing")
if token_info is None:
return _check("token detected", passed=False, detail="not detected")
return _check(
"TokenInfo.creator",
token_info.creator == MIGRATED_CREATOR,
f"{token_info.creator}"
+ (
"" if token_info.creator == MIGRATED_CREATOR else " (the CreateEvent value)"
),
)
def check_creator_matches_derived_sharing_config() -> bool:
print("\n2. That creator is the independently derived sharing-config PDA")
derived = PumpFunAddresses.find_sharing_config(SHARING_MINT)
logs = _logs_from_fixture(SHARING_FIXTURE)
token_info = _parser().parse_token_creation_from_logs(logs, "fee_sharing")
return _check(
"creator == PDA(['sharing-config', mint], FEE_PROGRAM)",
token_info is not None and token_info.creator == derived,
f"derived {derived}",
)
def check_creator_vault_follows() -> bool:
print("\n3. creator_vault is derived from the migrated creator")
expected, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(MIGRATED_CREATOR)], PumpFunAddresses.PROGRAM
)
stale, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(EVENT_CREATOR)], PumpFunAddresses.PROGRAM
)
logs = _logs_from_fixture(SHARING_FIXTURE)
token_info = _parser().parse_token_creation_from_logs(logs, "fee_sharing")
got = token_info.creator_vault if token_info else None
return _check(
"TokenInfo.creator_vault",
got == expected,
f"{got} (expected {expected}; the pre-fix value was {stale})",
)
def check_ordinary_create_unchanged() -> bool:
print("\n4. An ordinary create keeps the CreateEvent creator")
path, logs = _ordinary_fixture()
token_info = _parser().parse_token_creation_from_logs(logs, path.stem)
# Decoded from the logs rather than taken from the TokenInfo: deriving the
# expected vault from token_info.creator would compare the parser with
# itself and pass whatever it produced.
event_creator = _create_event_creator(logs)
expected_vault, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(event_creator)], PumpFunAddresses.PROGRAM
)
return _check(
path.name,
token_info.creator == event_creator
and token_info.creator_vault == expected_vault,
f"creator {token_info.creator} vs CreateEvent {event_creator}",
)
def check_zero_rpc_path_kept() -> bool:
print("\n5. The fee-sharing coin still carries state_from_event")
logs = _logs_from_fixture(SHARING_FIXTURE)
token_info = _parser().parse_token_creation_from_logs(logs, "fee_sharing")
return _check(
"TokenInfo.state_from_event",
bool(token_info and token_info.state_from_event),
f"{token_info.state_from_event if token_info else None} — no curve read needed",
)
def _create_event_creator(logs: list[str]) -> Pubkey:
"""Decode `CreateEvent.creator` straight out of a transaction's logs."""
manager = get_idl_manager().get_parser(Platform.PUMP_FUN)
want = manager.get_event_discriminators()["CreateEvent"]
for log in logs:
if "Program data:" not in log:
continue
raw = base64.b64decode(log.split("Program data: ")[1].strip())
if not raw.startswith(want):
continue
decoded = manager.decode_event_data(raw, "CreateEvent")
if decoded:
return Pubkey.from_string(str(decoded["fields"]["creator"]))
raise SystemExit("No CreateEvent in the fixture logs") # noqa: TRY003
def _forged_migration_log(mint: Pubkey, new_creator: Pubkey) -> str:
"""Build a `Program data:` line carrying a MigrateBondingCurveCreatorEvent."""
disc = (
get_idl_manager()
.get_parser(Platform.PUMP_FUN)
.get_event_discriminators()["MigrateBondingCurveCreatorEvent"]
)
payload = (
disc
+ struct.pack("<q", 1_790_000_000)
+ bytes(mint) * 1
+ bytes(Pubkey.default()) # bonding_curve, unread by the parser
+ bytes(Pubkey.default()) # sharing_config, unread by the parser
+ bytes(Pubkey.default()) # old_creator, unread by the parser
+ bytes(new_creator)
)
return "Program data: " + base64.b64encode(payload).decode()
def _wrap_in_frame(program: str, inner: list[str]) -> list[str]:
"""Bracket log lines in one program's invocation, as the runtime does."""
return [
f"Program {program} invoke [2]",
*inner,
f"Program {program} success",
]
def check_foreign_migration_is_ignored() -> bool:
print("\n6. A migration payload from another program is ignored")
path, logs = _ordinary_fixture()
genuine_creator = _create_event_creator(logs)
mint = _parser().parse_token_creation_from_logs(logs, path.stem).mint
attacker = Pubkey.from_string("SysvarC1ock11111111111111111111111111111111")
forged = _forged_migration_log(mint, attacker)
foreign = logs + _wrap_in_frame(
"MEViEyo5m4W6FZTuJLvCTLPYm4Sm1AejpZL4ceFwpump", [forged]
)
from_foreign = _parser().parse_token_creation_from_logs(foreign, "foreign")
own = logs + _wrap_in_frame(str(_parser().get_program_id()), [forged])
from_own = _parser().parse_token_creation_from_logs(own, "own")
ok = _check(
"emitted by another program",
from_foreign is not None and from_foreign.creator == genuine_creator,
f"creator stayed {from_foreign.creator if from_foreign else None}",
)
# Without this the check above would also pass on a payload the parser can
# never decode, which would prove nothing about the emitter test.
ok &= _check(
"same payload emitted by pump.fun",
from_own is not None and from_own.creator == attacker,
f"creator became {from_own.creator if from_own else None}",
)
return ok
def _create_event_log(logs: list[str]) -> str:
"""The raw `Program data:` line carrying the CreateEvent."""
want = (
get_idl_manager()
.get_parser(Platform.PUMP_FUN)
.get_event_discriminators()["CreateEvent"]
)
for log in logs:
if "Program data:" in log and base64.b64decode(
log.split("Program data: ")[1].strip()
).startswith(want):
return log
raise SystemExit("No CreateEvent line in the fixture logs") # noqa: TRY003
def check_foreign_create_event_is_not_a_coin() -> bool:
print("\n7. A CreateEvent emitted by another program is not a coin")
_path, logs = _ordinary_fixture()
event_log = _create_event_log(logs)
pump = str(_parser().get_program_id())
attacker = "MEViEyo5m4W6FZTuJLvCTLPYm4Sm1AejpZL4ceFwpump"
def framed(inner_program: str) -> list[str]:
return [
f"Program {pump} invoke [1]",
"Program log: Instruction: CreateV2",
*_wrap_in_frame(inner_program, [event_log]),
f"Program {pump} success",
]
forged = _parser().parse_token_creation_from_logs(framed(attacker), "forged")
genuine = _parser().parse_token_creation_from_logs(framed(pump), "genuine")
ok = _check(
"CreateEvent from another program",
forged is None,
"not detected" if forged is None else f"detected as {forged.symbol}",
)
# Same payload, same shape, only the emitting frame differs — otherwise the
# check above would pass on logs the parser simply cannot read.
ok &= _check(
"same payload from a pump.fun frame",
genuine is not None,
f"detected as {genuine.symbol}" if genuine else "not detected",
)
return ok
def main() -> None:
print("=" * 72)
print("Verifying the bonding-curve creator migration is read from the logs")
print("=" * 72)
results = [
check_migrated_creator_is_used(),
check_creator_matches_derived_sharing_config(),
check_creator_vault_follows(),
check_ordinary_create_unchanged(),
check_zero_rpc_path_kept(),
check_foreign_migration_is_ignored(),
check_foreign_create_event_is_not_a_coin(),
]
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()