mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* 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>
345 lines
14 KiB
Python
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()
|