Files
Anton Sauchyk a33f4fd035 fix(learning-examples): repair crashing listeners and decoders, prune obsolete paths, unify naming (#181)
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>
2026-07-29 15:26:26 +02:00

137 lines
4.8 KiB
Python

"""
This module provides functionality to:
1. Find market addresses by base mint
2. Fetch and parse market data (including pool addresses) from Pump AMM program accounts
"""
import asyncio
import os
import struct
import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.types import MemcmpOpts
from solders.pubkey import Pubkey
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA")
import sys
TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # argv[1] or hardcode
async def get_market_address_by_base_mint(
base_mint_address: Pubkey, amm_program_id: Pubkey
):
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
base_mint_bytes = bytes(base_mint_address)
# Define the offset for base_mint field
offset = 43
# Create the filter to match the base_mint
filters = [MemcmpOpts(offset=offset, bytes=base_mint_bytes)]
# Retrieve the accounts that match the filter
response = await client.get_program_accounts(
amm_program_id, # AMM program ID
encoding="base64",
filters=filters,
)
pool_addresses = [account.pubkey for account in response.value]
return pool_addresses[0]
async def get_market_data(market_address: Pubkey):
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
response = await client.get_account_info(market_address, encoding="base64")
data = response.value.data
parsed_data = {}
offset = 8
# Fields end at 261; live pool accounts are 301 bytes with trailing padding.
# virtual_quote_reserves is an i128, not a u64 — reading only 8 bytes happens
# to work while the high half is zero, and silently breaks when it isn't.
fields = [
("pool_bump", "u8"),
("index", "u16"),
("creator", "pubkey"),
("base_mint", "pubkey"),
("quote_mint", "pubkey"),
("lp_mint", "pubkey"),
("pool_base_token_account", "pubkey"),
("pool_quote_token_account", "pubkey"),
("lp_supply", "u64"),
("coin_creator", "pubkey"),
("is_mayhem_mode", "bool"),
("is_cashback_coin", "bool"),
("virtual_quote_reserves", "i128"),
]
for field_name, field_type in fields:
if field_type == "pubkey":
value = data[offset : offset + 32]
parsed_data[field_name] = base58.b58encode(value).decode("utf-8")
offset += 32
elif field_type in {"u64", "i64"}:
value = (
struct.unpack("<Q", data[offset : offset + 8])[0]
if field_type == "u64"
else struct.unpack("<q", data[offset : offset + 8])[0]
)
parsed_data[field_name] = value
offset += 8
elif field_type == "i128":
if len(data) < offset + 16:
parsed_data[field_name] = 0
continue
parsed_data[field_name] = int.from_bytes(
data[offset : offset + 16], "little", signed=True
)
offset += 16
elif field_type == "u16":
value = struct.unpack("<H", data[offset : offset + 2])[0]
parsed_data[field_name] = value
offset += 2
elif field_type == "u8":
value = data[offset]
parsed_data[field_name] = value
offset += 1
elif field_type == "bool":
parsed_data[field_name] = (
bool(data[offset]) if len(data) > offset else False
)
offset += 1
return parsed_data
async def main():
market_address = await get_market_address_by_base_mint(
TOKEN_MINT, PUMP_AMM_PROGRAM_ID
)
print(market_address)
market_data = await get_market_data(market_address)
for key, value in market_data.items():
print(f" {key}: {value}")
# Quote against effective reserves. Upstream's release note says
# virtual_quote_reserves is 0 on all pools; that is out of date — pools carry
# 17.5845 SOL of virtual reserves, so quoting off the raw vault balance
# under-prices by anywhere from a few percent to over 20%.
virtual = market_data.get("virtual_quote_reserves", 0)
if virtual:
print(
f"\nNote: this pool carries {virtual / 1e9:.9f} SOL of virtual quote "
"reserves. Add them to pool_quote_token_account.amount before quoting."
)
if __name__ == "__main__":
asyncio.run(main())