mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-07-31 09:17:43 +00:00
a33f4fd035
Audited every script in learning-examples/ against mainnet. Broken — found by running them, all invisible offline: - listen_geyser.py crashed with IndexError after ~11 coins: it never resolved v0 address-lookup-table accounts, which geyser reports in meta.loaded_writable_addresses / loaded_readonly_addresses. Resolving them removes the crash and brings detections level with the WebSocket listeners, 35 coins each per 150 s. - compare_listeners.py logged 13,090,862 error lines / 888 MB in 150 s and never printed its own 30-second report: the inner recv() loop caught ConnectionClosed in a broad handler that only logged, so every following recv() raised at once and the outer reconnect handler was unreachable. Now 12 KB and exit 0. Same shape fixed in compare_migration_listeners.py, listen_blocksubscribe.py and extract_blocksubscribe_transactions.py; the last two also gained the reconnect loop their siblings already had. - decode_from_gettransaction.py matched instructions on account count instead of discriminator, reporting a real 19-account create_v2 as claim_cashback with every account under the wrong name. It also walked only top-level instructions, and in 40 consecutive pump.fun transactions there was 1 top-level pump instruction against 8 inner ones. - decode_from_blocksubscribe.py crashed on every real create_v2: on chain the trailing args are variable length, 0001 in one tx and 00 in another, so is_cashback_enabled can be absent entirely. - poll_bonding_curve_progress.py polled a hardcoded dead mint and took no argv. Obsolete: - Delete listen_blocksubscribe_old_raydium.py. Seven minutes on mainnet produced 0 initialize2 events while the wrapper listener caught 3 real migrations. - Delete the duplicate geyser stubs and protos under listen-new-tokens/. The protos were byte-identical to src/geyser/proto and the stubs had drifted; both geyser examples now import src.geyser.generated. - Recapture all four fixtures. The old ones were from Aug 2024 and included a 49-byte pre-creator bonding curve. Behind the protocol: - fetch_price.py, get_bonding_curve_status.py, poll_bonding_curve_progress.py and decode_from_getaccountinfo.py never read quote_mint and scaled by a hardcoded 1e9. Against a live USDC-paired curve the price was off by 1000x. - get_pumpswap_pools.py stopped parsing at coin_creator and missed the i128 virtual_quote_reserves. Live pools carry 17.5845 SOL of them, which under-prices by 3.5-23.9% when ignored. Duplication and naming: - Merge manual_buy_cu_optimized.py into manual_buy.py --cu-optimized. The deleted file's docstring said 512 KB while its code used 16 MB; simulation confirms 512 KB and 4 MB both fail MaxLoadedAccountsDataSizeExceeded on Token-2022 mints, so 16 MB is the correct value. - Merge listen_logsubscribe_abc.py into listen_logsubscribe.py. Its ATA derivation hardcoded the legacy token program, so every Associated BC it printed for a Token2022 coin was an address that does not exist on chain. Fixed on merge and cross-checked 59/59 against on-chain accounts. - Remove 19 dead symbols. BREAKING_FEE_RECIPIENTS is still live in the PumpSwap scripts and stays there. - Normalize naming: kebab-case directories, RPC method names as one lowercase token, scripts verb-first. Rules documented in CLAUDE.md. get_graduating_tokens.py is knowingly left broken: getProgramAccounts over the whole pump program is now rejected by providers and it needs a getProgramAccountsV2 rewrite, which belongs in its own PR. Verified: both offline gates pass, all 41 examples parse, every read-only script exercised on mainnet against SOL- and USDC-paired coins, no new ruff findings (427 -> 413). No script that spends real funds was run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
285 lines
9.1 KiB
Python
285 lines
9.1 KiB
Python
"""Decode the pump.fun instructions in a getTransaction response.
|
|
|
|
Usage:
|
|
uv run learning-examples/decode_from_gettransaction.py [tx.json]
|
|
|
|
Two things this example exists to show, because both are easy to get wrong:
|
|
|
|
1. Identify an instruction by its **8-byte Anchor discriminator**, not by how many
|
|
accounts it carries. Several pump.fun instructions share an account count, so
|
|
matching on the count alone silently labels a `create_v2` as `claim_cashback`
|
|
and then prints every account under the wrong name.
|
|
|
|
2. Read `meta.innerInstructions` as well as `message.instructions`. Most pump.fun
|
|
trades reach the program as a CPI from an aggregator or router, so a decoder
|
|
that only walks the top level sees almost nothing — in a sample of 40 consecutive
|
|
pump.fun transactions there was 1 top-level pump instruction against 8 inner ones.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
import sys
|
|
from collections.abc import Iterator
|
|
|
|
import base58
|
|
|
|
PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"
|
|
DEFAULT_TX = "learning-examples/raw_buy_tx_from_gettransaction.json"
|
|
IDL_PATH = "idl/pump_fun_idl.json"
|
|
|
|
# Anchor's event-CPI prefix. Every emitted event shows up as an inner instruction
|
|
# that calls the program itself with this discriminator, followed by the event's own
|
|
# 8-byte discriminator. Without recognising it, half the inner instructions in a
|
|
# trade look like unknown instructions.
|
|
ANCHOR_EVENT_CPI = bytes([0xE4, 0x45, 0xA5, 0x2E, 0x51, 0xCB, 0x9A, 0x1D])
|
|
|
|
DISCRIMINATOR_LEN = 8
|
|
# v2 trade args: discriminator + two u64, with no track_volume OptionBool.
|
|
TRADE_DATA_LEN = 24
|
|
EXPECTED_ARGC = 2
|
|
|
|
|
|
def anchor_discriminator(name: str) -> bytes:
|
|
"""Return the 8-byte Anchor discriminator for an instruction name.
|
|
|
|
Args:
|
|
name: snake_case instruction name as it appears in the IDL
|
|
|
|
Returns:
|
|
First 8 bytes of sha256("global:<name>")
|
|
"""
|
|
return hashlib.sha256(f"global:{name}".encode()).digest()[:8]
|
|
|
|
|
|
def build_instruction_index(idl: dict) -> dict[bytes, dict]:
|
|
"""Map every IDL instruction to its discriminator.
|
|
|
|
Args:
|
|
idl: Parsed Anchor IDL
|
|
|
|
Returns:
|
|
discriminator -> IDL instruction definition
|
|
"""
|
|
return {anchor_discriminator(ix["name"]): ix for ix in idl.get("instructions", [])}
|
|
|
|
|
|
def build_event_index(idl: dict) -> dict[bytes, str]:
|
|
"""Map every IDL event to its discriminator.
|
|
|
|
Args:
|
|
idl: Parsed Anchor IDL
|
|
|
|
Returns:
|
|
discriminator -> event name
|
|
"""
|
|
return {
|
|
hashlib.sha256(f"event:{event['name']}".encode()).digest()[:8]: event["name"]
|
|
for event in idl.get("events", [])
|
|
}
|
|
|
|
|
|
def decode_create_instruction(data: bytes) -> dict:
|
|
"""Decode a legacy `create` instruction (Metaplex tokens).
|
|
|
|
Args:
|
|
data: Raw instruction data including the discriminator
|
|
|
|
Returns:
|
|
Decoded args: name, symbol, uri, creator
|
|
"""
|
|
offset = 8 # Skip the 8-byte discriminator
|
|
results = []
|
|
for _ in range(3):
|
|
length = struct.unpack_from("<I", data, offset)[0]
|
|
offset += 4
|
|
results.append(data[offset : offset + length].decode("utf-8"))
|
|
offset += length
|
|
|
|
creator = (
|
|
base58.b58encode(data[offset : offset + 32]).decode("utf-8")
|
|
if offset + 32 <= len(data)
|
|
else None
|
|
)
|
|
|
|
return {
|
|
"name": results[0],
|
|
"symbol": results[1],
|
|
"uri": results[2],
|
|
"creator": creator,
|
|
"token_standard": "legacy",
|
|
"is_mayhem_mode": False,
|
|
}
|
|
|
|
|
|
def decode_create_v2_instruction(data: bytes) -> dict:
|
|
"""Decode a `create_v2` instruction (Token2022 tokens).
|
|
|
|
IDL args: name, symbol, uri, creator (pubkey), is_mayhem_mode (bool),
|
|
is_cashback_enabled (OptionBool = 1 byte).
|
|
|
|
Args:
|
|
data: Raw instruction data including the discriminator
|
|
|
|
Returns:
|
|
Decoded args including the mayhem and cashback flags
|
|
"""
|
|
offset = 8 # Skip the 8-byte discriminator
|
|
results = []
|
|
for _ in range(3):
|
|
length = struct.unpack_from("<I", data, offset)[0]
|
|
offset += 4
|
|
results.append(data[offset : offset + length].decode("utf-8"))
|
|
offset += length
|
|
|
|
creator = (
|
|
base58.b58encode(data[offset : offset + 32]).decode("utf-8")
|
|
if offset + 32 <= len(data)
|
|
else None
|
|
)
|
|
offset += 32
|
|
|
|
is_mayhem_mode = bool(data[offset]) if offset < len(data) else False
|
|
offset += 1
|
|
|
|
is_cashback_enabled = bool(data[offset]) if offset < len(data) else False
|
|
|
|
return {
|
|
"name": results[0],
|
|
"symbol": results[1],
|
|
"uri": results[2],
|
|
"creator": creator,
|
|
"token_standard": "token2022",
|
|
"is_mayhem_mode": is_mayhem_mode,
|
|
"is_cashback_enabled": is_cashback_enabled,
|
|
}
|
|
|
|
|
|
def decode_trade_instruction(data: bytes) -> dict:
|
|
"""Decode the two u64 args shared by buy/sell and their v2 forms.
|
|
|
|
v2 args carry no `track_volume` OptionBool: 24 bytes of data, being the
|
|
discriminator plus two u64. The quote-side limit is denominated in the quote
|
|
mint's raw units — lamports for SOL, 1e-6 for USDC.
|
|
|
|
Args:
|
|
data: Raw instruction data including the discriminator
|
|
|
|
Returns:
|
|
The token amount and the quote-side limit
|
|
"""
|
|
if len(data) < TRADE_DATA_LEN:
|
|
return {"error": f"expected >={TRADE_DATA_LEN} bytes of data, got {len(data)}"}
|
|
amount, quote_limit = struct.unpack_from("<QQ", data, 8)
|
|
return {"amount": amount, "quote_limit": quote_limit}
|
|
|
|
|
|
DECODERS = {
|
|
"create": decode_create_instruction,
|
|
"create_v2": decode_create_v2_instruction,
|
|
"buy": decode_trade_instruction,
|
|
"buy_v2": decode_trade_instruction,
|
|
"sell": decode_trade_instruction,
|
|
"sell_v2": decode_trade_instruction,
|
|
}
|
|
|
|
|
|
def iter_pump_instructions(result: dict) -> Iterator[tuple[str, dict]]:
|
|
"""Yield every pump.fun instruction in a getTransaction result.
|
|
|
|
Args:
|
|
result: The `result` object of a jsonParsed getTransaction response
|
|
|
|
Yields:
|
|
(location, instruction) where location is "top-level" or "inner (ix N)"
|
|
"""
|
|
for ix in result["transaction"]["message"].get("instructions", []):
|
|
if ix.get("programId") == PUMP_PROGRAM and ix.get("data"):
|
|
yield "top-level", ix
|
|
for group in result.get("meta", {}).get("innerInstructions") or []:
|
|
for ix in group.get("instructions", []):
|
|
if ix.get("programId") == PUMP_PROGRAM and ix.get("data"):
|
|
yield f"inner (ix {group['index']})", ix
|
|
|
|
|
|
def describe(
|
|
location: str, ix: dict, index: dict[bytes, dict], events: dict[bytes, str]
|
|
) -> None:
|
|
"""Print one pump.fun instruction, resolved against the IDL.
|
|
|
|
Args:
|
|
location: Where the instruction sits in the transaction
|
|
ix: The jsonParsed instruction
|
|
index: discriminator -> IDL instruction definition
|
|
events: discriminator -> IDL event name
|
|
"""
|
|
data = base58.b58decode(ix["data"])
|
|
accounts = ix.get("accounts", [])
|
|
|
|
if data[:8] == ANCHOR_EVENT_CPI:
|
|
event = events.get(data[8:16], "unknown event")
|
|
print(f"\n[{location}] anchor event emit -> {event}")
|
|
return
|
|
|
|
definition = index.get(data[:8])
|
|
|
|
if definition is None:
|
|
disc = (
|
|
struct.unpack("<Q", data[:DISCRIMINATOR_LEN])[0]
|
|
if len(data) >= DISCRIMINATOR_LEN
|
|
else None
|
|
)
|
|
print(f"\n[{location}] unknown pump.fun instruction (discriminator {disc})")
|
|
return
|
|
|
|
name = definition["name"]
|
|
print(f"\n[{location}] {name}")
|
|
|
|
decoder = DECODERS.get(name)
|
|
print(f" args: {decoder(data) if decoder else '(no decoder in this example)'}")
|
|
|
|
# The IDL under-reports the legacy instructions and omits create_v2's optional
|
|
# remaining accounts, so name what it covers and list the rest positionally.
|
|
idl_accounts = definition.get("accounts", [])
|
|
print(f" accounts: {len(accounts)} on chain, {len(idl_accounts)} named in the IDL")
|
|
for i, account in enumerate(accounts):
|
|
label = idl_accounts[i]["name"] if i < len(idl_accounts) else f"<extra {i}>"
|
|
print(f" {i:2d} {label}: {account}")
|
|
|
|
|
|
def main() -> None:
|
|
"""Decode the pump.fun instructions of one saved transaction."""
|
|
tx_file_path = sys.argv[1] if len(sys.argv) == EXPECTED_ARGC else DEFAULT_TX
|
|
if len(sys.argv) != EXPECTED_ARGC:
|
|
print(f"No path provided, using the path: {tx_file_path}")
|
|
|
|
with open(IDL_PATH) as f:
|
|
idl = json.load(f)
|
|
index = build_instruction_index(idl)
|
|
events = build_event_index(idl)
|
|
|
|
with open(tx_file_path) as f:
|
|
result = json.load(f)["result"]
|
|
|
|
found = 0
|
|
for location, ix in iter_pump_instructions(result):
|
|
describe(location, ix, index, events)
|
|
found += 1
|
|
|
|
if not found:
|
|
print(
|
|
"\nNo pump.fun instruction in this transaction. That is normal for a "
|
|
"router transaction whose pump.fun call was not captured in meta."
|
|
)
|
|
|
|
message = result["transaction"]["message"]
|
|
print("\nTransaction Information:")
|
|
print(f"Blockhash: {message['recentBlockhash']}")
|
|
print(f"Fee payer: {message['accountKeys'][0]['pubkey']}")
|
|
print(f"Signature: {result['transaction']['signatures'][0]}")
|
|
print(f"Slot: {result.get('slot')}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|