mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-13 15:28:05 +00:00
* fix(tx-status): read meta.err before reporting a trade succeeded
confirm_transaction answers "did this signature land in a block?", never
"did it succeed". A landed transaction can have reverted, and RPC reports
that only in meta.err. Reading whether a call threw instead of reading
meta.err produced false results in both directions.
False success — a reverted trade reported as confirmed:
- ten examples (pump.fun, pumpswap, letsbonk) printed "Transaction
confirmed" without checking meta.err
- src/cleanup/manager.py discarded confirm_transaction's boolean and
logged "Closed successfully" unconditionally, so a reverted close
reported rent as reclaimed while the account stayed open
- learning-examples/cleanup_accounts.py did the same
False failure — a good trade reported as unconfirmed:
- _get_transaction_result omitted maxSupportedTransactionVersion, so the
RPC answered -32015 for every versioned (v0) transaction. meta.err was
unreadable and a successful trade read back as failed. The bot sends
legacy transactions, which is the only reason this was survivable.
- confirm_transaction raised TypeError on a base58 str (solana-py wants a
Signature) while _get_transaction_result raised on a Signature (not
JSON serializable). Both were swallowed by a broad except into "not
confirmed". The annotations pointed the wrong way too:
build_and_send_transaction returns Signature, not str.
Changes:
- add learning-examples/tx_status.py — assert_transaction_succeeded and
confirm_and_assert, replacing the copy duplicated in mint_and_buy{,_v2}
- wire it into the ten examples that confirmed without checking
- read the boolean in both cleanup paths
- normalize str/Signature at the client boundary; correct the annotations
- send maxSupportedTransactionVersion: 0 on getTransaction
- split verify_transaction_succeeded out of confirm_transaction so the
meta.err check can run against a transaction that landed earlier
Two reporting bugs found while testing the above:
- live_v2_round_trip read balances at solana-py's default (finalized)
commitment while confirming trades at "confirmed", so the end read saw
pre-trade state and it printed "net change: +0.000000000 SOL" after a
real round trip. Verified: on a busy account finalized trails confirmed
by ~263k lamports.
- cleanup_accounts produced no output at all, success or failure, because
get_logger attaches no handler and only the bot installs one. httpx is
pinned to WARNING alongside it — the RPC endpoint carries an API key.
Adds learning-examples/verify_tx_status_checks.py: offline stub checks, a
scan that fails if an example confirms without checking meta.err, an AST
check that nothing in src/ discards the boolean, a guard that
getTransaction opts into v0, and --live, which replays issue #175's three
signatures against mainnet and requires both layers to reject them on
meta.err (Custom: 6062) rather than on a failed fetch. The two src/ guards
were mutation-tested: reintroducing each bug makes them fail.
The BuybackFeeRecipientMissing (6062) half of #175 was already fixed by
the buy_v2/sell_v2 migration in 02343b7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* fix(client): retry RPC timeouts instead of letting them escape post_rpc
aiohttp signals a request timeout with asyncio.TimeoutError, which is not
an aiohttp.ClientError. post_rpc caught only the latter, so every RPC
timeout propagated to the caller unretried — and str() on it is empty, so
whatever logged it printed a blank reason.
Found while running learning-examples/live_listener_matrix.py: three of
the four listeners died mid-run with "CRASHED: " and no message. The
endpoint was answering getHealth in ~100ms while getAccountInfo hung past
60s, and every caller that touches it (sol_balance, ata_is_closed,
AccountCleanupManager.cleanup_ata) went down with it. With the retry in
place the same run degrades to a logged failure and completes.
Also makes the verifier report a raising check as a failure rather than
aborting the run — several checks assert that a call does NOT raise, so
the raise is the finding and the remaining checks still need to report.
Covered by "RPC timeouts are retried, not raised" in
verify_tx_status_checks.py, mutation-tested against the one-exception
version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* fix(examples): raise websocket frame limit, load .env, drop dead code
Running every learning example turned up three separate reasons a shipped
script could not work at all.
blockSubscribe examples were completely non-functional. websockets
defaults max_size to 1 MiB and Solana's blockSubscribe frames run well
past that, so the connection died with a 1009 close on the first real
block and the retry loop spun. listen_blocksubscribe.py logged 158,613
"message too big" errors in 35s and decoded zero tokens; compare_listeners
produced 10.1M error lines and its block column never reported anything.
Pass max_size=WEBSOCKET_MAX_MESSAGE_BYTES (32 MiB, the value the bot's own
listeners already use) at all 12 example connect sites — logsSubscribe and
programSubscribe included, since they have the same latent ceiling.
After: listen_blocksubscribe decodes tokens with 0 errors, and
compare_listeners reports provider_1_block alongside geyser and logs.
Seven examples read SOLANA_* from the environment but never called
load_dotenv(), so they only ran with variables already exported —
manual_buy, manual_buy_cu_optimized, manual_buy_geyser, manual_sell,
fetch_price, blockSubscribe_extract_transactions and
sample_cashback_pumpswap. manual_buy died on
"None isn't a valid URI: scheme isn't ws or wss" against a normal .env
checkout.
Dead code and artifacts:
- unused datetime import in listen_pumpportal
- initial_real_token_reserves computed and never read in mint_and_buy and
mint_and_buy_v2
- gitignore blockSubscribe-transactions/, which a shipped example writes
into 1200+ files deep and which nothing ignored
- stop tracking trades/trades.log; .gitignore has listed it all along, but
a tracked file ignores .gitignore
ruff over src/ and learning-examples/ goes 2396 -> 2393 findings: the three
removals, nothing new.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* fix: read balances at confirmed, derive letsbonk platform_config per pool
Running every example against mainnet surfaced four more bugs, three of
them the same root cause as the commitment bug already in this branch:
solana-py defaults to "finalized", but trades confirm at "confirmed", and
finalization lags far enough behind that anything read in between is
pre-trade state.
get_token_account_balance defaulted to finalized. Cleanup reads it to
decide whether to burn before closing, so right after a sell it saw the
pre-sell amount and built a burn for tokens the account no longer held —
the burn + close reverted with InsufficientFunds and the rent stayed
locked. Observed live: "Burning 35766666 tokens" on an account the sell
had already emptied, then Custom(1). Defaults to confirmed now, which is
also what confirm_transaction uses.
manual_sell_pumpswap read the user's base balance at finalized, which
failed two ways in one session: "could not find account" when the ATA had
been created by a buy moments earlier, and a stale non-zero balance whose
transfer then reverted with insufficient funds. Pool vault reads move to
confirmed too, so quotes are not priced off stale reserves.
The letsbonk examples hardcoded platform_config. LaunchLab pools do not
share one — partner launches carry their own — so every buy/sell against
such a pool failed with ConstraintAddress (2012):
AnchorError caused by account: platform_config
Left: 5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA
Right: FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1
PoolState carries platform_config at field 18 and these scripts already
parse the pool, so they now take the pool's own value and keep the
constant only as a fallback. src/platforms/letsbonk already documented
this; only the examples were stale. All four scripts now simulate and land
(buy_exact_in, sell_exact_in, buy_exact_out, sell_exact_out).
They also discarded simulation logs on failure, printing an error number
with no indication of which account or constraint broke. They print the
program logs now — that is how the above was diagnosed.
Runnability and credentials:
- fetch_price, cleanup_accounts and the four letsbonk scripts hardcoded a
placeholder ("...", "YOUR_TOKEN_MINT_ADDRESS_HERE") and could not run at
all without editing the source. They take argv[1] now, matching
manual_sell and the pumpswap scripts. cleanup_accounts takes "2022" as
argv[2] for Token-2022 mints, which every pump.fun coin is.
- six scripts printed the RPC/WSS endpoint, which carries an API key, into
stdout. They print only the host now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* fix(cleanup): never burn wrapped SOL, and label what actually happened
learning-examples/cleanup_accounts.py burned any non-zero balance before
closing. The token program rejects a burn of native (wrapped) SOL with
NativeNotSupported (error 10), so the burn + close transaction reverted and
a WSOL account could never be cleaned up — the pumpswap examples leave one
behind on every sell. Closing a WSOL account already returns both the
wrapped lamports and the rent, so there is nothing to burn first.
src/cleanup/manager.py already had this guard; only the example was
missing it. Observed live: "Burning 2206381 tokens" then Custom(10), with
2.2m lamports stranded until the guard went in.
The success line also claimed "Burned and closed" for the unwrap path,
which burns nothing. It now reports Unwrapped/Burned/Closed to match the
instructions actually built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* chore: drop captured-output artifacts and a spent one-off investigation script
learning-examples/decoded_buy_tx_from_getTransaction.json and
decoded_create_tx_from_getTransaction.json are not JSON — they are captured
stdout from the decode scripts, several objects concatenated, so json.load
raises "Extra data" on both. Nothing reads them and nothing can. The
raw_*.json fixtures stay: all three are valid input and were re-checked
through decode_from_getTransaction / decode_from_blockSubscribe /
decode_from_getAccountInfo.
learning-examples/pumpswap/sample_cashback_pumpswap.py was added by #168 to
find the position of the extra account that cashback PumpSwap pools
require. That question is answered — the layouts are in
platforms/pumpfun/instruction_builder.py and machine-checked against the
IDL by verify_v2_account_layout.py. It is undocumented, unlike every other
pumpswap example in the README, and nothing imports it.
Not touched: logs/ (run history), .cursor/.kiro/.windsurf (deliberate
mirrors of the same rules for other editors), and the raw_*.json fixtures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
* fix(tx-status): fail closed on missing metadata, stop retrying landed reverts
Addresses the must-fix findings from review. Two of them were wrong in code
this branch added.
assert_transaction_succeeded folded meta=None into err=None and returned
success. Missing execution metadata means the outcome is unknown, not that
it succeeded — which is the exact mistake this module exists to prevent. It
raises now.
It also always read at "confirmed" while confirm_and_assert accepted a
commitment argument, so confirming at "finalized" and then reading status at
"confirmed" could report success before the finalization the caller asked
for. The commitment is threaded through.
The manual buy/sell retry loops built one message and blockhash before the
loop, so a landed revert was retried by resubmitting byte-identical signed
bytes — three attempts with backoff that could never succeed. Reverts now
raise TransactionRevertedError (a RuntimeError subclass, so existing
handlers keep working) and the loops treat it as terminal. Rebuilding and
re-signing per attempt is the fuller fix but a bigger change to these
scripts than this branch should carry.
cleanup_accounts guessed the mint's token program, defaulting to legacy SPL
unless argv[2] was "2022". The ATA address differs between programs, so a
wrong guess derives an address that does not exist and the script reports
"already closed" for an account it never looked at. It reads the owner off
the mint account instead, which is authoritative, and rejects anything not
owned by a token program. Verified live: pump.fun coins resolve to
Token-2022, letsbonk and USDC to legacy. argv[2] is gone — nothing to guess.
urlsplit(...).netloc keeps any user:pass@ userinfo, so the endpoint
redaction added earlier still printed credentials for providers that put the
key there. Uses .hostname in all five sites.
Four checks added to verify_tx_status_checks.py, each mutation-tested:
missing metadata, revert-is-terminal, commitment propagation, and a scan
that fails if any example goes back to netloc.
Skipped, both minor and pre-existing repo-wide rather than introduced here:
validating that RPC/WSS env vars are non-None before connecting (every
example has this shape; fixing two of them would just make it inconsistent),
and the cryptic base58 error when a letsbonk script runs with no argument,
which matches the "..." placeholder convention the pumpswap and manual_sell
examples already use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
762 lines
28 KiB
Python
762 lines
28 KiB
Python
"""
|
|
This standalone script demonstrates how to buy tokens on the PUMP AMM (pAMM) protocol.
|
|
It covers the complete flow from finding markets to executing buys with mayhem mode support.
|
|
|
|
Key concepts demonstrated:
|
|
- Finding AMM pool addresses by token mint
|
|
- Parsing binary account data structures
|
|
- Dynamic fee recipient calculation (mayhem mode vs standard)
|
|
- Program Derived Address (PDA) derivation
|
|
- WSOL wrapping (converting SOL to wrapped SOL for SPL token operations)
|
|
- Volume tracking incentives integration
|
|
- Transaction simulation before sending
|
|
- Slippage protection mechanisms
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import random
|
|
import struct
|
|
import sys
|
|
|
|
import base58
|
|
from dotenv import load_dotenv
|
|
from solana.rpc.async_api import AsyncClient
|
|
from solana.rpc.commitment import Confirmed
|
|
from solana.rpc.types import MemcmpOpts, TxOpts
|
|
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
|
|
from solders.instruction import AccountMeta, Instruction
|
|
from solders.keypair import Keypair
|
|
from solders.message import Message
|
|
from solders.pubkey import Pubkey
|
|
from solders.system_program import TransferParams, transfer
|
|
from solders.transaction import VersionedTransaction
|
|
from spl.token.instructions import (
|
|
SyncNativeParams,
|
|
create_idempotent_associated_token_account,
|
|
get_associated_token_address,
|
|
sync_native,
|
|
)
|
|
|
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import tx_status # noqa: E402
|
|
|
|
load_dotenv()
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
|
|
|
|
TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1]
|
|
PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
|
|
PAYER = Keypair.from_bytes(PRIVATE_KEY)
|
|
SLIPPAGE = 0.3 # 30% - maximum acceptable price movement during trade
|
|
|
|
# Token configuration
|
|
TOKEN_DECIMALS = 6 # Standard for most pump.fun tokens
|
|
|
|
# Program instruction discriminators (first 8 bytes identify the instruction)
|
|
BUY_DISCRIMINATOR = bytes.fromhex("66063d1201daebea")
|
|
|
|
# ============================================================================
|
|
# Solana Program IDs and System Accounts
|
|
# ============================================================================
|
|
|
|
SOL = Pubkey.from_string("So11111111111111111111111111111111111111112")
|
|
PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA")
|
|
PUMP_SWAP_GLOBAL_CONFIG = Pubkey.from_string(
|
|
"ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw"
|
|
)
|
|
SYSTEM_TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
|
|
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
|
|
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
|
|
SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
|
|
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
|
|
)
|
|
PUMP_SWAP_EVENT_AUTHORITY = Pubkey.from_string(
|
|
"GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR"
|
|
)
|
|
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
|
|
|
|
# 8 breaking-upgrade fee recipients (pump-swap program upgrade 2026-04-28).
|
|
# Two new accounts must be appended after pool-v2: the fee recipient (readonly)
|
|
# and its quote-mint ATA (mutable).
|
|
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
|
|
BREAKING_FEE_RECIPIENTS = [
|
|
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
|
|
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
|
|
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
|
|
Pubkey.from_string("3BpXnfJaUTiwXnJNe7Ej1rcbzqTTQUvLShZaWazebsVR"),
|
|
Pubkey.from_string("5cjcW9wExnJJiqgLjq7DEG75Pm6JBgE1hNv4B2vHXUW6"),
|
|
Pubkey.from_string("EHAAiTxcdDwQ3U4bU6YcMsQGaekdzLS3B5SmYo46kJtL"),
|
|
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
|
|
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
|
|
]
|
|
|
|
# ============================================================================
|
|
# Constants for Account Structure Parsing
|
|
# ============================================================================
|
|
|
|
# Pool account structure offsets
|
|
POOL_DISCRIMINATOR_SIZE = 8
|
|
POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data
|
|
POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored
|
|
POOL_IS_CASHBACK_OFFSET = 244
|
|
# virtual_quote_reserves is an i128 appended after the flags. Pool fields
|
|
# end at 261; live accounts are 301 bytes with trailing padding.
|
|
POOL_VIRTUAL_QUOTE_RESERVES_OFFSET = 245
|
|
POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
|
|
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
|
|
|
|
# GlobalConfig structure offsets
|
|
GLOBALCONFIG_DISCRIMINATOR_SIZE = 8
|
|
GLOBALCONFIG_ADMIN_SIZE = 32
|
|
GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32
|
|
GLOBALCONFIG_RESERVED_FEE_OFFSET = (
|
|
GLOBALCONFIG_DISCRIMINATOR_SIZE
|
|
+ GLOBALCONFIG_ADMIN_SIZE
|
|
+ GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE
|
|
)
|
|
|
|
# Fee recipients
|
|
STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string(
|
|
"7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"
|
|
)
|
|
|
|
# Solana constants
|
|
LAMPORTS_PER_SOL = 1_000_000_000
|
|
COMPUTE_UNIT_PRICE = 10_000 # Micro-lamports per compute unit
|
|
COMPUTE_UNIT_BUDGET = 200_000 # Max compute units for transaction
|
|
|
|
# Buy-specific constants
|
|
PROTOCOL_FEE_BUFFER = 0.1 # 10% buffer for protocol fees when wrapping SOL
|
|
VOLUME_TRACKING_ENABLED = 1 # 1 = true, 0 = false
|
|
|
|
|
|
# ============================================================================
|
|
# Market Discovery
|
|
# ============================================================================
|
|
|
|
|
|
async def get_market_address_by_base_mint(
|
|
client: AsyncClient, base_mint_address: Pubkey, amm_program_id: Pubkey
|
|
) -> Pubkey:
|
|
"""Find the AMM pool address for a specific token.
|
|
|
|
Uses getProgramAccounts RPC method with a memcmp filter to find the pool
|
|
that matches the given token mint address.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
base_mint_address: Token mint to find the pool for
|
|
amm_program_id: PUMP AMM program address
|
|
|
|
Returns:
|
|
Address of the AMM pool (market) for the token
|
|
"""
|
|
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=bytes(base_mint_address))]
|
|
response = await client.get_program_accounts(
|
|
amm_program_id, encoding="base64", filters=filters
|
|
)
|
|
return response.value[0].pubkey
|
|
|
|
|
|
async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
|
|
"""Parse binary pool account data into a structured dictionary.
|
|
|
|
The pool account stores data in a specific binary format. This function
|
|
deserializes that data based on the known structure.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
market_address: Address of the pool account
|
|
|
|
Returns:
|
|
Dictionary with parsed pool data fields
|
|
"""
|
|
response = await client.get_account_info(market_address, encoding="base64")
|
|
data = response.value.data
|
|
parsed_data: dict = {}
|
|
|
|
offset = POOL_DISCRIMINATOR_SIZE
|
|
|
|
# Field definitions: (name, type)
|
|
# Types: u8=1 byte, u16=2 bytes, u64/i64=8 bytes, pubkey=32 bytes
|
|
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"),
|
|
# Appended after coin_creator: is_mayhem_mode (243), is_cashback_coin
|
|
# (244), then virtual_quote_reserves as an i128 at 245..261. Live pool
|
|
# accounts are 301 bytes (fields end at 261, rest is padding).
|
|
("is_mayhem_mode", "u8"),
|
|
("is_cashback_coin", "u8"),
|
|
]
|
|
|
|
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"}:
|
|
format_char = "<Q" if field_type == "u64" else "<q"
|
|
parsed_data[field_name] = struct.unpack(
|
|
format_char, data[offset : offset + 8]
|
|
)[0]
|
|
offset += 8
|
|
elif field_type == "u16":
|
|
parsed_data[field_name] = struct.unpack("<H", data[offset : offset + 2])[0]
|
|
offset += 2
|
|
elif field_type == "u8":
|
|
parsed_data[field_name] = data[offset]
|
|
offset += 1
|
|
|
|
return parsed_data
|
|
|
|
|
|
# ============================================================================
|
|
# Program Derived Address (PDA) Derivation
|
|
# ============================================================================
|
|
# PDAs are deterministic addresses derived from seeds and a program ID.
|
|
# They allow programs to own accounts without needing a private key.
|
|
|
|
|
|
def find_coin_creator_vault(coin_creator: Pubkey) -> Pubkey:
|
|
"""Derive the PDA for the coin creator's fee vault.
|
|
|
|
The creator vault collects fees on behalf of the token creator.
|
|
This is a deterministic address that can be recalculated by anyone.
|
|
|
|
Args:
|
|
coin_creator: Public key of the token creator
|
|
|
|
Returns:
|
|
PDA of the creator's vault authority
|
|
"""
|
|
derived_address, _ = Pubkey.find_program_address(
|
|
[b"creator_vault", bytes(coin_creator)],
|
|
PUMP_AMM_PROGRAM_ID,
|
|
)
|
|
return derived_address
|
|
|
|
|
|
def find_global_volume_accumulator() -> Pubkey:
|
|
"""Derive the PDA for the global volume accumulator.
|
|
|
|
This account tracks total trading volume across all pools.
|
|
Volume tracking is used for incentive programs and analytics.
|
|
|
|
Returns:
|
|
PDA of the global volume accumulator
|
|
"""
|
|
derived_address, _ = Pubkey.find_program_address(
|
|
[b"global_volume_accumulator"],
|
|
PUMP_AMM_PROGRAM_ID,
|
|
)
|
|
return derived_address
|
|
|
|
|
|
def find_user_volume_accumulator(user: Pubkey) -> Pubkey:
|
|
"""Derive the PDA for a user's volume accumulator.
|
|
|
|
Tracks individual user's trading volume, which may qualify them
|
|
for incentives or rewards based on trading activity.
|
|
|
|
Args:
|
|
user: Public key of the user
|
|
|
|
Returns:
|
|
PDA of the user's volume accumulator
|
|
"""
|
|
derived_address, _ = Pubkey.find_program_address(
|
|
[b"user_volume_accumulator", bytes(user)],
|
|
PUMP_AMM_PROGRAM_ID,
|
|
)
|
|
return derived_address
|
|
|
|
|
|
def find_fee_config() -> Pubkey:
|
|
"""Derive the PDA for the fee configuration account.
|
|
|
|
This account stores fee-related configuration for the AMM.
|
|
"""
|
|
derived_address, _ = Pubkey.find_program_address(
|
|
[b"fee_config", bytes(PUMP_AMM_PROGRAM_ID)],
|
|
PUMP_FEE_PROGRAM,
|
|
)
|
|
return derived_address
|
|
|
|
|
|
def find_pool_v2(base_mint: Pubkey) -> Pubkey:
|
|
"""Derive the PDA for the pool-v2 account (per-base-mint), required as the
|
|
last "pre-upgrade" account on every pump-swap buy/sell."""
|
|
derived_address, _ = Pubkey.find_program_address(
|
|
[b"pool-v2", bytes(base_mint)],
|
|
PUMP_AMM_PROGRAM_ID,
|
|
)
|
|
return derived_address
|
|
|
|
|
|
# ============================================================================
|
|
# Mayhem Mode Fee Handling
|
|
# ============================================================================
|
|
# Mayhem mode is a special fee structure where fees go to a different recipient.
|
|
# The fee recipient changes dynamically based on the pool's mayhem_mode flag.
|
|
|
|
|
|
async def get_reserved_fee_recipient_pumpswap(client: AsyncClient) -> Pubkey:
|
|
"""Fetch the mayhem mode fee recipient from GlobalConfig.
|
|
|
|
When mayhem mode is active, fees are redirected to a special recipient
|
|
stored in the GlobalConfig account.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
|
|
Returns:
|
|
Public key of the mayhem mode fee recipient
|
|
"""
|
|
response = await client.get_account_info(PUMP_SWAP_GLOBAL_CONFIG, encoding="base64")
|
|
if not response.value or not response.value.data:
|
|
msg = "Cannot fetch GlobalConfig account"
|
|
raise ValueError(msg)
|
|
|
|
data = response.value.data
|
|
recipient_bytes = data[
|
|
GLOBALCONFIG_RESERVED_FEE_OFFSET : GLOBALCONFIG_RESERVED_FEE_OFFSET + 32
|
|
]
|
|
return Pubkey.from_bytes(recipient_bytes)
|
|
|
|
|
|
async def get_pumpswap_fee_recipients(
|
|
client: AsyncClient, pool: Pubkey
|
|
) -> tuple[Pubkey, Pubkey, bool]:
|
|
"""Determine the correct fee recipient and whether the pool is cashback.
|
|
|
|
Returns:
|
|
Tuple of (fee_recipient_pubkey, fee_recipient_token_account, is_cashback)
|
|
"""
|
|
response = await client.get_account_info(pool, encoding="base64")
|
|
if not response.value or not response.value.data:
|
|
msg = "Cannot fetch pool account"
|
|
raise ValueError(msg)
|
|
|
|
pool_data = response.value.data
|
|
|
|
is_mayhem_mode = len(pool_data) >= POOL_MAYHEM_MODE_MIN_SIZE and bool(
|
|
pool_data[POOL_MAYHEM_MODE_OFFSET]
|
|
)
|
|
is_cashback = len(pool_data) > POOL_IS_CASHBACK_OFFSET and bool(
|
|
pool_data[POOL_IS_CASHBACK_OFFSET]
|
|
)
|
|
|
|
if is_mayhem_mode:
|
|
fee_recipient = await get_reserved_fee_recipient_pumpswap(client)
|
|
else:
|
|
fee_recipient = STANDARD_PUMPSWAP_FEE_RECIPIENT
|
|
|
|
fee_recipient_token_account = get_associated_token_address(
|
|
fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM
|
|
)
|
|
|
|
return (fee_recipient, fee_recipient_token_account, is_cashback)
|
|
|
|
|
|
# ============================================================================
|
|
# Price Calculation
|
|
# ============================================================================
|
|
|
|
|
|
async def read_virtual_quote_reserves(client: AsyncClient, pool: Pubkey) -> int:
|
|
"""Read Pool::virtual_quote_reserves, the field appended after the flags.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
pool: Pool (market) address
|
|
|
|
Returns:
|
|
Raw virtual quote reserves, or 0 if the account predates the field
|
|
"""
|
|
response = await client.get_account_info(pool, encoding="base64")
|
|
if not response.value or not response.value.data:
|
|
return 0
|
|
data = response.value.data
|
|
end = POOL_VIRTUAL_QUOTE_RESERVES_OFFSET + POOL_VIRTUAL_QUOTE_RESERVES_SIZE
|
|
if len(data) < end:
|
|
return 0
|
|
return int.from_bytes(
|
|
data[POOL_VIRTUAL_QUOTE_RESERVES_OFFSET : end], "little", signed=True
|
|
)
|
|
|
|
|
|
async def calculate_token_pool_price(
|
|
client: AsyncClient,
|
|
pool_base_token_account: Pubkey,
|
|
pool_quote_token_account: Pubkey,
|
|
virtual_quote_reserves: int = 0,
|
|
) -> float:
|
|
"""Calculate current token price from AMM pool reserves.
|
|
|
|
Price is the ratio of *effective* quote reserves to base reserves:
|
|
|
|
effective_quote_reserves =
|
|
pool_quote_token_account.amount + Pool::virtual_quote_reserves
|
|
|
|
PumpSwap added `virtual_quote_reserves` to the Pool account. Upstream's
|
|
release note says it is 0 on every pool, but that is out of date: live pools
|
|
carry non-zero values (17.58 SOL observed on a 148 SOL pool, i.e. quoting
|
|
off the raw vault balance under-prices by ~10.6%). Always add it.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
pool_base_token_account: Pool's token account (the token being priced)
|
|
pool_quote_token_account: Pool's quote account
|
|
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
|
|
|
|
Returns:
|
|
Price in quote asset per token
|
|
"""
|
|
base_balance_resp = await client.get_token_account_balance(
|
|
pool_base_token_account, commitment=Confirmed
|
|
)
|
|
quote_balance_resp = await client.get_token_account_balance(
|
|
pool_quote_token_account
|
|
)
|
|
|
|
base_amount = float(base_balance_resp.value.ui_amount)
|
|
quote_decimals = int(quote_balance_resp.value.decimals)
|
|
quote_raw = int(quote_balance_resp.value.amount) + int(virtual_quote_reserves)
|
|
quote_amount = quote_raw / 10**quote_decimals
|
|
|
|
return quote_amount / base_amount
|
|
|
|
|
|
# ============================================================================
|
|
# Token Buying
|
|
# ============================================================================
|
|
|
|
|
|
async def get_token_program_id(client: AsyncClient, mint_address: Pubkey) -> Pubkey:
|
|
"""Determines if a mint uses TokenProgram or Token2022Program."""
|
|
mint_info = await client.get_account_info(mint_address)
|
|
|
|
if not mint_info.value:
|
|
raise ValueError(f"Could not fetch mint info for {mint_address}")
|
|
|
|
owner = mint_info.value.owner
|
|
|
|
if owner == SYSTEM_TOKEN_PROGRAM:
|
|
return SYSTEM_TOKEN_PROGRAM
|
|
elif owner == TOKEN_2022_PROGRAM:
|
|
return TOKEN_2022_PROGRAM
|
|
else:
|
|
raise ValueError(
|
|
f"Mint account {mint_address} is owned by an unknown program: {owner}"
|
|
)
|
|
|
|
|
|
async def buy_pump_swap(
|
|
client: AsyncClient,
|
|
market: Pubkey,
|
|
payer: Keypair,
|
|
base_mint: Pubkey,
|
|
user_base_token_account: Pubkey,
|
|
user_quote_token_account: Pubkey,
|
|
pool_base_token_account: Pubkey,
|
|
pool_quote_token_account: Pubkey,
|
|
coin_creator_vault_authority: Pubkey,
|
|
coin_creator_vault_ata: Pubkey,
|
|
sol_amount_to_spend: float,
|
|
slippage: float = 0.25,
|
|
) -> str | None:
|
|
"""Execute a token buy on the PUMP AMM with slippage protection.
|
|
|
|
This function:
|
|
1. Calculates expected token output based on current price
|
|
2. Wraps SOL into WSOL (required for SPL token operations)
|
|
3. Constructs and simulates the transaction
|
|
4. Sends the buy transaction if simulation succeeds
|
|
|
|
Why WSOL wrapping is needed:
|
|
SPL tokens can only interact with other SPL tokens. Native SOL must be
|
|
wrapped into WSOL (an SPL token representation of SOL) before trading.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
market: AMM pool address
|
|
payer: Wallet keypair for signing
|
|
base_mint: Token mint address
|
|
user_base_token_account: User's token account (for receiving tokens)
|
|
user_quote_token_account: User's WSOL account
|
|
pool_base_token_account: Pool's token account
|
|
pool_quote_token_account: Pool's WSOL account
|
|
coin_creator_vault_authority: Creator vault PDA
|
|
coin_creator_vault_ata: Creator's WSOL account
|
|
sol_amount_to_spend: Amount of SOL to spend (in SOL, not lamports)
|
|
slippage: Maximum acceptable slippage (0.25 = 25%)
|
|
|
|
Returns:
|
|
Transaction signature if successful, None otherwise
|
|
"""
|
|
token_program_id = await get_token_program_id(client, base_mint)
|
|
token_price_sol = await calculate_token_pool_price(
|
|
client,
|
|
pool_base_token_account,
|
|
pool_quote_token_account,
|
|
await read_virtual_quote_reserves(client, market),
|
|
)
|
|
print(f"Token price in SOL: {token_price_sol:.10f} SOL")
|
|
|
|
# Calculate expected token amount and maximum SOL we're willing to spend
|
|
base_amount_out = int((sol_amount_to_spend / token_price_sol) * 10**TOKEN_DECIMALS)
|
|
max_sol_input = int((sol_amount_to_spend * (1 + slippage)) * LAMPORTS_PER_SOL)
|
|
|
|
print(f"Buying {base_amount_out / (10**TOKEN_DECIMALS):.10f} tokens")
|
|
print(f"Maximum SOL input: {max_sol_input / LAMPORTS_PER_SOL:.10f} SOL")
|
|
|
|
# Derive volume accumulator PDAs for incentive tracking
|
|
global_volume_accumulator = find_global_volume_accumulator()
|
|
user_volume_accumulator = find_user_volume_accumulator(payer.pubkey())
|
|
|
|
# Get fee recipient based on mayhem mode + detect cashback pool
|
|
(
|
|
fee_recipient,
|
|
fee_recipient_token_account,
|
|
is_cashback,
|
|
) = await get_pumpswap_fee_recipients(client, market)
|
|
|
|
# WSOL ATA of user_volume_accumulator — only required for cashback pools.
|
|
user_volume_accumulator_quote_ata = get_associated_token_address(
|
|
user_volume_accumulator, SOL, SYSTEM_TOKEN_PROGRAM
|
|
)
|
|
|
|
# Build account list for buy instruction
|
|
# Order matters! Must match the program's expected account layout
|
|
accounts = [
|
|
AccountMeta(pubkey=market, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
|
|
AccountMeta(pubkey=PUMP_SWAP_GLOBAL_CONFIG, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=base_mint, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=SOL, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=user_base_token_account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=user_quote_token_account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=pool_base_token_account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=pool_quote_token_account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=False),
|
|
AccountMeta(
|
|
pubkey=fee_recipient_token_account, is_signer=False, is_writable=True
|
|
),
|
|
AccountMeta(
|
|
pubkey=token_program_id, is_signer=False, is_writable=False
|
|
), # Use dynamic token_program_id
|
|
AccountMeta(pubkey=SYSTEM_TOKEN_PROGRAM, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
|
|
AccountMeta(
|
|
pubkey=SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM,
|
|
is_signer=False,
|
|
is_writable=False,
|
|
),
|
|
AccountMeta(
|
|
pubkey=PUMP_SWAP_EVENT_AUTHORITY, is_signer=False, is_writable=False
|
|
),
|
|
AccountMeta(pubkey=PUMP_AMM_PROGRAM_ID, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=coin_creator_vault_ata, is_signer=False, is_writable=True),
|
|
AccountMeta(
|
|
pubkey=coin_creator_vault_authority, is_signer=False, is_writable=False
|
|
),
|
|
AccountMeta(
|
|
pubkey=global_volume_accumulator, is_signer=False, is_writable=False
|
|
),
|
|
AccountMeta(pubkey=user_volume_accumulator, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=find_fee_config(), is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=PUMP_FEE_PROGRAM, is_signer=False, is_writable=False),
|
|
]
|
|
# Cashback pools require user_volume_accumulator_quote_ata (writable) BEFORE
|
|
# pool-v2. Confirmed against on-chain post-cutover cashback buy
|
|
# (sig 4JaWdExj6zzU3aGNWqNFhmtCyhbjRU3zLrsA3vASGu9krQrLjAfbBygP9i7yXmruSbuYn4StgMdMFBi22oQCfvjK).
|
|
if is_cashback:
|
|
accounts.append(
|
|
AccountMeta(
|
|
pubkey=user_volume_accumulator_quote_ata,
|
|
is_signer=False,
|
|
is_writable=True,
|
|
)
|
|
)
|
|
# pool-v2 PDA (per-base-mint) — the last "pre-upgrade" account.
|
|
accounts.append(
|
|
AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False)
|
|
)
|
|
# 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER
|
|
# pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable).
|
|
# Buy counts: 26 non-cashback / 27 cashback.
|
|
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
|
|
breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS)
|
|
breaking_fee_quote_ata = get_associated_token_address(
|
|
breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM
|
|
)
|
|
accounts.extend([
|
|
AccountMeta(pubkey=breaking_fee_recipient, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True),
|
|
])
|
|
|
|
# Instruction data format:
|
|
# discriminator (8 bytes) + amount_out (8 bytes) + max_in (8 bytes) + track_volume (1 byte)
|
|
# All integers are little-endian (<)
|
|
data = (
|
|
BUY_DISCRIMINATOR
|
|
+ struct.pack("<Q", base_amount_out) # Expected token amount
|
|
+ struct.pack("<Q", max_sol_input) # Maximum SOL to spend
|
|
+ struct.pack("<B", VOLUME_TRACKING_ENABLED) # Enable volume tracking
|
|
)
|
|
|
|
# Set compute budget to avoid transaction failures
|
|
compute_limit_ix = set_compute_unit_limit(COMPUTE_UNIT_BUDGET)
|
|
compute_price_ix = set_compute_unit_price(COMPUTE_UNIT_PRICE)
|
|
|
|
# Create WSOL account if it doesn't exist
|
|
# Note: WSOL always uses the standard Token program, never Token2022
|
|
create_wsol_ata_ix = create_idempotent_associated_token_account(
|
|
payer.pubkey(),
|
|
payer.pubkey(),
|
|
SOL,
|
|
SYSTEM_TOKEN_PROGRAM, # WSOL always uses standard Token program
|
|
)
|
|
|
|
# Calculate amount to wrap (includes buffer for fees)
|
|
wrap_amount = int(
|
|
(sol_amount_to_spend * (1 + PROTOCOL_FEE_BUFFER)) * LAMPORTS_PER_SOL
|
|
)
|
|
|
|
# Transfer SOL to WSOL account and sync
|
|
# This converts native SOL to the SPL token version (WSOL)
|
|
transfer_sol_ix = transfer(
|
|
TransferParams(
|
|
from_pubkey=payer.pubkey(),
|
|
to_pubkey=user_quote_token_account,
|
|
lamports=wrap_amount,
|
|
)
|
|
)
|
|
sync_native_ix = sync_native(
|
|
SyncNativeParams(
|
|
SYSTEM_TOKEN_PROGRAM, user_quote_token_account
|
|
) # WSOL always uses standard Token program
|
|
)
|
|
|
|
# Create token account for receiving purchased tokens
|
|
create_token_ata_ix = create_idempotent_associated_token_account(
|
|
payer.pubkey(),
|
|
payer.pubkey(),
|
|
base_mint,
|
|
token_program_id, # Use dynamic token_program_id
|
|
)
|
|
|
|
buy_ix = Instruction(PUMP_AMM_PROGRAM_ID, data, accounts)
|
|
|
|
# Build and sign transaction
|
|
blockhash_resp = await client.get_latest_blockhash()
|
|
msg = Message.new_with_blockhash(
|
|
[
|
|
compute_limit_ix,
|
|
compute_price_ix,
|
|
create_wsol_ata_ix,
|
|
transfer_sol_ix,
|
|
sync_native_ix,
|
|
create_token_ata_ix,
|
|
buy_ix,
|
|
],
|
|
payer.pubkey(),
|
|
blockhash_resp.value.blockhash,
|
|
)
|
|
tx = VersionedTransaction(message=msg, keypairs=[payer])
|
|
|
|
# Simulate first to catch errors before sending
|
|
simulation = await client.simulate_transaction(tx)
|
|
if simulation.value.err:
|
|
print(f"Simulation error: {simulation.value.err}")
|
|
for log in (simulation.value.logs or []):
|
|
print(f" log: {log}")
|
|
# NOTE: pump-swap may throw AnchorError 6023 (Overflow) at buy.rs:400 on
|
|
# the dynamic creator-fee calc for some pools. The script's account list
|
|
# matches the IDL — the error is from the program itself, likely related
|
|
# to the 2026-03 dynamic-fee overhaul. Pending investigation.
|
|
return None
|
|
|
|
print(
|
|
f"Simulation successful, compute units used: {simulation.value.units_consumed}"
|
|
)
|
|
|
|
try:
|
|
# Skip preflight since we already simulated (faster execution)
|
|
tx_sig = await client.send_transaction(
|
|
tx, opts=TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
|
|
)
|
|
tx_hash = tx_sig.value
|
|
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
|
|
|
|
await tx_status.confirm_and_assert(client, tx_hash)
|
|
print("Transaction confirmed")
|
|
return tx_hash
|
|
except Exception as e:
|
|
print(f"Error: {e!s}")
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# Main Execution
|
|
# ============================================================================
|
|
|
|
|
|
async def main() -> None:
|
|
"""Execute the complete buy flow."""
|
|
sol_amount_to_spend = 0.001 # Amount of SOL to spend on the purchase
|
|
|
|
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
|
|
# Step 1: Find the pool address for our token
|
|
market_address = await get_market_address_by_base_mint(
|
|
client, TOKEN_MINT, PUMP_AMM_PROGRAM_ID
|
|
)
|
|
|
|
# Step 2: Parse pool data to get necessary accounts
|
|
market_data = await get_market_data(client, market_address)
|
|
|
|
# Determine token program ID for the base mint
|
|
token_program_id = await get_token_program_id(client, TOKEN_MINT)
|
|
|
|
# Step 3: Derive PDAs needed for the transaction
|
|
coin_creator_vault_authority = find_coin_creator_vault(
|
|
Pubkey.from_string(market_data["coin_creator"])
|
|
)
|
|
coin_creator_vault_ata = get_associated_token_address(
|
|
coin_creator_vault_authority, SOL, SYSTEM_TOKEN_PROGRAM
|
|
)
|
|
|
|
# Step 4: Execute the buy
|
|
await buy_pump_swap(
|
|
client,
|
|
market_address,
|
|
PAYER,
|
|
TOKEN_MINT,
|
|
get_associated_token_address(PAYER.pubkey(), TOKEN_MINT, token_program_id),
|
|
get_associated_token_address(PAYER.pubkey(), SOL, SYSTEM_TOKEN_PROGRAM),
|
|
Pubkey.from_string(market_data["pool_base_token_account"]),
|
|
Pubkey.from_string(market_data["pool_quote_token_account"]),
|
|
coin_creator_vault_authority,
|
|
coin_creator_vault_ata,
|
|
sol_amount_to_spend,
|
|
SLIPPAGE,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|