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>
This commit is contained in:
Anton Sauchyk
2026-09-27 12:49:31 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent b7aae5e333
commit 9c733d3401
6 changed files with 856 additions and 0 deletions
@@ -0,0 +1,143 @@
{
"signature": "26e86xLp3fdBahgD2edyeqfqY4RYob2Z7xzdAU1KiUyjmeQzYcKfBrK8qWw4nThAH5jKtc8PHK8NFNBW94MdNb7c",
"meta": {
"err": null,
"logMessages": [
"Program ComputeBudget111111111111111111111111111111 invoke [1]",
"Program ComputeBudget111111111111111111111111111111 success",
"Program ComputeBudget111111111111111111111111111111 invoke [1]",
"Program ComputeBudget111111111111111111111111111111 success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [1]",
"Program log: Instruction: CreateV2",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: MetadataPointerInstruction::Initialize",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 925 of 786608 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: InitializeMint2",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1772 of 784020 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [2]",
"Program log: Create",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]",
"Program log: Instruction: GetAccountDataSize",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1447 of 762693 compute units",
"Program return: TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb qgAAAAAAAAA=",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program 11111111111111111111111111111111 invoke [3]",
"Program 11111111111111111111111111111111 success",
"Program log: Initialize the associated token account",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]",
"Program log: Instruction: InitializeImmutableOwner",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 736 of 756424 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]",
"Program log: Instruction: InitializeAccount3",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1969 of 753352 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 16947 of 768026 compute units",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: TokenMetadataInstruction: Initialize",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 3436 of 720366 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: TokenMetadataInstruction: UpdateAuthority",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 3818 of 714680 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: MintTo",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1714 of 708670 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: SetAuthority",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1044 of 705020 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program data: G3KpTd7rY3YZAAAAQWNjZWxlcmF0aW9uaXNtIEVmZmVjdGl2ZQUAAABDQ0EvRToAAABodHRwczovL3VzZXBhaWQuYXBwL20vNGI4Y2Y3NzMtNDFjOS00NjhhLWE4Y2MtZWY5NDVhMGYxZjk02UmWLSm1qqlCQ8qVkxDzBf4V/o7PM2mb3Znp/QTECFVW48EWa7nCHgoKPedaqeLEmxlo/grMygnFMy2HMeVK/JYB7UF2pjfH4kMabjKzEzVT81ObNKfxRUiD/jckSyJ9lgHtQXamN8fiQxpuMrMTNVPzU5s0p/FFSIP+NyRLIn2ZbLdqAAAAAAAQ2EfjzwMAAKwj/AYAAAAAeMX7UdECAACAxqR+jQMABt324e51j94YQl285GzN2rYa/E2DuQ0n/r35KNihi/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKwj/AYAAAAAAAAAAAAAAAA=",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [2]",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 2149 of 697587 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 105601 of 799700 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [1]",
"Program log: Instruction: CreateFeeSharingConfig",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [2]",
"Program log: Instruction: ExtendAccount",
"Program 11111111111111111111111111111111 invoke [3]",
"Program 11111111111111111111111111111111 success",
"Program data: YWHXkF2SFnxW48EWa7nCHgoKPedaqeLEmxlo/grMygnFMy2HMeVK/JYB7UF2pjfH4kMabjKzEzVT81ObNKfxRUiD/jckSyJ9fQAAAAAAAACXAAAAAAAAAJlst2oAAAAA",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [3]",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 2149 of 655179 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 11609 of 664446 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [2]",
"Program log: Instruction: MigrateBondingCurveCreator",
"Program data: m6do3NVs8wOZbLdqAAAAANlJli0ptaqpQkPKlZMQ8wX+Ff6OzzNpm92Z6f0ExAhVVuPBFmu5wh4KCj3nWqnixJsZaP4KzMoJxTMthzHlSvxOi8XxYob4TLGlsLznNi9bk6KtGBmqEaSsprp9tgmVr5YB7UF2pjfH4kMabjKzEzVT81ObNKfxRUiD/jckSyJ9TovF8WKG+EyxpbC85zYvW5OirRgZqhGkrKa6fbYJla8=",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [3]",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 2149 of 634490 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 17967 of 649316 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program data: hWmqyLh0+1iZbLdqAAAAANlJli0ptaqpQkPKlZMQ8wX+Ff6OzzNpm92Z6f0ExAhVVuPBFmu5wh4KCj3nWqnixJsZaP4KzMoJxTMthzHlSvwATovF8WKG+EyxpbC85zYvW5OirRgZqhGkrKa6fbYJla+WAe1BdqY3x+JDGm4ysxM1U/NTmzSn8UVIg/43JEsifQEAAACWAe1BdqY3x+JDGm4ysxM1U/NTmzSn8UVIg/43JEsifRAnAQ==",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [2]",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 6592 of 627140 compute units",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 74531 of 694099 compute units",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [1]",
"Program log: Instruction: UpdateFeeShares",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [2]",
"Program log: Instruction: DistributeCreatorFees",
"Program data: pTeBcASzyiiZbLdqAAAAANlJli0ptaqpQkPKlZMQ8wX+Ff6OzzNpm92Z6f0ExAhVVuPBFmu5wh4KCj3nWqnixJsZaP4KzMoJxTMthzHlSvxOi8XxYob4TLGlsLznNi9bk6KtGBmqEaSsprp9tgmVr5YB7UF2pjfH4kMabjKzEzVT81ObNKfxRUiD/jckSyJ9AQAAAJYB7UF2pjfH4kMabjKzEzVT81ObNKfxRUiD/jckSyJ9ECcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [3]",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 2149 of 551477 compute units",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 28052 of 576808 compute units",
"Program return: 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P mWy3agAAAADZSZYtKbWqqUJDypWTEPMF/hX+js8zaZvdmen9BMQIVVbjwRZrucIeCgo951qp4sSbGWj+CszKCcUzLYcx5Ur8TovF8WKG+EyxpbC85zYvW5OirRgZqhGkrKa6fbYJla+WAe1BdqY3x+JDGm4ysxM1U/NTmzSn8UVIg/43JEsifQEAAACWAe1BdqY3x+JDGm4ysxM1U/NTmzSn8UVIg/43JEsifRAnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success",
"Program data: FbrEuFvk4cuZbLdqAAAAANlJli0ptaqpQkPKlZMQ8wX+Ff6OzzNpm92Z6f0ExAhVTovF8WKG+EyxpbC85zYvW5OirRgZqhGkrKa6fbYJla+WAe1BdqY3x+JDGm4ysxM1U/NTmzSn8UVIg/43JEsifQEAAADZ1hrbmc5jpkrfJl5lARAtJgOg3alFhhQgkxJ4FhQl9xAnAg==",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [2]",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 6592 of 544734 compute units",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 82550 of 619568 compute units",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]",
"Program log: CreateIdempotent",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: GetAccountDataSize",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1523 of 531758 compute units",
"Program return: TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb qgAAAAAAAAA=",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program 11111111111111111111111111111111 invoke [2]",
"Program 11111111111111111111111111111111 success",
"Program log: Initialize the associated token account",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: InitializeImmutableOwner",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 736 of 525413 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]",
"Program log: Instruction: InitializeAccount3",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 2153 of 522344 compute units",
"Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 17110 of 537018 compute units",
"Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success",
"Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [1]",
"Program log: Instruction: BuyV2",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ invoke [2]",
"Program log: Instruction: GetFeesWithQuoteMint",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ consumed 3360 of 460268 compute units",
"Program return: pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ AAAAAAAAAABfAAAAAAAAAB4AAAAAAAAA",
"Program pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ success",
"Log truncated"
]
}
}
+2
View File
@@ -16,6 +16,8 @@ individual scripts for a subset.
| `verify_transaction_v1.py` | every reader asks `maxSupportedTransactionVersion: 1`; a v1 `create_v2` is detected from logs alone with the envelope unreadable, and from the envelope alone with the logs stripped; the same two routes over geyser, plus the inline v1 budget |
| `verify_shreds_listener.py` | pre-execution creates decode from the instruction alone: `user` at `create_v2` account 5, a holder-reward creator derived as `PDA(["holder-rewards", mint])`, truncated trailing args decoding as not-holder-reward, lookup-table accounts resolved, and nothing reading a `meta` the stream has no field for |
| `verify_block_null_guard.py` | a `blockSubscribe` frame with `value.block: null` is skipped, not logged as an error |
| `verify_logs_listener_skips_failed_tx.py` | a `logsNotification` carrying an `err` yields no coin, so a create whose transaction reverted is not bought; a successful one is still detected, and geyser still excludes failed transactions server-side |
| `verify_creator_migration_from_logs.py` | a fee-sharing coin's `creator` comes from `MigrateBondingCurveCreatorEvent` in the same logs, not from `CreateEvent.creator`, so `creator_vault` matches the curve and the buy does not revert `ConstraintSeeds`; ordinary creates unchanged, `state_from_event` kept, and neither event is honoured unless pump.fun's own invocation emitted it |
| `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_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 |
+12
View File
@@ -219,6 +219,18 @@ class UniversalLogsListener(BaseTokenListener):
return None
log_data = data["params"]["result"]["value"]
# Skip failed txs — logsSubscribe delivers the logs a transaction
# emitted before it reverted, so a CreateEvent decoded from one
# describes a mint that was never created. Buying it fails at the
# ATA-create instruction with IncorrectProgramId, because the mint
# address is owned by the system program rather than a token
# program, and that reads as a token-program bug rather than a
# detection one. The blocks listener guards the same way on
# `meta.err`.
if log_data.get("err") is not None:
return None
logs = log_data.get("logs", [])
signature = log_data.get("signature", "unknown")
+148
View File
@@ -20,6 +20,9 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Fields in a "Program <id> invoke [n]" / "Program <id> success" frame line.
_FRAME_LOG_FIELDS = 3
# Index of the optional `quote_mint` remaining account on create_v2. Accounts
# 1-16 are in the IDL; 17-19 are optional remaining accounts appended only when
# creating a coin with a non-native quote mint. See COIN_CREATION.md in the
@@ -107,6 +110,13 @@ class PumpFunEventParser(EventParser):
"<Q", self._create_event_discriminator_bytes
)[0]
# A coin with creator-fee sharing has its BondingCurve.creator rewritten
# in the same transaction that creates it, and this event reports the
# value written. Absent from older IDLs, hence the get().
self._migrate_creator_discriminator_bytes = event_discriminators.get(
"MigrateBondingCurveCreatorEvent"
)
instruction_discriminators = self._idl_parser.get_instruction_discriminators()
self._create_instruction_discriminator_bytes = instruction_discriminators[
"create"
@@ -168,6 +178,7 @@ class PumpFunEventParser(EventParser):
try:
create_instruction_found = False
program_data_entries = []
from_program = self._emitted_by_program(logs)
# First, collect all Program data entries and note when Create instruction happens
for i, log in enumerate(logs):
@@ -178,6 +189,11 @@ class PumpFunEventParser(EventParser):
f"📝 Found {instruction_type} instruction at log index {i}"
)
elif "Program data:" in log:
# A CreateEvent another program emitted is not pump.fun
# saying a coin exists; trading on one buys a mint that was
# never created.
if from_program is not None and not from_program[i]:
continue
# Extract base64 encoded event data
encoded_data = log.split("Program data: ")[1].strip()
program_data_entries.append((i, encoded_data, log))
@@ -314,6 +330,7 @@ class PumpFunEventParser(EventParser):
associated_bonding_curve = self._derive_associated_bonding_curve(
mint, bonding_curve, token_program_id
)
creator = self._creator_after_migration(logs, mint, creator)
creator_vault = self._derive_creator_vault(creator)
logger.info(
@@ -372,6 +389,137 @@ class PumpFunEventParser(EventParser):
logger.exception("Failed to parse token creation from logs")
return None
def _emitted_by_program(self, logs: list[str]) -> list[bool] | None:
"""Flag, per log line, whether pump.fun's own invocation emitted it.
`Program data:` names no program, so without this any program sharing
the transaction can emit a payload the parsers below will read.
Returns:
One bool per log line, or None when the logs carry no invocation
frames at all — a reconstructed log list rather than an RPC's, for
which there is nothing to attribute by and the callers keep their
previous behaviour. A real notification always carries frames.
"""
emitters = self._emitting_program(logs)
if not any(emitters):
return None
program_id = str(self.get_program_id())
return [emitter == program_id for emitter in emitters]
@staticmethod
def _emitting_program(logs: list[str]) -> list[str | None]:
"""Map each log line to the program id whose invocation emitted it.
The runtime brackets every invocation with `Program <id> invoke [depth]`
and a matching `Program <id> success` or `... failed`, so a stack over
those lines attributes the lines between them. A line emitted outside
any invocation, which should not happen, maps to None.
Args:
logs: The transaction's log lines, in order
Returns:
One entry per log line: the program id that emitted it, or None
"""
emitters: list[str | None] = []
stack: list[str] = []
for log in logs:
# "Program <id> invoke [n]" / "Program <id> success" — three
# fields before anything a frame line carries.
parts = log.split(" ")
is_frame = len(parts) >= _FRAME_LOG_FIELDS and parts[0] == "Program"
if is_frame and parts[2].startswith("invoke"):
stack.append(parts[1])
emitters.append(None)
continue
if is_frame and parts[2] in ("success", "failed"):
emitters.append(None)
if stack:
stack.pop()
continue
emitters.append(stack[-1] if stack else None)
return emitters
def _creator_after_migration(
self,
logs: list[str],
mint: Pubkey,
creator: Pubkey,
) -> Pubkey:
"""Return the creator the bonding curve ends the transaction holding.
CreateEvent.creator is the wallet that submitted the create, which is
what BondingCurve.creator holds for an ordinary coin. A coin with
creator-fee sharing is different: the same transaction goes on to call
`migrate_bonding_curve_creator`, which overwrites the curve's creator
with the sharing-config PDA. This has nothing to do with graduation —
the `migrate`/`migrate_v2` instructions that move a completed curve to
PumpSwap — it rewrites one field on a curve that has just been created.
`buy_v2` derives `creator_vault` from `bonding_curve.creator`, so
building the buy from the CreateEvent value derives the wrong vault and
the transaction reverts with `ConstraintSeeds` (2006).
The rewrite announces the value it wrote, in the same log set the
CreateEvent came from, so no RPC call is needed to find it and
extreme_fast_mode keeps its zero-RPC path. A coin whose creator is
rewritten in a *later* transaction is not covered: the launch logs
cannot carry an event that has not happened yet, and the buy reverts
the same way.
Only a payload pump.fun itself emitted is considered. `Program data:`
carries no program of its own, and any program sharing the transaction
can emit eight bytes that match this discriminator; honouring one would
let a launcher pick the creator the bot builds its buy from and make
every sniper's transaction revert.
Args:
logs: The transaction's log lines, in order
mint: Mint of the coin the CreateEvent described, to ignore a
migration belonging to a different coin in the same transaction
creator: Creator the CreateEvent reported
Returns:
The migrated creator, or `creator` unchanged when the transaction
carries no migration for this mint.
"""
if not self._migrate_creator_discriminator_bytes:
return creator
from_program = self._emitted_by_program(logs)
for index, log in enumerate(logs):
if "Program data:" not in log:
continue
if from_program is not None and not from_program[index]:
continue
try:
decoded_data = base64.b64decode(log.split("Program data: ")[1].strip())
except (ValueError, TypeError) as e:
logger.debug(f"Skipping an undecodable Program data entry: {e}")
continue
if not decoded_data.startswith(self._migrate_creator_discriminator_bytes):
continue
decoded_event = self._idl_parser.decode_event_data(
decoded_data, "MigrateBondingCurveCreatorEvent"
)
if not decoded_event:
continue
fields = decoded_event.get("fields", {})
event_mint = _coerce_pubkey(fields.get("mint"))
new_creator = _coerce_pubkey(fields.get("new_creator"))
if event_mint != mint or new_creator is None:
continue
logger.info(f"🔁 Bonding curve creator migrated to: {new_creator}")
return new_creator
return creator
def parse_token_creation_from_instruction(
self, instruction_data: bytes, accounts: list[int], account_keys: list[bytes]
) -> TokenInfo | None:
@@ -0,0 +1,344 @@
"""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()
@@ -0,0 +1,207 @@
"""Verify the logs listener ignores creates whose transaction reverted.
`UniversalLogsListener._wait_for_token_creation` read `logs` and `signature` out
of a logsNotification's `value` and never looked at `value.err`. logsSubscribe
delivers the logs a transaction emitted *before* it reverted, so a CreateEvent
decoded from one describes a coin that does not exist, and the bot bought it.
The buy fails at the ATA-create instruction with `IncorrectProgramId`: the mint
account was never created, so the address is owned by the system program rather
than by a token program. That reads as a token-program bug and sends the reader
into `_correct_token_program` and `state_from_event`, neither of which is at
fault. `extreme_fast_mode` compounds it without causing it — the CreateEvent
carried the trailing fields, so `state_from_event` was set and the pre-buy curve
read, which would have found no curve and skipped the buy, never ran. The cost
is one full buy fee per occurrence.
The blocks listener already guarded on `meta.err`; the geyser listener excludes
failed transactions server-side with `SubscribeRequestFilterTransactions.failed
= False`. The shreds listener sees transactions pre-execution and has no `meta`
at all, so it cannot know the outcome — a design limit, not this bug.
Offline machine checks, no network and no funds moved. A stub websocket replays
scripted frames into the real `_wait_for_token_creation`, with real create logs
taken from the captured fixtures under `cookbook/pumpfun/decode/`:
1. A create frame carrying an `err` yields no token. This is the check that
fails before the fix.
2. The same logs with `err: null` still yield a token, so the guard did not
swallow genuine creates.
3. A frame with no `err` key at all still yields a token.
4. The geyser subscription request still sets `failed = False`, which is what
keeps the same hole closed there.
Usage:
uv run tests/regression/verify_logs_listener_skips_failed_tx.py
"""
import asyncio
import json
import sys
from collections.abc import Iterator
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, TokenInfo # noqa: E402
from monitoring.universal_geyser_listener import UniversalGeyserListener # noqa: E402
from monitoring.universal_logs_listener import UniversalLogsListener # noqa: E402
DECODE_DIR = PROJECT_ROOT / "cookbook" / "pumpfun" / "decode"
# The create transaction that prompted this verifier reverted this way.
INSTRUCTION_ERROR = {"InstructionError": [4, {"Custom": 1}]}
SIGNATURE = "3TpvJ71gxpfpCWEiJ9XzADK732aePo61yNCnuUFPo2aMLaT3c9qdXc28EyaFKs1kRGEZBFhB2NyRwvEhZk1fUKvZ"
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_logs() -> list[str]:
"""Real pump.fun create logs, from the first fixture that carries them."""
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):
return logs
raise SystemExit("No create fixture with logs under cookbook/pumpfun/decode") # noqa: TRY003
def _frame(logs: list[str], err: object, *, include_err: bool = True) -> dict:
"""Build a logsNotification frame around one transaction's logs."""
value = {"signature": SIGNATURE, "logs": logs}
if include_err:
value["err"] = err
return {
"jsonrpc": "2.0",
"method": "logsNotification",
"params": {
"result": {"context": {"slot": 372_000_000}, "value": value},
"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)
async def _feed(frame: dict) -> TokenInfo | None:
"""Run one frame through the real handler and return what it yielded."""
listener = UniversalLogsListener(
wss_endpoint="wss://stub.invalid", platforms=[Platform.PUMP_FUN]
)
return await listener._wait_for_token_creation( # noqa: SLF001
SimpleNamespace(recv=StubWebSocket(frame).recv)
)
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_reverted_create_yields_no_token() -> bool:
print("\n1. A create whose transaction reverted yields no token")
token_info = await _feed(_frame(_create_logs(), INSTRUCTION_ERROR))
return _check(
"handler return value",
token_info is None,
"None"
if token_info is None
else f"{token_info.symbol} ({token_info.mint}) — a coin that does not exist",
)
async def check_successful_create_is_still_detected() -> bool:
print("\n2. The same logs with err: null still yield a token")
token_info = await _feed(_frame(_create_logs(), None))
return _check(
"handler return value",
token_info is not None,
f"{token_info.symbol}" if token_info else "not detected",
)
async def check_missing_err_key_is_still_detected() -> bool:
print("\n3. A frame carrying no err key at all still yields a token")
token_info = await _feed(_frame(_create_logs(), None, include_err=False))
return _check(
"handler return value",
token_info is not None,
f"{token_info.symbol}" if token_info else "not detected",
)
def check_geyser_excludes_failed_transactions() -> bool:
print("\n4. The geyser subscription still asks the server to drop failed txs")
listener = UniversalGeyserListener(
geyser_endpoint="stub.invalid:443",
geyser_api_token="stub", # noqa: S106 — offline stub, never connects
geyser_auth_type="x-token",
platforms=[Platform.PUMP_FUN],
)
request = listener._create_subscription_request() # noqa: SLF001
filters = list(request.transactions.values())
ok = _check("transaction filters", bool(filters), f"{len(filters)} filter(s)")
for name, tx_filter in request.transactions.items():
ok &= _check(
name,
tx_filter.HasField("failed") and tx_filter.failed is False,
f"failed={tx_filter.failed if tx_filter.HasField('failed') else 'unset'}",
)
return ok
async def main() -> None:
print("=" * 72)
print("Verifying the logs listener skips reverted token creates")
print("=" * 72)
results = [
await check_reverted_create_yields_no_token(),
await check_successful_create_is_still_detected(),
await check_missing_err_key_is_still_detected(),
check_geyser_excludes_failed_transactions(),
]
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())