mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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:
co-authored by
Claude Opus 5
parent
b7aae5e333
commit
9c733d3401
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user