feat(pumpfun): migrate to buy_v2/sell_v2 and support non-SOL quote assets (#176)

Refresh the vendored IDLs from pump-fun/pump-public-docs @ 9c82f61 and move all
pump.fun trading onto the v2 instruction interface. This is required, not
optional: legacy buy/sell cannot trade coins paired against a quote asset other
than SOL, and USDC is already whitelisted in the on-chain Global account.

Protocol changes absorbed:

- buy_v2 (27 accounts) / sell_v2 (26 accounts) replace the legacy instructions.
  Every account is mandatory and the order is identical for all coins, so the
  conditional cashback/mayhem account lists are gone. Legacy remains available
  via PumpFunInstructionBuilder(use_legacy_instructions=True).
- BondingCurve is 151 bytes: virtual_sol_reserves -> virtual_quote_reserves,
  real_sol_reserves -> real_quote_reserves, plus quote_mint at offset 83. Old
  field names are kept as aliases so existing callers keep working.
- v2 instruction data drops the track_volume OptionBool; amounts are in the
  quote mint's raw units rather than always lamports.
- create_v2 carries a non-SOL quote mint as optional remaining accounts 17-19,
  and CreateEvent gained quote_mint, so extreme_fast_mode can resolve the quote
  asset without an extra fetch.

USDC support: new trade.quote_amounts and filters.allowed_quote_mints config,
accepting "sol"/"usdc" aliases or raw mints. Amounts are per-quote-mint because
1 USDC and 1 SOL are not interchangeable. A coin whose quote mint has no
configured amount is skipped rather than traded at the wrong size, so SOL-only
configs are unaffected.

Bug fixes found while verifying:

- The logs and blocks listeners set no websocket max_size, so any frame over
  1 MiB closed the connection with 1009 and the token in it was lost. Raised
  to 32 MiB.
- PumpSwap priced against the raw quote vault balance, ignoring the new
  Pool.virtual_quote_reserves (i128 at offset 245; live pools are 301 bytes).
  Upstream's note that this field is 0 everywhere is out of date: a live pool
  carries 17.58 SOL against a 148 SOL vault, a 10.15% price error.
- The seller read curve state once at confirmed commitment and silently fell
  back to create-time values, risking a stale creator_vault and ConstraintSeeds.
  It now retries at processed, matching the buyer.
- Account cleanup would burn wrapped SOL when force_burn was set, destroying
  value that closing the account returns. WSOL is now closed without burning.
- The mint scripts treated a landed transaction as a successful one, so a
  reverted buy printed as success. They now assert the on-chain result.

Compute unit limits retuned from mainnet measurements: buy 100k -> 180k,
sell 60k -> 120k. Mint-and-buy is no longer atomic, because create_v2 plus
buy_v2 exceeds the 1232-byte transaction limit; both mint scripts send two
transactions.

Adds learning-examples/pump_v2.py as one shared, standalone v2 toolkit for the
example scripts, and three verification scripts: an offline layout check
against the IDL, a no-funds mainnet simulation, and a live listener matrix that
buys, sells and closes the ATA per listener.

Verified on mainnet: all four listeners (geyser, logs, blocks, pumpportal) and
all eight example scripts completed a real buy, sell and ATA close, each
confirmed by reading the transaction result back rather than trusting
confirmation alone. The USDC path is verified structurally only; no USDC-paired
coin could be found on-chain to exercise it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-28 17:58:33 +02:00
committed by GitHub
parent 3b88a06d9d
commit 02343b775b
34 changed files with 10949 additions and 3987 deletions
+96 -6
View File
@@ -50,6 +50,23 @@ uv run learning-examples/listen-new-tokens/listen_logsubscribe_abc.py
uv run learning-examples/listen-new-tokens/compare_listeners.py
```
### Verifying pump.fun v2 trade instructions
```bash
# Offline: cross-check buy_v2/sell_v2 account layouts, PDA/ATA derivations,
# instruction encoding and quote-asset config against idl/pump_fun_idl.json
uv run learning-examples/verify_v2_account_layout.py
# Mainnet, no funds moved: simulate buy_v2/sell_v2 for one coin, report CU
uv run learning-examples/simulate_v2_trades.py <MINT>
# Mainnet, no funds moved: run the bot's whole buy path against a fresh coin
uv run learning-examples/simulate_bot_buy_path.py
uv run learning-examples/simulate_bot_buy_path.py --no-extreme-fast
```
Run all three after any pump.fun program upgrade. The simulations report
`unitsConsumed`; use it to retune `get_buy_compute_unit_limit` /
`get_sell_compute_unit_limit` in `platforms/pumpfun/instruction_builder.py`.
### Code Quality
```bash
# Format code
@@ -64,12 +81,85 @@ ruff check --fix
## Pump.fun protocol notes (gotchas)
- The vendored IDL at `idl/pump_fun_idl.json` is **incomplete**: it does not list `bonding-curve-v2` (BC trades) or `pool-v2` (PumpSwap trades). Both are required on-chain. `bonding-curve-v2` PDA seed is `["bonding-curve-v2", mint]` under the pump program; `pool-v2` is `["pool-v2", base_mint]` under the pump-amm program. Always cross-check account lists against a recent successful on-chain tx — IDL alone will produce broken code.
- BC `buy` ix is **18 accounts** (post 2026-04-28 upgrade). The trailing account is one of 8 `BREAKING_FEE_RECIPIENTS` (mutable), AFTER `bonding-curve-v2`.
- BC `sell` ix is **16 accounts non-cashback / 17 cashback**. Cashback path inserts `user_volume_accumulator` (PDA seed `["user_volume_accumulator", user]`) BEFORE `bonding-curve-v2`. Detect cashback from byte 82 of the bonding-curve account data, or via the `is_cashback_coin` field returned by the curve manager.
- The BondingCurve account is **83 bytes** (was 81): trailing fields are `is_mayhem_mode: bool` then `is_cashback_coin: bool`. PumpSwap Pool is **245 bytes** with the same `is_cashback_coin` byte at offset 244.
- The IDL instruction is `create_v2` (snake_case). `create_v2` args: `name (str), symbol (str), uri (str), creator (pubkey), is_mayhem_mode (bool), is_cashback_enabled (OptionBool 1B)`. `OptionBool` is a struct wrapping a single bool — serialized as 1 byte, not 2.
- `extreme_fast_mode` skips the curve-state RPC fetch — make sure event parsers populate `is_mayhem_mode` and `is_cashback_coin` on `TokenInfo` from the `CreateEvent` payload (otherwise mayhem coins use the wrong fee_recipient and cashback coins use the wrong sell account count).
The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-public-docs`
(`idl/pump.json``pump_fun_idl.json`, `pump_amm.json``pump_swap_idl.json`,
`pump_fees.json`). Refresh them from upstream rather than hand-editing.
### Quote assets and the v2 trade instructions (current path)
- pump.fun supports quote assets other than SOL. `BondingCurve.quote_mint` is
`Pubkey::default()` (all zeros) for SOL-paired coins; USDC
(`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`) is whitelisted in `Global`.
**Legacy `buy`/`sell` cannot trade non-SOL-paired coins at all.**
- The bot uses **`buy_v2` (27 accounts)** and **`sell_v2` (26 accounts)**. Every
account is mandatory and the order is identical for every coin — SOL or USDC
paired, mayhem or not, cashback or not. `sell_v2` is `buy_v2` minus
`global_volume_accumulator`. Layouts live in `_BUY_V2_ACCOUNTS` /
`_SELL_V2_ACCOUNTS` in `platforms/pumpfun/instruction_builder.py` and are
machine-checked against the IDL by `learning-examples/verify_v2_account_layout.py`.
- v2 args carry **no `track_volume` OptionBool** (24-byte data: discriminator +
two u64). Volume tracking is unconditional now that `user_volume_accumulator`
is mandatory. `max_sol_cost`/`min_sol_output` are in the **quote mint's** raw
units — lamports for SOL, 1e-6 for USDC.
- Even for SOL-paired coins you must pass **wrapped SOL** as `quote_mint`, not
`Pubkey::default()`. Transfers still happen in native SOL, and the
`associated_quote_*` accounts are only seed-constrained — do **not** create the
user's WSOL ATA, it would burn ~0.002 SOL of rent for nothing.
- Fee recipients: 24 total, in three sets of 8 (`NORMAL_FEE_RECIPIENTS`,
`RESERVED_FEE_RECIPIENTS` for mayhem coins, `BUYBACK_FEE_RECIPIENTS`). Every
v2 buy/sell needs a `fee_recipient` **and** a `buyback_fee_recipient`.
- `sharing_config` (PDA `["sharing-config", base_mint]`) lives under the **pump
fees program**, not the pump program. Easy to derive against the wrong program.
### BondingCurve account layout
- The account is **151 bytes**: 8-byte discriminator, then
`virtual_token_reserves, virtual_quote_reserves, real_token_reserves,
real_quote_reserves, token_total_supply` (u64 each), `complete` (1B, offset 48),
`creator` (32B, offset 49), `is_mayhem_mode` (offset 81),
`is_cashback_coin` (offset 82), `quote_mint` (32B, offset 83), then 36 reserved
zero bytes. The documented struct is 115 bytes; the extra 36 are padding.
- The SOL-named fields were **renamed**: `virtual_sol_reserves`
`virtual_quote_reserves`, `real_sol_reserves``real_quote_reserves`. The
curve manager still exposes the old names as aliases, so pre-existing callers
keep working for SOL-paired coins — but anything doing arithmetic must scale by
the quote mint's decimals (`quote_units_per_token`), not a hardcoded 1e9.
- PumpSwap `Pool` gained a trailing **`virtual_quote_reserves: i128`** (16 bytes,
offset 245). Pool fields end at 261; live accounts are **301 bytes** with
trailing padding. Quote against **effective** reserves:
`pool_quote_token_account.amount + virtual_quote_reserves`.
**Upstream's release note claims it is 0 on all pools — that is out of date.**
Verified on mainnet: pool `6Bv1JM1deBPe…` carries 17.584505433 SOL of virtual
reserves against a 148.455 SOL vault, so quoting off the raw vault balance
under-prices by ~10.6%. It is `i128`, not `u64` — reading 8 bytes happens to
work only while the high half is zero.
### Coin creation
- The IDL instruction is `create_v2` (snake_case). Args: `name (str),
symbol (str), uri (str), creator (pubkey), is_mayhem_mode (bool),
is_cashback_enabled (OptionBool 1B)`. `OptionBool` is a struct wrapping a single
bool — serialized as 1 byte, not 2.
- `create_v2` accounts 1-16 are in the IDL; accounts **17-19 are optional
remaining accounts** (`quote_mint`, `associated_quote_bonding_curve`,
`quote_token_program`) appended only for a non-SOL quote mint. All three or
none. This is the only way to read a new coin's quote asset from the
instruction rather than the event.
- `extreme_fast_mode` skips the curve-state price fetch but still refreshes
mayhem/cashback/creator/**quote_mint** from chain, because the wrong quote mint
means spending the wrong balance entirely. Event parsers also populate
`quote_mint` from `CreateEvent` (which gained `quote_mint` and
`virtual_quote_reserves` as trailing fields).
### Legacy instructions (fallback only)
Retained behind `PumpFunInstructionBuilder(..., use_legacy_instructions=True)`.
The IDL under-reports these: `buy` is **18 accounts** on-chain (IDL lists 16) and
`sell` is **16 non-cashback / 17 cashback** (IDL lists 14). The extras are
`bonding-curve-v2` (PDA `["bonding-curve-v2", mint]`) followed by a buyback fee
recipient (mutable); the cashback sell path also inserts
`user_volume_accumulator` before `bonding-curve-v2`. Prefer v2 — it is the
interface pump.fun maintains.
## Code Style & Conventions
+48 -4
View File
@@ -124,15 +124,59 @@ This helps ensure reliable operation within your node provider's rate limits wit
The IDLs under [`idl/`](idl/) are vendored from [pump-fun/pump-public-docs](https://github.com/pump-fun/pump-public-docs). To refresh, copy `pump.json`, `pump_amm.json`, `pump_fees.json` from that repo into `pump_fun_idl.json`, `pump_swap_idl.json`, `pump_fees.json` respectively, and reference the upstream commit hash in your commit message.
> **The IDL is incomplete.** It doesn't list two PDAs that the on-chain program actually requires:
> - `bonding-curve-v2` — required on every BC `buy` (18 accounts) and `sell` (16/17 accounts). Seed: `["bonding-curve-v2", mint]` under the pump program.
Currently vendored from upstream commit `9c82f61`.
> **The IDL under-reports the legacy instructions.** It omits two PDAs that the on-chain program requires on the pre-v2 path:
> - `bonding-curve-v2` — required on every legacy BC `buy` (18 accounts) and `sell` (16/17 accounts). Seed: `["bonding-curve-v2", mint]` under the pump program.
> - `pool-v2` — required on every PumpSwap `buy`/`sell`. Seed: `["pool-v2", base_mint]` under the pump-amm program. **Without it, pump-amm throws `AnchorError 6023 (Overflow)` after the trade transfers complete** — a misleading error code for a missing-account issue.
>
> Always cross-check your account lists against a recent successful on-chain tx (`getSignaturesForAddress` + `getTransaction`) before trusting the IDL.
> The `buy_v2` / `sell_v2` account lists *are* complete in the IDL — that's the point of the v2 interface. For anything else, cross-check against a recent successful on-chain tx (`getSignaturesForAddress` + `getTransaction`) before trusting the IDL.
## Quote assets: SOL and USDC (v2 trade instructions)
Pump.fun added support for quote assets other than SOL, with USDC first. The bonding curve carries a `quote_mint` field (`Pubkey::default()` for SOL-paired coins), and trading non-SOL-paired coins **requires** the newer `buy_v2` / `sell_v2` instructions — the legacy `buy` / `sell` cannot do it at all.
The bot uses `buy_v2` (27 accounts) and `sell_v2` (26 accounts) for every pump.fun trade. Every account is mandatory and the order is identical for all coins, regardless of quote asset, mayhem mode, or cashback — no more conditional account lists.
To trade a non-SOL quote asset, give it a spend amount. Amounts are in that mint's own whole units, so `usdc: 1.0` is one USDC and is **not** comparable to `buy_amount`:
```yaml
trade:
buy_amount: 0.0001 # SOL-paired coins
quote_amounts:
usdc: 1.0 # USDC-paired coins
filters:
allowed_quote_mints: ["sol", "usdc"] # omit to allow any configured quote
```
Keys accept the aliases `sol` / `wsol` / `usdc` or a raw base58 mint address. A coin whose quote mint has no configured amount is skipped with a log line rather than bought with a wrongly-scaled amount. SOL always falls back to `buy_amount`, so existing configs keep working untouched.
Buying a USDC-paired coin requires USDC in the wallet plus a little SOL for fees and ATA rent.
Verify the v2 wiring after any program upgrade:
```bash
uv run learning-examples/verify_v2_account_layout.py # offline: layouts, PDAs, encoding
uv run learning-examples/simulate_v2_trades.py <MINT> # mainnet simulation, no funds moved
uv run learning-examples/simulate_bot_buy_path.py # whole bot buy path, simulated
```
## PumpSwap: quote against effective reserves
The PumpSwap `Pool` account gained a trailing `virtual_quote_reserves` field (an **`i128`** at offset 245; fields end at 261, live accounts are 301 bytes). Price must be computed from **effective** quote reserves:
```
effective_quote_reserves = pool_quote_token_account.amount + Pool::virtual_quote_reserves
```
> ⚠️ Upstream's release note says `virtual_quote_reserves` is 0 on every pool. **That is out of date.** Verified on mainnet: pool `6Bv1JM1deBPeEeovhoRajFtbMVDKQidC9mXYRQWfGoXz` holds 17.584505433 SOL of virtual reserves against a 148.455 SOL vault — quoting off the raw vault balance under-prices by **~10.6%**. Note it is `i128`, not `u64`; an 8-byte read only works while the high half happens to be zero.
`learning-examples/pumpswap/manual_buy_pumpswap.py` and `manual_sell_pumpswap.py` read and apply it. Note that pump-amm has **no** `buy_v2`/`sell_v2` — the AMM instruction names are unchanged; only the pool layout and quoting moved.
## 2026-04-28 program upgrade
Pump.fun shipped a breaking program upgrade on **2026-04-28 16:00 UTC** ([BREAKING_FEE_RECIPIENT.md](https://github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md)). The bot is updated for it:
Pump.fun shipped a breaking program upgrade on **2026-04-28 16:00 UTC** ([BREAKING_FEE_RECIPIENT.md](https://github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md)). This section describes the **legacy** instruction path, which the bot retains only as a fallback (`PumpFunInstructionBuilder(..., use_legacy_instructions=True)`) — see the quote-assets section above for the v2 path used by default:
- BC `buy` ix is now **18 accounts** (was 17). Trailing account is one of 8 `BREAKING_FEE_RECIPIENTS` (mutable), AFTER `bonding-curve-v2`.
- BC `sell` ix is now **16 accounts non-cashback / 17 cashback** (was 15/16). Same trailing fee recipient.
+14
View File
@@ -27,6 +27,16 @@ trade:
buy_slippage: 0.3 # Maximum acceptable price deviation (0.3 = 30%)
sell_slippage: 0.3
# Spend amounts for coins paired against a non-SOL quote asset.
# pump.fun now supports quote mints other than SOL (USDC first). Amounts are
# in that mint's own whole units, so "usdc: 1.0" means one USDC — it is NOT
# comparable to buy_amount above. Keys accept the aliases "sol"/"usdc" or a
# raw base58 mint address. A coin whose quote mint has no entry here is
# skipped rather than bought with a SOL-sized amount.
# SOL always uses buy_amount above and needs no entry.
#quote_amounts:
# usdc: 1.0
# Exit strategy configuration
exit_strategy: "time_based" # Options: "time_based", "tp_sl", "manual"
#take_profit_percentage: 0.1 # Take profit at 10% gain (0.1 = 10%)
@@ -69,6 +79,10 @@ compute_units:
filters:
match_string: null # Only process tokens with this string in name/symbol
bro_address: null # Only trade tokens created by this user address
# Restrict which quote assets to trade. Omit (or set null) to allow any quote
# mint that has an amount configured under trade.quote_amounts.
# Accepts "sol"/"usdc" aliases or raw mint addresses.
#allowed_quote_mints: ["sol"]
listener_type: "geyser" # Method for detecting new tokens: "logs", "blocks", or "geyser"
max_token_age: 0.001 # Maximum token age in seconds for processing
marry_mode: false # Only buy tokens, skip selling
+3146 -4
View File
File diff suppressed because it is too large Load Diff
+2611 -2896
View File
File diff suppressed because it is too large Load Diff
+1123 -1
View File
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
"""Verify every listener with a real buy_v2 + sell_v2 + ATA cleanup on mainnet.
WARNING: this submits real transactions and spends real funds.
For each listener (geyser, logs, blocks, pumpportal) it detects a live token,
buys it, sells it, then closes the base-token ATA so the ~0.002 SOL of rent is
reclaimed rather than stranded. Tests run sequentially so the same funds are
recycled across listeners.
Each listener exercises a different event-parsing path into the same v2 trade
code, which is the point: pumpportal in particular carries no mayhem/cashback/
quote_mint flags, so it relies entirely on the on-chain curve refresh.
Usage:
uv run learning-examples/live_listener_matrix.py --yes
uv run learning-examples/live_listener_matrix.py --yes --listeners geyser,logs
uv run learning-examples/live_listener_matrix.py --cleanup-only <MINT> [<MINT>...]
"""
import asyncio
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from cleanup.manager import AccountCleanupManager # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.pubkeys import LAMPORTS_PER_SOL, SystemAddresses # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
HOLD_SECONDS = 5
PRIORITY_FEE = 1_000_000
# This harness verifies plumbing, not profitability. Brand-new coins routinely
# lose 90% of their curve SOL within seconds of creation, and a realistic
# sell_slippage of 0.3 then makes the program reject the sell with
# TooLittleSolReceived (6003) — a correct guard, but it stops us proving the
# 26-account sell_v2 actually lands. Accept almost any payout instead; the
# position is worth well under a lamport-thousandth of a SOL either way.
SELL_SLIPPAGE = 0.95
BUY_SLIPPAGE = 0.3
DETECT_TIMEOUT_SECONDS = 150.0
# Time to let a close/sell finalize before reading balances or account state.
SETTLE_SECONDS = 20
ALL_LISTENERS = ("geyser", "logs", "blocks", "pumpportal")
@dataclass
class LegResult:
"""Outcome of one listener's buy/sell/cleanup cycle."""
listener: str
detected: str | None = None
symbol: str | None = None
quote_mint: str | None = None
buy_ok: bool = False
buy_sig: str | None = None
sell_ok: bool = False
sell_sig: str | None = None
cleanup_ok: bool = False
start_sol: float = 0.0
end_sol: float = 0.0
notes: list[str] = field(default_factory=list)
@property
def passed(self) -> bool:
"""Whether the whole cycle succeeded."""
return self.buy_ok and self.sell_ok and self.cleanup_ok
def make_listener(listener_type: str):
"""Build a listener of the given type wired to this project's endpoints.
Args:
listener_type: One of geyser, logs, blocks, pumpportal
Returns:
Configured listener instance
"""
return ListenerFactory.create_listener(
listener_type=listener_type,
wss_endpoint=os.environ.get("SOLANA_NODE_WSS_ENDPOINT"),
geyser_endpoint=os.environ.get("GEYSER_ENDPOINT"),
geyser_api_token=os.environ.get("GEYSER_API_TOKEN"),
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
async def detect(listener_type: str) -> TokenInfo | None:
"""Wait for one new pump.fun token from the given listener.
Args:
listener_type: Listener to use
Returns:
TokenInfo, or None on timeout
"""
listener = make_listener(listener_type)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(DETECT_TIMEOUT_SECONDS / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
await asyncio.sleep(0)
return seen[0] if seen else None
async def sol_balance(client: SolanaClient, wallet: Wallet) -> float:
"""Read the wallet's SOL balance at finalized commitment.
Reads immediately after a transaction confirms can still serve a slot that
predates it, which silently produces "net change: 0" style nonsense. Ask for
finalized explicitly so the reported numbers mean something.
Args:
client: RPC client
wallet: Wallet to query
Returns:
Balance in SOL
"""
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "getBalance",
"params": [str(wallet.pubkey), {"commitment": "finalized"}],
}
)
lamports = (response or {}).get("result", {}).get("value")
if lamports is None:
raise RuntimeError(f"getBalance failed for {wallet.pubkey}: {response}")
return lamports / LAMPORTS_PER_SOL
async def ata_is_closed(client: SolanaClient, ata: Pubkey) -> bool:
"""Check whether an ATA is closed, at finalized commitment.
Args:
client: RPC client
ata: Associated token account address
Returns:
True if the account no longer exists
"""
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "getAccountInfo",
"params": [str(ata), {"encoding": "base64", "commitment": "finalized"}],
}
)
return (response or {}).get("result", {}).get("value") is None
async def run_leg(
listener_type: str,
client: SolanaClient,
wallet: Wallet,
priority_fee_manager: PriorityFeeManager,
) -> LegResult:
"""Detect, buy, sell and clean up for one listener.
Args:
listener_type: Listener under test
client: RPC client
wallet: Trading wallet
priority_fee_manager: Priority fee strategy
Returns:
LegResult describing the cycle
"""
result = LegResult(listener=listener_type)
result.start_sol = await sol_balance(client, wallet)
print(
f"\n{'=' * 62}\n{listener_type.upper()} (start {result.start_sol:.9f} SOL)\n{'=' * 62}"
)
print(
f"[{listener_type}] waiting for a token (up to {DETECT_TIMEOUT_SECONDS:.0f}s)..."
)
token_info = await detect(listener_type)
if token_info is None:
result.notes.append("no token detected before timeout")
print(f"[{listener_type}] no token detected")
return result
result.detected = str(token_info.mint)
result.symbol = token_info.symbol
result.quote_mint = str(token_info.quote_mint)
print(f"[{listener_type}] detected {token_info.symbol} ({token_info.mint})")
print(f"[{listener_type}] quote_mint from listener: {token_info.quote_mint}")
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=BUY_SLIPPAGE,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=True,
)
seller = PlatformAwareSeller(
client, wallet, priority_fee_manager, slippage=SELL_SLIPPAGE, max_retries=1
)
buy = await buyer.execute(token_info)
result.buy_ok = buy.success
result.buy_sig = str(buy.tx_signature) if buy.tx_signature else None
print(f"[{listener_type}] BUY ok={buy.success} tx={buy.tx_signature}")
if not buy.success:
result.notes.append(f"buy failed: {buy.error_message}")
else:
await asyncio.sleep(HOLD_SECONDS)
sell = await seller.execute(token_info, buy.amount, buy.price)
result.sell_ok = sell.success
result.sell_sig = str(sell.tx_signature) if sell.tx_signature else None
print(f"[{listener_type}] SELL ok={sell.success} tx={sell.tx_signature}")
if not sell.success:
result.notes.append(f"sell failed: {sell.error_message}")
# Always attempt cleanup, even after a failed buy: a partially-created ATA
# still holds rent.
token_program = token_info.token_program_id or SystemAddresses.TOKEN_2022_PROGRAM
manager = AccountCleanupManager(
client, wallet, priority_fee_manager, use_priority_fee=False, force_burn=True
)
print(f"[{listener_type}] cleaning up ATA for {token_info.mint}...")
await manager.cleanup_ata(token_info.mint, token_program)
# Let the close finalize before judging it; otherwise the check races the
# very transaction it is verifying.
await asyncio.sleep(SETTLE_SECONDS)
ata = wallet.get_associated_token_address(token_info.mint, token_program)
result.cleanup_ok = await ata_is_closed(client, ata)
if not result.cleanup_ok:
result.notes.append(f"ATA {ata} still open after cleanup")
result.end_sol = await sol_balance(client, wallet)
print(
f"[{listener_type}] cleanup ok={result.cleanup_ok} "
f"end {result.end_sol:.9f} SOL "
f"(net {result.end_sol - result.start_sol:+.9f})"
)
return result
async def cleanup_only(mints: list[str]) -> int:
"""Close ATAs for the given mints and exit.
Args:
mints: Base mint addresses whose ATAs should be closed
Returns:
Process exit code
"""
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
try:
before = await sol_balance(client, wallet)
print(f"balance before cleanup: {before:.9f} SOL")
manager = AccountCleanupManager(
client,
wallet,
priority_fee_manager,
use_priority_fee=False,
force_burn=True,
)
for mint_str in mints:
mint = Pubkey.from_string(mint_str)
for program in (
SystemAddresses.TOKEN_2022_PROGRAM,
SystemAddresses.TOKEN_PROGRAM,
):
ata = wallet.get_associated_token_address(mint, program)
if await ata_is_closed(client, ata):
continue
print(f"closing {ata} (mint {mint_str}, program {program})")
await manager.cleanup_ata(mint, program)
await asyncio.sleep(SETTLE_SECONDS)
print(f" closed: {await ata_is_closed(client, ata)}")
after = await sol_balance(client, wallet)
print(f"balance after cleanup: {after:.9f} SOL (net {after - before:+.9f})")
return 0
finally:
await client.close()
async def main() -> int:
"""Run the listener matrix.
Returns:
Process exit code (0 if every requested listener passed)
"""
if "--cleanup-only" in sys.argv:
index = sys.argv.index("--cleanup-only")
mints = sys.argv[index + 1 :]
if not mints:
print("--cleanup-only needs at least one mint")
return 2
return await cleanup_only(mints)
if "--yes" not in sys.argv:
print("This spends real funds. Re-run with --yes to proceed.")
return 2
listeners = list(ALL_LISTENERS)
if "--listeners" in sys.argv:
listeners = sys.argv[sys.argv.index("--listeners") + 1].split(",")
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
results: list[LegResult] = []
try:
opening = await sol_balance(client, wallet)
print(f"wallet: {wallet.pubkey}\nopening balance: {opening:.9f} SOL")
print(f"listeners under test: {', '.join(listeners)}")
for listener_type in listeners:
try:
results.append(
await run_leg(listener_type, client, wallet, priority_fee_manager)
)
except Exception as error: # noqa: BLE001
failed = LegResult(listener=listener_type)
failed.notes.append(f"crashed: {error}")
results.append(failed)
print(f"[{listener_type}] CRASHED: {error}")
closing = await sol_balance(client, wallet)
finally:
await client.close()
print(f"\n{'=' * 62}\nSUMMARY\n{'=' * 62}")
header = (
f"{'listener':<12} {'detect':<7} {'buy':<5} {'sell':<5} {'cleanup':<8} symbol"
)
print(header)
for item in results:
print(
f"{item.listener:<12} "
f"{'yes' if item.detected else 'no':<7} "
f"{'ok' if item.buy_ok else '-':<5} "
f"{'ok' if item.sell_ok else '-':<5} "
f"{'ok' if item.cleanup_ok else '-':<8} "
f"{item.symbol or ''}"
)
for note in item.notes:
print(f"{'':<12} note: {note}")
print(f"\nopening {opening:.9f} SOL -> closing {closing:.9f} SOL")
print(f"total net change: {closing - opening:+.9f} SOL")
for item in results:
if item.buy_sig:
print(f" {item.listener} buy: {item.buy_sig}")
if item.sell_sig:
print(f" {item.listener} sell: {item.sell_sig}")
passed = [item for item in results if item.passed]
print(f"\n{len(passed)}/{len(results)} listeners passed the full cycle")
return 0 if len(passed) == len(results) else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+160
View File
@@ -0,0 +1,160 @@
"""Execute one real buy_v2 + sell_v2 round trip on mainnet.
WARNING: this submits real transactions and spends real funds. It exists to
cover the last gap the simulation scripts cannot reach — actual submission,
confirmation, and the post-trade accounting that parses a confirmed
transaction. Keep BUY_AMOUNT_SOL tiny.
Uses the bot's own PlatformAwareBuyer / PlatformAwareSeller, so a pass here
means the production path works end to end.
Usage:
uv run learning-examples/live_v2_round_trip.py # needs confirmation
uv run learning-examples/live_v2_round_trip.py --yes # skip the prompt
"""
import asyncio
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.pubkeys import LAMPORTS_PER_SOL # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
HOLD_SECONDS = 5
PRIORITY_FEE = 1_000_000
async def wait_for_token(timeout_seconds: float = 120.0) -> TokenInfo | None:
"""Wait for the bot's geyser listener to report a new coin.
Args:
timeout_seconds: How long to wait
Returns:
First TokenInfo seen, or None on timeout
"""
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(timeout_seconds / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0] if seen else None
async def main() -> int:
"""Run one live buy/sell round trip.
Returns:
Process exit code (0 if both legs confirmed)
"""
if "--yes" not in sys.argv:
print(
f"This spends real funds ({BUY_AMOUNT_SOL} SOL plus fees). "
f"Re-run with --yes to proceed."
)
return 2
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
)
try:
start_lamports = (await client.get_account_info(wallet.pubkey)).lamports
print(f"wallet: {wallet.pubkey}")
print(f"start balance: {start_lamports / LAMPORTS_PER_SOL:.9f} SOL\n")
print("Waiting for a fresh pump.fun coin...")
token_info = await wait_for_token()
if token_info is None:
print("No coin detected before timeout.")
return 2
print(f"detected: {token_info.symbol} ({token_info.mint})")
print(f"quote: {token_info.quote_mint}\n")
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=True,
)
seller = PlatformAwareSeller(
client, wallet, priority_fee_manager, slippage=0.3, max_retries=1
)
print("--- BUY (buy_v2) ---")
buy = await buyer.execute(token_info)
print(f"success={buy.success} tx={buy.tx_signature}")
if not buy.success:
print(f"error: {buy.error_message}")
return 1
print(f"tokens={buy.amount} price={buy.price:.10f}\n")
print(f"holding {HOLD_SECONDS}s...")
await asyncio.sleep(HOLD_SECONDS)
print("\n--- SELL (sell_v2) ---")
sell = await seller.execute(token_info, buy.amount, buy.price)
print(f"success={sell.success} tx={sell.tx_signature}")
if not sell.success:
print(f"error: {sell.error_message}")
end_lamports = (await client.get_account_info(wallet.pubkey)).lamports
delta = (end_lamports - start_lamports) / LAMPORTS_PER_SOL
print(f"\nend balance: {end_lamports / LAMPORTS_PER_SOL:.9f} SOL")
print(f"net change: {delta:+.9f} SOL")
print(
"\nNote: the base-token ATA still holds rent (~0.002 SOL) until a "
"cleanup run closes it."
)
return 0 if (buy.success and sell.success) else 1
finally:
await client.close()
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+85 -252
View File
@@ -3,28 +3,25 @@ import base64
import hashlib
import json
import os
import random
import struct
import base58
import pump_v2
import websockets
from construct import Flag, Int64ul, Struct
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import 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.transaction import Transaction, VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -44,189 +41,59 @@ SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
SOL = Pubkey.from_string("So11111111111111111111111111111111111111112")
LAMPORTS_PER_SOL = 1_000_000_000
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# 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"),
]
# RPC ENDPOINTS
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# logsSubscribe frames exceed the websockets library's 1 MiB default, which
# closes the connection with 1009 ("message too big").
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
# The bonding curve account and the v2 instruction layout live in pump_v2 so
# every example shares one copy. See learning-examples/pump_v2.py.
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
conn: AsyncClient, curve_address: Pubkey
) -> BondingCurveState:
) -> pump_v2.BondingCurveState:
"""Fetch and parse a bonding curve account.
Args:
conn: Solana RPC client
curve_address: Bonding curve address
Returns:
Parsed curve state
Raises:
ValueError: If the account is missing or not a bonding curve
"""
response = await conn.get_account_info(curve_address, encoding="base64")
if not response.value or not response.value.data:
raise ValueError("Invalid curve state: No data")
data = response.value.data
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(response.value.data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
def _find_creator_vault(creator: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"creator-vault", bytes(creator)],
PUMP_PROGRAM,
)
return derived_address
def _find_global_volume_accumulator() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"global_volume_accumulator"],
PUMP_PROGRAM,
)
return derived_address
def _find_user_volume_accumulator(user: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)],
PUMP_PROGRAM,
)
return derived_address
def _find_fee_config() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)],
PUMP_FEE_PROGRAM,
)
return derived_address
def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"bonding-curve-v2", bytes(mint)],
PUMP_PROGRAM,
)
return derived_address
async def get_fee_recipient(
client: AsyncClient, curve_state: BondingCurveState
) -> Pubkey:
"""Determine the correct fee recipient based on mayhem mode.
Mayhem mode tokens use a different fee recipient (reserved_fee_recipient from Global account)
instead of the standard fee recipient. This function checks the bonding curve state
and returns the appropriate fee recipient.
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole quote units.
Args:
client: Solana RPC client to fetch Global account data
curve_state: Parsed bonding curve state containing is_mayhem_mode flag
curve_state: Parsed curve state
Returns:
Appropriate fee recipient pubkey (mayhem or standard)
Price in the curve's quote asset
Raises:
ValueError: If reserves are empty
"""
if not curve_state.is_mayhem_mode:
return PUMP_FEE
# Fetch Global account to get reserved_fee_recipient for mayhem mode tokens
response = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
if not response.value or not response.value.data:
# Fallback to standard fee if Global account cannot be fetched
return PUMP_FEE
data = response.value.data
# Parse reserved_fee_recipient from Global account
# Offset calculation based on pump_fun_idl.json Global struct:
# discriminator(8) + initialized(1) + authority(32) + fee_recipient(32) +
# initial_virtual_token_reserves(8) + initial_virtual_sol_reserves(8) +
# initial_real_token_reserves(8) + token_total_supply(8) + fee_basis_points(8) +
# withdraw_authority(32) + enable_migrate(1) + pool_migration_fee(8) +
# creator_fee_basis_points(8) + fee_recipients[7](224) + set_creator_authority(32) +
# admin_set_creator_authority(32) + create_v2_enabled(1) + whitelist_pda(32) = 483
RESERVED_FEE_RECIPIENT_OFFSET = 483
if len(data) < RESERVED_FEE_RECIPIENT_OFFSET + 32:
# Fallback if account data is too short
return PUMP_FEE
reserved_fee_recipient_bytes = data[
RESERVED_FEE_RECIPIENT_OFFSET : RESERVED_FEE_RECIPIENT_OFFSET + 32
]
return Pubkey.from_bytes(reserved_fee_recipient_bytes)
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return price
async def buy_token(
@@ -243,97 +110,54 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program_id=token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state to calculate price and determine fee recipient
# Fetch bonding curve state for price, mayhem mode and quote asset.
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
# Amounts are denominated in the curve's quote asset, which is not
# necessarily SOL any more.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
token_amount = amount / token_price_sol
max_quote_cost = int(amount * quote_unit * (1 + slippage))
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
print(f"Quote asset: {quote_mint}")
print(f"Buying {token_amount:.6f} tokens, max cost {max_quote_cost} raw units")
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=max_quote_cost,
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
instructions = [
set_compute_unit_price(1_000),
create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
),
]
# SOL-paired coins settle in native SOL and only seed-check the quote
# ATA, so creating it would waste rent. Other quotes need a real account.
if not pump_v2.is_sol_paired(quote_mint):
instructions.append(
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
quote_mint,
token_program_id=pump_v2.quote_token_program(quote_mint),
)
)
instructions.append(buy_ix)
discriminator = struct.pack("<Q", 16927863322537952870)
# Encode OptionBool for track_volume: [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1])
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ track_volume_bytes
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
)
msg = Message(
[set_compute_unit_price(1_000), idempotent_ata_ix, buy_ix], payer.pubkey()
)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
@@ -416,7 +240,9 @@ async def listen_for_create_transaction():
create_discriminator = calculate_discriminator("global:create")
create_v2_discriminator = calculate_discriminator("global:create_v2")
async with websockets.connect(RPC_WEBSOCKET) as websocket:
async with websockets.connect(
RPC_WEBSOCKET, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
subscription_message = json.dumps(
{
"jsonrpc": "2.0",
@@ -489,8 +315,13 @@ async def listen_for_create_transaction():
# Skip txs that use Address Lookup Tables — their
# instruction account indices reference ALT-loaded keys
# not present in transaction.message.account_keys.
static_keys = transaction.message.account_keys
if any(idx >= len(static_keys) for idx in ix.accounts):
static_keys = (
transaction.message.account_keys
)
if any(
idx >= len(static_keys)
for idx in ix.accounts
):
continue
account_keys = [
str(static_keys[index])
@@ -524,7 +355,9 @@ async def main():
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+52 -144
View File
@@ -22,28 +22,26 @@ import base64
import hashlib
import json
import os
import random
import struct
import base58
import pump_v2
import websockets
from construct import Flag, Int64ul, Struct
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.instruction import Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction, VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Discriminators
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -83,61 +81,12 @@ COMPUTE_BUDGET_PROGRAM = Pubkey.from_string(
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# logsSubscribe frames exceed the websockets library's 1 MiB default, which
# closes the connection with 1009 ("message too big").
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -151,16 +100,25 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
curve_state: Parsed curve state
Returns:
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
def _find_creator_vault(creator: Pubkey) -> Pubkey:
@@ -281,89 +239,30 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program_id=token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state to calculate price and determine fee recipient
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
token_amount = amount / token_price_sol
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
discriminator = struct.pack("<Q", 16927863322537952870)
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ struct.pack("<B", 1) # track_volume: 1 = true (enable volume tracking)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
print(f"Quote asset: {quote_mint}")
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=int(amount * quote_unit * (1 + slippage)),
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
)
@@ -458,7 +357,9 @@ async def listen_for_create_transaction():
create_discriminator = calculate_discriminator("global:create")
create_v2_discriminator = calculate_discriminator("global:create_v2")
async with websockets.connect(RPC_WEBSOCKET) as websocket:
async with websockets.connect(
RPC_WEBSOCKET, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
subscription_message = json.dumps(
{
"jsonrpc": "2.0",
@@ -531,8 +432,13 @@ async def listen_for_create_transaction():
# Skip txs that use Address Lookup Tables — their
# instruction account indices reference ALT-loaded keys
# not present in transaction.message.account_keys.
static_keys = transaction.message.account_keys
if any(idx >= len(static_keys) for idx in ix.accounts):
static_keys = (
transaction.message.account_keys
)
if any(
idx >= len(static_keys)
for idx in ix.accounts
):
continue
account_keys = [
str(
@@ -569,7 +475,9 @@ async def main():
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+42 -144
View File
@@ -1,25 +1,22 @@
import asyncio
import json
import os
import random
import struct
import sys
import base58
import grpc
from construct import Flag, Int64ul, Struct
import pump_v2
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import 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.transaction import Transaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -29,7 +26,7 @@ from src.geyser.generated import (
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
# Global constants
@@ -76,60 +73,7 @@ PUMP_CREATE_DISCRIMINATOR = struct.pack("<Q", 8576854823835016728)
PUMP_CREATE_V2_DISCRIMINATOR = bytes([214, 144, 76, 236, 95, 139, 49, 180])
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
else:
self.is_mayhem_mode = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -143,16 +87,25 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
curve_state: Parsed curve state
Returns:
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
def _find_creator_vault(creator: Pubkey) -> Pubkey:
@@ -380,87 +333,28 @@ async def buy_token(
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
associated_token_account = get_associated_token_address(
payer.pubkey(), mint, token_program
)
amount_lamports = int(amount * LAMPORTS_PER_SOL)
# Fetch bonding curve state for price + mayhem-mode-aware fee recipient.
# Fetch bonding curve state for price, mayhem mode and quote asset.
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
token_amount = amount / token_price_sol
fee_recipient = await get_fee_recipient(client, curve_state)
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + slippage))
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=token_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(),
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
discriminator = struct.pack("<Q", 16927863322537952870)
data = (
discriminator
+ struct.pack("<Q", int(token_amount * 10**6))
+ struct.pack("<Q", max_amount_lamports)
+ struct.pack("<B", 1) # track_volume: 1 = true (enable volume tracking)
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
print(f"Quote asset: {quote_mint}")
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=int(amount * quote_unit * (1 + slippage)),
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
buy_ix = Instruction(PUMP_PROGRAM, data, accounts)
idempotent_ata_ix = create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program
)
@@ -519,13 +413,17 @@ async def main():
print(json.dumps(token_data, indent=2))
sleep_duration_sec = 15
print(f"Waiting {sleep_duration_sec}s for the bonding curve account to propagate...")
print(
f"Waiting {sleep_duration_sec}s for the bonding curve account to propagate..."
)
await asyncio.sleep(sleep_duration_sec)
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = _find_creator_vault(Pubkey.from_string(token_data["creator"]))
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
+55 -238
View File
@@ -1,26 +1,28 @@
import asyncio
import os
import random
import struct
import sys
import base58
from construct import Flag, Int64ul, Struct
import pump_v2
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.compute_budget import 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.transaction import Transaction
from spl.token.instructions import get_associated_token_address
from spl.token.instructions import (
create_idempotent_associated_token_account,
get_associated_token_address,
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1] or hardcode here
TOKEN_MINT = Pubkey.from_string(
sys.argv[1] if len(sys.argv) > 1 else "..."
) # Pass mint as argv[1] or hardcode here
# Global constants
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
@@ -44,82 +46,8 @@ UNIT_BUDGET = 100_000
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# 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"),
]
class BondingCurveState:
"""Bonding curve state parser with progressive field parsing.
Parses bonding curve account data progressively based on available bytes,
making it forward-compatible with future schema versions.
"""
# Base struct present in all versions
_BASE_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_sol_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_sol_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
def __init__(self, data: bytes) -> None:
"""Parse bonding curve data progressively based on available bytes.
Args:
data: Raw account data including discriminator
Raises:
ValueError: If discriminator is invalid or data is too short
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
# Parse base fields (always present)
offset = 8
base_data = data[offset:]
parsed = self._BASE_STRUCT.parse(base_data)
self.__dict__.update(parsed)
# Calculate offset after base struct
offset += self._BASE_STRUCT.sizeof()
# Parse creator if bytes remaining (added in V2)
if len(data) >= offset + 32:
creator_bytes = data[offset : offset + 32]
self.creator = Pubkey.from_bytes(creator_bytes)
offset += 32
else:
self.creator = None
# Parse mayhem mode flag if bytes remaining (added in V3)
if len(data) >= offset + 1:
self.is_mayhem_mode = bool(data[offset])
offset += 1
else:
self.is_mayhem_mode = False
# Parse cashback flag if bytes remaining (added in V4 — late-Feb 2026 cashback upgrade)
if len(data) >= offset + 1:
self.is_cashback_coin = bool(data[offset])
else:
self.is_cashback_coin = False
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
@@ -133,7 +61,7 @@ async def get_pump_curve_state(
if data[:8] != EXPECTED_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
return BondingCurveState(data)
return pump_v2.BondingCurveState(data)
def get_bonding_curve_address(mint: Pubkey) -> tuple[Pubkey, int]:
@@ -162,84 +90,22 @@ def find_creator_vault(creator: Pubkey) -> Pubkey:
return derived_address
def _find_fee_config() -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)],
PUMP_FEE_PROGRAM,
)
return derived_address
def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"bonding-curve-v2", bytes(mint)],
PUMP_PROGRAM,
)
return derived_address
def _find_user_volume_accumulator(user: Pubkey) -> Pubkey:
derived_address, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)],
PUMP_PROGRAM,
)
return derived_address
async def get_fee_recipient(
client: AsyncClient, curve_state: BondingCurveState
) -> Pubkey:
"""Determine the correct fee recipient based on mayhem mode.
Mayhem mode tokens use a different fee recipient (reserved_fee_recipient from Global account)
instead of the standard fee recipient. This function checks the bonding curve state
and returns the appropriate fee recipient.
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole units of the curve's quote asset.
Args:
client: Solana RPC client to fetch Global account data
curve_state: Parsed bonding curve state containing is_mayhem_mode flag
curve_state: Parsed curve state
Returns:
Appropriate fee recipient pubkey (mayhem or standard)
Price in the quote asset
Raises:
ValueError: If reserves are empty
"""
if not curve_state.is_mayhem_mode:
return PUMP_FEE
# Fetch Global account to get reserved_fee_recipient for mayhem mode tokens
response = await client.get_account_info(PUMP_GLOBAL, encoding="base64")
if not response.value or not response.value.data:
# Fallback to standard fee if Global account cannot be fetched
return PUMP_FEE
data = response.value.data
# Parse reserved_fee_recipient from Global account
# Offset calculation based on pump_fun_idl.json Global struct:
# discriminator(8) + initialized(1) + authority(32) + fee_recipient(32) +
# initial_virtual_token_reserves(8) + initial_virtual_sol_reserves(8) +
# initial_real_token_reserves(8) + token_total_supply(8) + fee_basis_points(8) +
# withdraw_authority(32) + enable_migrate(1) + pool_migration_fee(8) +
# creator_fee_basis_points(8) + fee_recipients[7](224) + set_creator_authority(32) +
# admin_set_creator_authority(32) + create_v2_enabled(1) + whitelist_pda(32) = 483
RESERVED_FEE_RECIPIENT_OFFSET = 483
if len(data) < RESERVED_FEE_RECIPIENT_OFFSET + 32:
# Fallback if account data is too short
return PUMP_FEE
reserved_fee_recipient_bytes = data[
RESERVED_FEE_RECIPIENT_OFFSET : RESERVED_FEE_RECIPIENT_OFFSET + 32
]
return Pubkey.from_bytes(reserved_fee_recipient_bytes)
def calculate_pump_curve_price(curve_state: BondingCurveState) -> float:
if curve_state.virtual_token_reserves <= 0 or curve_state.virtual_sol_reserves <= 0:
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return (curve_state.virtual_sol_reserves / LAMPORTS_PER_SOL) / (
curve_state.virtual_token_reserves / 10**TOKEN_DECIMALS
)
return price
async def get_token_balance(conn: AsyncClient, associated_token_account: Pubkey):
@@ -298,97 +164,48 @@ async def sell_token(
token_price_sol = calculate_pump_curve_price(curve_state)
print(f"Price per Token: {token_price_sol:.20f} SOL")
# Calculate minimum SOL output
# Minimum payout, in the curve's quote asset raw units.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
amount = token_balance
min_sol_output = float(token_balance_decimal) * float(token_price_sol)
slippage_factor = 1 - slippage
min_sol_output = int((min_sol_output * slippage_factor) * LAMPORTS_PER_SOL)
expected_output = float(token_balance_decimal) * float(token_price_sol)
min_quote_output = max(1, int(expected_output * (1 - slippage) * quote_unit))
print(f"Selling {token_balance_decimal} tokens")
print(f"Minimum SOL output: {min_sol_output / LAMPORTS_PER_SOL:.10f} SOL")
print(f"Quote asset: {quote_mint}")
print(
f"Minimum output: {min_quote_output / quote_unit:.10f} ({min_quote_output} raw)"
)
# Determine fee recipient based on whether token uses mayhem mode
fee_recipient = await get_fee_recipient(client, curve_state)
# sell_v2 takes the same 26 mandatory accounts for every coin — no
# cashback/mayhem branching on the account list any more.
sell_ix = pump_v2.build_sell_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=amount,
min_quote_output_raw=min_quote_output,
quote_mint=quote_mint,
base_token_program=token_program_id,
is_mayhem_mode=curve_state.is_mayhem_mode,
)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=associated_bonding_curve,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=associated_token_account,
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=creator_vault,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=token_program_id, is_signer=False, is_writable=False
), # Use dynamic token_program_id
AccountMeta(
pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
# Index 12: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 13: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
]
# For cashback coins, insert user_volume_accumulator before bonding-curve-v2 (17 accounts total).
if curve_state.is_cashback_coin:
accounts.append(
AccountMeta(
pubkey=_find_user_volume_accumulator(payer.pubkey()),
is_signer=False,
is_writable=True,
instructions = [set_compute_unit_price(1_000)]
# Non-SOL proceeds land in the seller's quote ATA, which must exist.
if not pump_v2.is_sol_paired(quote_mint):
instructions.append(
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
quote_mint,
token_program_id=pump_v2.quote_token_program(quote_mint),
)
)
accounts.extend([
# bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# Breaking-upgrade fee recipient (mutable) — required from 2026-04-28.
# 16 accounts non-cashback / 17 accounts cashback.
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
])
instructions.append(sell_ix)
discriminator = struct.pack("<Q", 12502976635542562355)
# Encode OptionBool for track_volume: [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1])
data = (
discriminator
+ struct.pack("<Q", amount)
+ struct.pack("<Q", min_sol_output)
+ track_volume_bytes
)
sell_ix = Instruction(PUMP_PROGRAM, data, accounts)
msg = Message([set_compute_unit_price(1_000), sell_ix], payer.pubkey())
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
# Continue with the sell transaction
+87 -63
View File
@@ -1,10 +1,10 @@
import asyncio
import os
import random
import struct
from typing import Final
import base58
import pump_v2
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
@@ -166,6 +166,32 @@ def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey:
return derived_address
async def assert_transaction_succeeded(client: AsyncClient, signature) -> None:
"""Raise if a confirmed transaction actually failed on-chain.
`confirm_transaction` only waits for the transaction to land a landed
transaction can still have reverted. Without this check a failed buy prints
as a success, which is exactly how a wrong fee recipient (NotAuthorized,
6000) can look like a passing test.
Args:
client: Solana RPC client
signature: Transaction signature to inspect
Raises:
RuntimeError: If the transaction reverted
"""
result = await client.get_transaction(
signature, commitment="confirmed", max_supported_transaction_version=0
)
value = result.value
if value is None:
raise RuntimeError(f"Transaction {signature} not found after confirmation")
err = value.transaction.meta.err if value.transaction.meta else None
if err:
raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}")
def create_pump_create_instruction(
mint: Pubkey,
mint_authority: Pubkey,
@@ -251,68 +277,46 @@ def create_buy_instruction(
token_amount: int,
max_sol_cost: int,
track_volume: bool = True,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Create the buy instruction."""
accounts = [
AccountMeta(pubkey=global_state, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_user, is_signer=False, is_writable=True),
AccountMeta(pubkey=user, is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=SYSTEM_TOKEN_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(), is_signer=False, is_writable=False
),
AccountMeta(
pubkey=_find_user_volume_accumulator(user),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
"""Create the buy instruction (buy_v2).
# Encode OptionBool for track_volume
# OptionBool: [0] = None, [1, 0] = Some(false), [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1 if track_volume else 0])
The signature is unchanged for callers, but this builds `buy_v2` with its 27
mandatory accounts. Several parameters are accepted only for backwards
compatibility and are derived or dropped internally: buy_v2 takes no
track_volume argument, and pump_v2 selects the fee recipient from the
documented set. This script mints the coin with `creator = payer`, so the
buyer is also the creator.
data = (
BUY_DISCRIMINATOR
+ struct.pack("<Q", token_amount)
+ struct.pack("<Q", max_sol_cost)
+ track_volume_bytes
Args:
global_state: Unused; pump_v2 uses the canonical global PDA
fee_recipient: Unused; pump_v2 selects from the documented set
mint: Base token mint just created
bonding_curve: Unused; derived from the mint
associated_bonding_curve: Unused; derived
associated_user: Unused; derived
user: Buyer, and the coin's creator in this script
creator_vault: Unused; derived from the creator
token_amount: Base tokens to buy, raw units
max_sol_cost: Spend cap in lamports
track_volume: Ignored; volume tracking is unconditional under buy_v2
is_mayhem_mode: Mayhem coins must use a *reserved* fee recipient; passing
this wrong makes the program reject the buy with NotAuthorized (6000)
Returns:
The buy_v2 instruction
"""
return pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=user,
user=user,
token_amount_raw=token_amount,
max_quote_cost_raw=max_sol_cost,
quote_mint=pump_v2.WSOL_MINT,
is_mayhem_mode=is_mayhem_mode,
base_token_program=SYSTEM_TOKEN_PROGRAM,
)
return Instruction(PUMP_PROGRAM, data, accounts)
async def main():
"""Create and buy pump.fun token in a single transaction."""
@@ -385,14 +389,19 @@ async def main():
bonding_curve=bonding_curve,
user=payer.pubkey(),
),
# Create user ATA
]
# buy_v2's 27 accounts push a combined create+buy message past Solana's
# 1232-byte packet limit (measured 1972 bytes for the v2 pair), so the buy
# goes in a second transaction. The legacy 18-account buy used to fit;
# recovering atomicity would need an address lookup table.
buy_instructions = [
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
mint_keypair.pubkey(),
SYSTEM_TOKEN_PROGRAM,
),
# Buy tokens
create_buy_instruction(
global_state=PUMP_GLOBAL,
fee_recipient=PUMP_FEE,
@@ -416,18 +425,33 @@ async def main():
[payer, mint_keypair], message, recent_blockhash.value.blockhash
)
print("\nSending transaction...")
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
tx_hash = response.value
print(f"Transaction sent: https://solscan.io/tx/{tx_hash}")
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
print("Transaction confirmed!")
await assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
return tx_hash
+91 -65
View File
@@ -1,10 +1,10 @@
import asyncio
import os
import random
import struct
from typing import Final
import base58
import pump_v2
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
@@ -283,6 +283,32 @@ def create_extend_account_instruction(
return Instruction(PUMP_PROGRAM, data, accounts)
async def assert_transaction_succeeded(client: AsyncClient, signature) -> None:
"""Raise if a confirmed transaction actually failed on-chain.
`confirm_transaction` only waits for the transaction to land a landed
transaction can still have reverted. Without this check a failed buy prints
as a success, which is exactly how a wrong fee recipient (NotAuthorized,
6000) can look like a passing test.
Args:
client: Solana RPC client
signature: Transaction signature to inspect
Raises:
RuntimeError: If the transaction reverted
"""
result = await client.get_transaction(
signature, commitment="confirmed", max_supported_transaction_version=0
)
value = result.value
if value is None:
raise RuntimeError(f"Transaction {signature} not found after confirmation")
err = value.transaction.meta.err if value.transaction.meta else None
if err:
raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}")
def create_buy_instruction(
global_state: Pubkey,
fee_recipient: Pubkey,
@@ -295,68 +321,49 @@ def create_buy_instruction(
token_amount: int,
max_sol_cost: int,
track_volume: bool = True,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Create the buy instruction."""
accounts = [
AccountMeta(pubkey=global_state, is_signer=False, is_writable=False),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=True),
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_bonding_curve, is_signer=False, is_writable=True),
AccountMeta(pubkey=associated_user, is_signer=False, is_writable=True),
AccountMeta(pubkey=user, is_signer=True, is_writable=True),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=creator_vault, is_signer=False, is_writable=True),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=_find_global_volume_accumulator(), is_signer=False, is_writable=False
),
AccountMeta(
pubkey=_find_user_volume_accumulator(user),
is_signer=False,
is_writable=True,
),
# Index 14: fee_config (readonly)
AccountMeta(
pubkey=_find_fee_config(),
is_signer=False,
is_writable=False,
),
# Index 15: fee_program (readonly)
AccountMeta(
pubkey=PUMP_FEE_PROGRAM,
is_signer=False,
is_writable=False,
),
# Remaining account: bonding_curve_v2 (readonly, required for all coins)
AccountMeta(
pubkey=_find_bonding_curve_v2(mint),
is_signer=False,
is_writable=False,
),
# 18th account: breaking-upgrade fee recipient (mutable) — required from 2026-04-28
AccountMeta(
pubkey=random.choice(BREAKING_FEE_RECIPIENTS),
is_signer=False,
is_writable=True,
),
]
"""Create the buy instruction (buy_v2).
# Encode OptionBool for track_volume
# OptionBool: [0] = None, [1, 0] = Some(false), [1, 1] = Some(true)
track_volume_bytes = bytes([1, 1 if track_volume else 0])
Signature is unchanged for the caller, but this now builds `buy_v2` with its
27 mandatory accounts. `global_state`, `fee_recipient`,
`associated_bonding_curve`, `associated_user`, `creator_vault` and
`track_volume` are accepted for backwards compatibility and derived or
dropped internally buy_v2 has no track_volume argument, and pump_v2 picks
the fee recipient from the documented set.
data = (
BUY_DISCRIMINATOR
+ struct.pack("<Q", token_amount)
+ struct.pack("<Q", max_sol_cost)
+ track_volume_bytes
These scripts mint the coin themselves with `creator = payer`, so the buyer
is also the creator.
Args:
global_state: Unused; pump_v2 uses the canonical global PDA
fee_recipient: Unused; pump_v2 selects from the documented set
mint: Base token mint just created
bonding_curve: Unused; derived from the mint
associated_bonding_curve: Unused; derived
associated_user: Unused; derived
user: Buyer, and the coin's creator in these scripts
creator_vault: Unused; derived from the creator
token_amount: Base tokens to buy, raw units
max_sol_cost: Spend cap in lamports
track_volume: Ignored; volume tracking is unconditional under buy_v2
is_mayhem_mode: Mayhem coins must use a *reserved* fee recipient; passing
this wrong makes the program reject the buy with NotAuthorized (6000)
Returns:
The buy_v2 instruction
"""
return pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=user,
user=user,
token_amount_raw=token_amount,
max_quote_cost_raw=max_sol_cost,
quote_mint=pump_v2.WSOL_MINT,
is_mayhem_mode=is_mayhem_mode,
base_token_program=TOKEN_2022_PROGRAM,
)
return Instruction(PUMP_PROGRAM, data, accounts)
async def get_fee_recipient_for_mayhem(client: AsyncClient, is_mayhem: bool) -> Pubkey:
"""Get the appropriate fee recipient based on mayhem mode.
@@ -474,14 +481,19 @@ async def main():
bonding_curve=bonding_curve,
user=payer.pubkey(),
),
# Create user ATA
]
# buy_v2's 27 accounts push a combined create+buy message past Solana's
# 1232-byte packet limit (measured 1972 bytes), so the buy goes in a
# second transaction. The legacy 18-account buy used to fit; recovering
# atomicity would need an address lookup table.
buy_instructions = [
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
mint_keypair.pubkey(),
TOKEN_2022_PROGRAM,
),
# Buy tokens
create_buy_instruction(
global_state=PUMP_GLOBAL,
fee_recipient=fee_recipient,
@@ -494,6 +506,7 @@ async def main():
token_amount=expected_tokens,
max_sol_cost=max_sol_cost,
track_volume=True,
is_mayhem_mode=ENABLE_MAYHEM_MODE,
),
]
@@ -503,18 +516,31 @@ async def main():
[payer, mint_keypair], message, recent_blockhash.value.blockhash
)
print("\nSending transaction...")
print("\nSending create transaction...")
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
try:
response = await client.send_transaction(transaction, opts)
tx_hash = response.value
print(f"Transaction sent: https://solscan.io/tx/{tx_hash}")
print(f"Create sent: https://solscan.io/tx/{tx_hash}")
print("Waiting for confirmation...")
await client.confirm_transaction(tx_hash, commitment="confirmed")
print("Transaction confirmed!")
await assert_transaction_succeeded(client, tx_hash)
print("Create confirmed!")
buy_blockhash = await client.get_latest_blockhash()
buy_tx = Transaction(
[payer],
Message(buy_instructions, payer.pubkey()),
buy_blockhash.value.blockhash,
)
print("\nSending buy transaction (buy_v2)...")
buy_response = await client.send_transaction(buy_tx, opts)
buy_hash = buy_response.value
print(f"Buy sent: https://solscan.io/tx/{buy_hash}")
await client.confirm_transaction(buy_hash, commitment="confirmed")
await assert_transaction_succeeded(client, buy_hash)
print("Buy confirmed!")
return tx_hash
+502
View File
@@ -0,0 +1,502 @@
"""Self-contained pump.fun v2 trade helpers for the learning examples.
The bonding-curve program's `buy_v2` / `sell_v2` instructions take 27 and 26
mandatory accounts in a fixed order, identical for every coin SOL-paired or
USDC-paired, mayhem or not, cashback or not. Encoding that once here keeps the
example scripts from each carrying their own copy of the list, which is how they
drift out of sync with the program.
Deliberately standalone: it imports nothing from `src/`, so the examples stay
readable on their own. `learning-examples/verify_v2_account_layout.py` checks the
layout below against `idl/pump_fun_idl.json`.
Docs: BUY.md, SELL.md and COIN_CREATION.md under docs/instructions in
github.com/pump-fun/pump-public-docs
"""
import secrets
import struct
from construct import Flag, Int64ul, Struct
from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
# Programs and well-known accounts
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
PUMP_GLOBAL = Pubkey.from_string("4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf")
PUMP_EVENT_AUTHORITY = Pubkey.from_string(
"Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1"
)
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
ASSOCIATED_TOKEN_PROGRAM = Pubkey.from_string(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
)
# Quote mints. bonding_curve.quote_mint is Pubkey::default() for SOL-paired
# coins, but the v2 instructions expect wrapped SOL to be passed explicitly.
DEFAULT_PUBKEY = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
USDC_MINT = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
QUOTE_DECIMALS = {WSOL_MINT: 9, USDC_MINT: 6}
TOKEN_DECIMALS = 6
LAMPORTS_PER_SOL = 1_000_000_000
# Account discriminator for the BondingCurve account.
BONDING_CURVE_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
# Instruction discriminators (first 8 bytes of sha256("global:<name>")).
BUY_V2_DISCRIMINATOR = bytes([184, 23, 238, 97, 103, 197, 211, 61])
SELL_V2_DISCRIMINATOR = bytes([93, 246, 130, 60, 231, 233, 64, 178])
# Fee recipients: 8 normal (non-mayhem coins), 8 reserved (mayhem coins),
# 8 buyback (every coin). See FEE_RECIPIENTS.md in the pump-fun public docs.
NORMAL_FEE_RECIPIENTS = [
Pubkey.from_string("62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV"),
Pubkey.from_string("7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"),
Pubkey.from_string("7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX"),
Pubkey.from_string("9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz"),
Pubkey.from_string("AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY"),
Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM"),
Pubkey.from_string("FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz"),
Pubkey.from_string("G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP"),
]
RESERVED_FEE_RECIPIENTS = [
Pubkey.from_string("GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS"),
Pubkey.from_string("4budycTjhs9fD6xw62VBducVTNgMgJJ5BgtKq7mAZwn6"),
Pubkey.from_string("8SBKzEQU4nLSzcwF4a74F2iaUDQyTfjGndn6qUWBnrpR"),
Pubkey.from_string("4UQeTP1T39KZ9Sfxzo3WR5skgsaP6NZa87BAkuazLEKH"),
Pubkey.from_string("8sNeir4QsLsJdYpc9RZacohhK1Y5FLU3nC5LXgYB4aa6"),
Pubkey.from_string("Fh9HmeLNUMVCvejxCtCL2DbYaRyBFVJ5xrWkLnMH6fdk"),
Pubkey.from_string("463MEnMeGyJekNZFQSTUABBEbLnvMTALbT6ZmsxAbAdq"),
Pubkey.from_string("6AUH3WEHucYZyC61hqpqYUWVto5qA5hjHuNQ32GNnNxA"),
]
BUYBACK_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"),
]
def normalize_quote_mint(quote_mint: Pubkey | None) -> Pubkey:
"""Map a curve's raw quote_mint onto the mint to pass to the instruction.
Args:
quote_mint: Raw value read from the bonding curve, or None
Returns:
Wrapped SOL for SOL-paired coins, otherwise the mint unchanged
"""
if quote_mint is None or quote_mint == DEFAULT_PUBKEY:
return WSOL_MINT
return quote_mint
def is_sol_paired(quote_mint: Pubkey | None) -> bool:
"""Whether a coin settles in native SOL.
Args:
quote_mint: Raw or normalized quote mint
Returns:
True if the coin is SOL-paired
"""
return normalize_quote_mint(quote_mint) == WSOL_MINT
def quote_units(quote_mint: Pubkey) -> int:
"""Raw units per whole unit of a quote mint.
Args:
quote_mint: Quote mint address
Returns:
1e9 for SOL, 1e6 for USDC
"""
return 10 ** QUOTE_DECIMALS.get(quote_mint, 9)
def quote_token_program(quote_mint: Pubkey) -> Pubkey:
"""Token program owning a quote mint.
Args:
quote_mint: Quote mint address
Returns:
Token program id (both current quote mints are legacy SPL Token)
"""
return TOKEN_PROGRAM
def find_bonding_curve(mint: Pubkey) -> Pubkey:
"""Derive the bonding curve PDA.
Args:
mint: Base token mint
Returns:
Bonding curve address
"""
return Pubkey.find_program_address([b"bonding-curve", bytes(mint)], PUMP_PROGRAM)[0]
def find_creator_vault(creator: Pubkey) -> Pubkey:
"""Derive the creator vault PDA.
Args:
creator: Coin creator
Returns:
Creator vault address
"""
return Pubkey.find_program_address(
[b"creator-vault", bytes(creator)], PUMP_PROGRAM
)[0]
def find_global_volume_accumulator() -> Pubkey:
"""Derive the global volume accumulator PDA.
Returns:
Global volume accumulator address
"""
return Pubkey.find_program_address([b"global_volume_accumulator"], PUMP_PROGRAM)[0]
def find_user_volume_accumulator(user: Pubkey) -> Pubkey:
"""Derive a user's volume accumulator PDA.
Args:
user: User wallet
Returns:
User volume accumulator address
"""
return Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)], PUMP_PROGRAM
)[0]
def find_fee_config() -> Pubkey:
"""Derive the fee config PDA (under the pump fees program).
Returns:
Fee config address
"""
return Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_PROGRAM)], PUMP_FEE_PROGRAM
)[0]
def find_sharing_config(base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA (under the pump fees program).
Mandatory on buy_v2/sell_v2.
Args:
base_mint: Base token mint
Returns:
Sharing config address
"""
return Pubkey.find_program_address(
[b"sharing-config", bytes(base_mint)], PUMP_FEE_PROGRAM
)[0]
def find_associated_token_account(
owner: Pubkey, mint: Pubkey, token_program: Pubkey
) -> Pubkey:
"""Derive an associated token account address.
Args:
owner: ATA owner (may be a PDA)
mint: Token mint
token_program: Token program owning the mint
Returns:
Associated token account address
"""
return Pubkey.find_program_address(
[bytes(owner), bytes(token_program), bytes(mint)],
ASSOCIATED_TOKEN_PROGRAM,
)[0]
def pick_fee_recipient(*, is_mayhem_mode: bool) -> Pubkey:
"""Pick a fee_recipient from the set the program expects for this coin.
Args:
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
A fee recipient address
"""
pool = RESERVED_FEE_RECIPIENTS if is_mayhem_mode else NORMAL_FEE_RECIPIENTS
return secrets.choice(pool)
def pick_buyback_fee_recipient() -> Pubkey:
"""Pick a buyback fee recipient, required on every v2 trade.
Returns:
A buyback fee recipient address
"""
return secrets.choice(BUYBACK_FEE_RECIPIENTS)
class BondingCurveState:
"""Parsed pump.fun BondingCurve account.
The account is 151 bytes: the 115-byte documented struct followed by
reserved padding. The SOL-named reserve fields were renamed to quote fields
when non-SOL quote assets landed; the old names are kept as aliases.
"""
_STRUCT = Struct(
"virtual_token_reserves" / Int64ul,
"virtual_quote_reserves" / Int64ul,
"real_token_reserves" / Int64ul,
"real_quote_reserves" / Int64ul,
"token_total_supply" / Int64ul,
"complete" / Flag,
)
# Byte offsets from the start of the account, discriminator included.
_CREATOR_OFFSET = 49
_MAYHEM_OFFSET = 81
_CASHBACK_OFFSET = 82
_QUOTE_MINT_OFFSET = 83
def __init__(self, data: bytes) -> None:
"""Parse bonding curve account data.
Args:
data: Raw account data including the 8-byte discriminator
Raises:
ValueError: If the discriminator is wrong or the data is truncated
"""
if len(data) < 8:
raise ValueError("Data too short to contain discriminator")
if data[:8] != BONDING_CURVE_DISCRIMINATOR:
raise ValueError("Invalid curve state discriminator")
self.__dict__.update(self._STRUCT.parse(data[8:]))
# Aliases for the pre-rename field names.
self.virtual_sol_reserves = self.virtual_quote_reserves
self.real_sol_reserves = self.real_quote_reserves
self.creator = self._read_pubkey(data, self._CREATOR_OFFSET)
self.is_mayhem_mode = self._read_flag(data, self._MAYHEM_OFFSET)
self.is_cashback_coin = self._read_flag(data, self._CASHBACK_OFFSET)
raw_quote_mint = self._read_pubkey(data, self._QUOTE_MINT_OFFSET)
self.quote_mint = normalize_quote_mint(raw_quote_mint)
self.is_sol_paired = is_sol_paired(raw_quote_mint)
@staticmethod
def _read_pubkey(data: bytes, offset: int) -> Pubkey | None:
"""Read a 32-byte pubkey if the data extends that far.
Args:
data: Raw account data
offset: Byte offset
Returns:
Pubkey, or None if the field is absent
"""
if len(data) < offset + 32:
return None
return Pubkey.from_bytes(data[offset : offset + 32])
@staticmethod
def _read_flag(data: bytes, offset: int) -> bool:
"""Read a single-byte bool if the data extends that far.
Args:
data: Raw account data
offset: Byte offset
Returns:
Flag value, or False if the field is absent
"""
return bool(data[offset]) if len(data) > offset else False
def price_per_token(self) -> float:
"""Current price in whole quote units per whole token.
Returns:
Price, or 0.0 if reserves are empty
"""
if not self.virtual_token_reserves or not self.virtual_quote_reserves:
return 0.0
return (
self.virtual_quote_reserves / 10 ** QUOTE_DECIMALS.get(self.quote_mint, 9)
) / (self.virtual_token_reserves / 10**TOKEN_DECIMALS)
def build_v2_accounts(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
quote_mint: Pubkey,
base_token_program: Pubkey,
is_mayhem_mode: bool,
include_global_volume_accumulator: bool,
) -> list[AccountMeta]:
"""Build the ordered account list shared by buy_v2 and sell_v2.
Args:
base_mint: Coin being traded
creator: Coin creator, from bonding_curve.creator
user: Signer / trader
quote_mint: Normalized quote mint (wrapped SOL for SOL-paired coins)
base_token_program: Token program owning base_mint
is_mayhem_mode: Selects which fee recipient set to draw from
include_global_volume_accumulator: True for buy_v2, False for sell_v2
Returns:
Ordered AccountMeta list (27 entries for buy_v2, 26 for sell_v2)
"""
quote_program = quote_token_program(quote_mint)
bonding_curve = find_bonding_curve(base_mint)
creator_vault = find_creator_vault(creator)
user_volume_accumulator = find_user_volume_accumulator(user)
fee_recipient = pick_fee_recipient(is_mayhem_mode=is_mayhem_mode)
buyback_fee_recipient = pick_buyback_fee_recipient()
def ata(owner: Pubkey, mint: Pubkey, program: Pubkey) -> Pubkey:
return find_associated_token_account(owner, mint, program)
accounts = [
(PUMP_GLOBAL, False),
(base_mint, False),
(quote_mint, False),
(base_token_program, False),
(quote_program, False),
(ASSOCIATED_TOKEN_PROGRAM, False),
(fee_recipient, True),
(ata(fee_recipient, quote_mint, quote_program), True),
(buyback_fee_recipient, True),
(ata(buyback_fee_recipient, quote_mint, quote_program), True),
(bonding_curve, True),
(ata(bonding_curve, base_mint, base_token_program), True),
(ata(bonding_curve, quote_mint, quote_program), True),
(user, True),
(ata(user, base_mint, base_token_program), True),
(ata(user, quote_mint, quote_program), True),
(creator_vault, True),
(ata(creator_vault, quote_mint, quote_program), True),
(find_sharing_config(base_mint), False),
]
if include_global_volume_accumulator:
accounts.append((find_global_volume_accumulator(), False))
accounts += [
(user_volume_accumulator, True),
(ata(user_volume_accumulator, quote_mint, quote_program), True),
(find_fee_config(), False),
(PUMP_FEE_PROGRAM, False),
(SYSTEM_PROGRAM, False),
(PUMP_EVENT_AUTHORITY, False),
(PUMP_PROGRAM, False),
]
return [
AccountMeta(pubkey=pubkey, is_signer=pubkey == user, is_writable=writable)
for pubkey, writable in accounts
]
def build_buy_v2_instruction(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
token_amount_raw: int,
max_quote_cost_raw: int,
quote_mint: Pubkey = WSOL_MINT,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Build a buy_v2 instruction.
Args:
base_mint: Coin to buy
creator: Coin creator, from bonding_curve.creator
user: Buyer / signer
token_amount_raw: Base tokens to buy, in raw units
max_quote_cost_raw: Spend cap in the quote mint's raw units
quote_mint: Normalized quote mint
base_token_program: Token program owning base_mint
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
The buy_v2 instruction
"""
return Instruction(
program_id=PUMP_PROGRAM,
data=BUY_V2_DISCRIMINATOR
+ struct.pack("<Q", token_amount_raw)
+ struct.pack("<Q", max_quote_cost_raw),
accounts=build_v2_accounts(
base_mint=base_mint,
creator=creator,
user=user,
quote_mint=quote_mint,
base_token_program=base_token_program,
is_mayhem_mode=is_mayhem_mode,
include_global_volume_accumulator=True,
),
)
def build_sell_v2_instruction(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
token_amount_raw: int,
min_quote_output_raw: int,
quote_mint: Pubkey = WSOL_MINT,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
) -> Instruction:
"""Build a sell_v2 instruction.
Args:
base_mint: Coin to sell
creator: Coin creator, from bonding_curve.creator
user: Seller / signer
token_amount_raw: Base tokens to sell, in raw units
min_quote_output_raw: Minimum acceptable payout in raw quote units
quote_mint: Normalized quote mint
base_token_program: Token program owning base_mint
is_mayhem_mode: Whether the coin is in mayhem mode
Returns:
The sell_v2 instruction
"""
return Instruction(
program_id=PUMP_PROGRAM,
data=SELL_V2_DISCRIMINATOR
+ struct.pack("<Q", token_amount_raw)
+ struct.pack("<Q", min_quote_output_raw),
accounts=build_v2_accounts(
base_mint=base_mint,
creator=creator,
user=user,
quote_mint=quote_mint,
base_token_program=base_token_program,
is_mayhem_mode=is_mayhem_mode,
include_global_volume_accumulator=False,
),
)
@@ -100,7 +100,11 @@ BREAKING_FEE_RECIPIENTS = [
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 # Where is_cashback_coin 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
@@ -188,6 +192,11 @@ async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
("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:
@@ -364,23 +373,54 @@ async def get_pumpswap_fee_recipients(
# ============================================================================
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 balances.
"""Calculate current token price from AMM pool reserves.
AMM price is determined by the ratio of tokens in the pool:
price = quote_balance / base_balance
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 SOL account (the quote currency)
pool_quote_token_account: Pool's quote account
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in SOL per token
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(pool_base_token_account)
quote_balance_resp = await client.get_token_account_balance(
@@ -388,7 +428,9 @@ async def calculate_token_pool_price(
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_amount = float(quote_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
@@ -462,7 +504,10 @@ async def buy_pump_swap(
"""
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
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")
@@ -92,7 +92,11 @@ BREAKING_FEE_RECIPIENTS = [
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 # Where is_cashback_coin 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
@@ -176,6 +180,11 @@ async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
("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:
@@ -328,23 +337,54 @@ async def get_pumpswap_fee_recipients(
# ============================================================================
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 balances.
"""Calculate current token price from AMM pool reserves.
AMM price is determined by the ratio of tokens in the pool:
price = quote_balance / base_balance
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 SOL account (the quote currency)
pool_quote_token_account: Pool's quote account
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in SOL per token
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(pool_base_token_account)
quote_balance_resp = await client.get_token_account_balance(
@@ -352,7 +392,9 @@ async def calculate_token_pool_price(
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_amount = float(quote_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
@@ -469,7 +511,10 @@ async def sell_pump_swap(
return None
token_price_sol = await calculate_token_pool_price(
client, pool_base_token_account, pool_quote_token_account
client,
pool_base_token_account,
pool_quote_token_account,
await read_virtual_quote_reserves(client, market),
)
print(f"Price per Token: {token_price_sol:.20f} SOL")
+230
View File
@@ -0,0 +1,230 @@
"""Dry-run the bot's real buy path against a freshly created coin.
Detects a new pump.fun coin with the bot's own listener, runs the same
PlatformAwareBuyer code the bot uses (including the extreme_fast_mode curve
refresh), but intercepts the transaction just before submission and simulates it
instead. This exercises the listener -> event parser -> curve manager ->
address provider -> instruction builder chain as a unit.
No funds move: `build_and_send_transaction` is monkeypatched to simulate.
Usage:
uv run learning-examples/simulate_bot_buy_path.py
uv run learning-examples/simulate_bot_buy_path.py --no-extreme-fast
"""
import asyncio
import os
import sys
from base64 import b64encode
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price # noqa: E402
from solders.message import Message # noqa: E402
from solders.transaction import Transaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from monitoring.listener_factory import ListenerFactory # noqa: E402
from trading.platform_aware import PlatformAwareBuyer # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
BUY_AMOUNT_SOL = 0.0001
EXTREME_FAST_TOKEN_AMOUNT = 20
# Matches retries.wait_after_creation in the bot configs. Only used when
# extreme_fast_mode is off, where the buyer reads the curve at `confirmed`.
CURVE_STABILIZE_SECONDS = 15
async def wait_for_token(timeout_seconds: float = 90.0) -> TokenInfo | None:
"""Wait for the bot's geyser listener to report a new coin.
Args:
timeout_seconds: How long to wait
Returns:
The first TokenInfo seen, or None on timeout
"""
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
for _ in range(int(timeout_seconds / 0.5)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0] if seen else None
def install_simulation_hook(client: SolanaClient) -> dict:
"""Replace transaction submission with simulation.
Args:
client: Client whose send path should be intercepted
Returns:
Dict that will be populated with the simulation outcome
"""
outcome: dict = {}
async def simulate_instead(
instructions,
signer_keypair,
skip_preflight=True,
max_retries=3,
priority_fee=None,
compute_unit_limit=None,
account_data_size_limit=None,
):
preamble = []
if compute_unit_limit:
preamble.append(set_compute_unit_limit(compute_unit_limit))
if priority_fee:
preamble.append(set_compute_unit_price(priority_fee))
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[*preamble, *instructions], signer_keypair.pubkey(), blockhash
)
transaction = Transaction([signer_keypair], message, blockhash)
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "simulateTransaction",
"params": [
b64encode(bytes(transaction)).decode(),
{
"encoding": "base64",
"sigVerify": False,
"replaceRecentBlockhash": True,
"commitment": "processed",
},
],
}
)
value = (response or {}).get("result", {}).get("value", {})
outcome.update(
{
"err": value.get("err"),
"units": value.get("unitsConsumed"),
"logs": value.get("logs") or [],
"cu_limit": compute_unit_limit,
"priority_fee": priority_fee,
"instruction_count": len(instructions),
"account_count": len(instructions[-1].accounts),
}
)
# Returning a sentinel signature: confirm_transaction is stubbed below.
return "SIMULATED"
async def never_confirm(_signature, **_kwargs):
return False
client.build_and_send_transaction = simulate_instead
client.confirm_transaction = never_confirm
return outcome
async def main() -> int:
"""Run the bot's buy path in simulation mode.
Returns:
Process exit code (0 if the simulated buy had no program error)
"""
extreme_fast = "--no-extreme-fast" not in sys.argv
print("Waiting for a fresh pump.fun coin via the bot's geyser listener...")
token_info = await wait_for_token()
if token_info is None:
print("No coin detected before timeout.")
return 2
print(f"\ndetected: {token_info.symbol} ({token_info.mint})")
print(f"quote_mint (from CreateEvent): {token_info.quote_mint}")
print(f"token program: {token_info.token_program_id}")
print(f"mayhem={token_info.is_mayhem_mode} cashback={token_info.is_cashback_coin}")
print(f"extreme_fast_mode={extreme_fast}\n")
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
priority_fee_manager = PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=1_000_000,
extra_fee=0.0,
hard_cap=1_000_000,
)
outcome = install_simulation_hook(client)
buyer = PlatformAwareBuyer(
client,
wallet,
priority_fee_manager,
BUY_AMOUNT_SOL,
slippage=0.3,
max_retries=1,
extreme_fast_token_amount=EXTREME_FAST_TOKEN_AMOUNT,
extreme_fast_mode=extreme_fast,
)
if not extreme_fast:
# Mirror the bot's retries.wait_after_creation pause. Without it the
# curve read races the account's confirmation and fails before any
# instruction is built.
print(f"Waiting {CURVE_STABILIZE_SECONDS}s for the curve to stabilize...")
await asyncio.sleep(CURVE_STABILIZE_SECONDS)
try:
result = await buyer.execute(token_info)
finally:
await client.close()
if not outcome:
print(f"Buy path never reached transaction submission: {result.error_message}")
return 1
print("simulated buy:")
print(f" instructions: {outcome['instruction_count']}")
print(f" trade accounts: {outcome['account_count']}")
print(f" cu_limit: {outcome['cu_limit']}")
print(f" unitsConsumed: {outcome['units']}")
print(f" err: {outcome['err']}")
if outcome["err"]:
for line in outcome["logs"]:
if "Error" in line or "failed" in line or "Instruction:" in line:
print(f" {line}")
return 1
headroom = outcome["cu_limit"] - (outcome["units"] or 0)
print(f"\nCU headroom: {headroom} ({headroom / outcome['cu_limit']:.0%})")
print("Buy path validated end to end against live mainnet state.")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+288
View File
@@ -0,0 +1,288 @@
"""Simulate pump.fun buy_v2 / sell_v2 against mainnet without spending funds.
Builds both instructions through the bot's real code path (address provider,
instruction builder, curve manager) and runs them through
`simulateTransaction`, which executes the program against live state but never
submits. Reports the program error (if any) and the compute units consumed
use the latter to tune `get_buy_compute_unit_limit` / `get_sell_compute_unit_limit`.
The wallet is only used to derive a pubkey and sign nothing: simulation runs
with sigVerify disabled.
Usage:
# simulate against a specific coin
uv run learning-examples/simulate_v2_trades.py <MINT>
# discover a fresh coin via geyser, then simulate against it
uv run learning-examples/simulate_v2_trades.py
"""
import asyncio
import os
import sys
from base64 import b64encode
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solders.compute_budget import set_compute_unit_limit # noqa: E402
from solders.message import Message # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from solders.transaction import Transaction # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.pubkeys import ( # noqa: E402
TOKEN_DECIMALS,
SystemAddresses,
normalize_quote_mint,
quote_token_program,
quote_units_per_token,
)
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms import get_platform_implementations # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
# Buy this many whole tokens in the simulation. Small enough that the quote cost
# stays well under a test wallet's balance on a fresh curve.
SIMULATED_TOKEN_AMOUNT = 20
# Generous ceiling so simulation reports true consumption rather than hitting
# the limit. The real bot uses the builder's tuned values.
SIMULATION_CU_LIMIT = 400_000
async def discover_mint(timeout_seconds: float = 45.0) -> Pubkey | None:
"""Listen for a freshly created pump.fun coin via geyser.
Args:
timeout_seconds: How long to wait for a creation event
Returns:
Mint of the first coin seen, or None on timeout
"""
from monitoring.listener_factory import ListenerFactory
listener = ListenerFactory.create_listener(
listener_type="geyser",
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
platforms=[Platform.PUMP_FUN],
)
seen: list[TokenInfo] = []
async def on_token(token_info: TokenInfo) -> None:
seen.append(token_info)
task = asyncio.create_task(listener.listen_for_tokens(on_token))
try:
deadline = timeout_seconds / 0.5
for _ in range(int(deadline)):
if seen:
break
await asyncio.sleep(0.5)
finally:
task.cancel()
return seen[0].mint if seen else None
async def build_token_info(
mint: Pubkey, client: SolanaClient
) -> tuple[TokenInfo, dict]:
"""Assemble TokenInfo for an existing coin from its on-chain curve state.
Args:
mint: Base token mint
client: Solana RPC client
Returns:
Tuple of (TokenInfo, decoded curve state)
"""
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
provider = implementations.address_provider
curve_manager = implementations.curve_manager
bonding_curve = provider.derive_pool_address(mint)
state = await curve_manager.get_pool_state(bonding_curve, commitment="processed")
creator = state["creator"]
creator = Pubkey.from_string(creator) if isinstance(creator, str) else creator
quote_mint = normalize_quote_mint(state["quote_mint"])
# Which token program owns the base mint decides the base ATA derivation.
account = await client.get_account_info(mint)
base_token_program = (
SystemAddresses.TOKEN_2022_PROGRAM
if str(account.owner) == str(SystemAddresses.TOKEN_2022_PROGRAM)
else SystemAddresses.TOKEN_PROGRAM
)
token_info = TokenInfo(
name="simulation",
symbol="SIM",
uri="",
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=provider.derive_associated_bonding_curve(
mint, bonding_curve, base_token_program
),
creator=creator,
creator_vault=provider.derive_creator_vault(creator),
token_program_id=base_token_program,
is_mayhem_mode=state["is_mayhem_mode"],
is_cashback_coin=state["is_cashback_coin"],
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
)
return token_info, state
async def simulate(
client: SolanaClient, wallet: Wallet, instructions: list, label: str
) -> bool:
"""Run a set of instructions through simulateTransaction and report.
Args:
client: Solana RPC client
wallet: Wallet whose pubkey pays and signs
instructions: Instructions to simulate
label: Human-readable name for output
Returns:
True if the simulation reported no program error
"""
blockhash = await client.get_latest_blockhash()
message = Message.new_with_blockhash(
[set_compute_unit_limit(SIMULATION_CU_LIMIT), *instructions],
wallet.pubkey,
blockhash,
)
transaction = Transaction([wallet.keypair], message, blockhash)
response = await client.post_rpc(
{
"jsonrpc": "2.0",
"id": 1,
"method": "simulateTransaction",
"params": [
b64encode(bytes(transaction)).decode(),
{
"encoding": "base64",
"sigVerify": False,
"replaceRecentBlockhash": True,
"commitment": "processed",
},
],
}
)
if not response or "result" not in response:
print(f" {label}: RPC call failed: {response}")
return False
value = response["result"]["value"]
err = value.get("err")
units = value.get("unitsConsumed")
account_count = len(instructions[-1].accounts)
print(f" {label}: accounts={account_count} unitsConsumed={units} err={err}")
if err:
for line in value.get("logs") or []:
if "Error" in line or "failed" in line or "Instruction:" in line:
print(f" {line}")
return err is None
async def main() -> int:
"""Simulate a buy_v2 and sell_v2 for one coin.
Returns:
Process exit code (0 if the buy simulation succeeded)
"""
mint_arg = sys.argv[1] if len(sys.argv) > 1 else None
if mint_arg:
mint = Pubkey.from_string(mint_arg)
else:
print("No mint given; listening for a fresh pump.fun coin via geyser...")
mint = await discover_mint()
if mint is None:
print("No coin seen before timeout. Pass a mint explicitly.")
return 2
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
try:
token_info, state = await build_token_info(mint, client)
quote_mint = token_info.quote_mint
quote_unit = quote_units_per_token(quote_mint)
print(f"\nmint: {mint}")
print(f"bonding_curve: {token_info.bonding_curve}")
print(f"quote_mint: {quote_mint}")
print(f"base program: {token_info.token_program_id}")
print(
f"mayhem={token_info.is_mayhem_mode} "
f"cashback={token_info.is_cashback_coin} "
f"complete={state['complete']}"
)
print(f"price: {state['price_per_token']:.10f} quote/token")
print(f"wallet: {wallet.pubkey}\n")
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
provider = implementations.address_provider
builder = implementations.instruction_builder
token_raw = SIMULATED_TOKEN_AMOUNT * 10**TOKEN_DECIMALS
# Cap the quote spend generously; simulation only needs it affordable.
max_quote_raw = int(
SIMULATED_TOKEN_AMOUNT * state["price_per_token"] * quote_unit * 2
) or int(0.001 * quote_unit)
print("simulating:")
buy_instructions = await builder.build_buy_v2_instruction(
token_info, wallet.pubkey, max_quote_raw, token_raw, provider
)
buy_ok = await simulate(client, wallet, buy_instructions, "buy_v2 ")
sell_instructions = await builder.build_sell_v2_instruction(
token_info, wallet.pubkey, token_raw, 1, provider
)
await simulate(client, wallet, sell_instructions, "sell_v2")
print(
" ^ expected to fail with AccountNotInitialized on "
"associated_base_user unless\n the wallet already holds this "
"coin. That error means all 26 accounts validated."
)
# Buy and sell in one transaction so the position exists mid-tx. This is
# the only way to get a representative sell_v2 CU number without
# actually holding the coin. Sell slightly less than bought to stay
# inside the realised balance.
combined = [*buy_instructions, *sell_instructions[:-1]]
resell_raw = int(token_raw * 0.9)
combined.append(
(
await builder.build_sell_v2_instruction(
token_info, wallet.pubkey, resell_raw, 1, provider
)
)[-1]
)
combined_ok = await simulate(client, wallet, combined, "buy+sell")
if combined_ok:
print(" ^ subtract the buy_v2 figure above to estimate sell_v2 CU")
return 0 if buy_ok else 1
finally:
await client.close()
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,556 @@
"""Cross-check the hardcoded buy_v2/sell_v2 account layouts against the IDL.
The v2 instructions take 27 and 26 mandatory accounts in a fixed order. Getting
one position or writability flag wrong produces an on-chain failure that is
awkward to debug, so this script diffs the layouts in
`platforms.pumpfun.instruction_builder` against `idl/pump_fun_idl.json` and also
recomputes every PDA/ATA the address provider derives, comparing each against
the seeds declared in the IDL.
Runs entirely offline no RPC, no keys, no transactions.
Usage:
uv run learning-examples/verify_v2_account_layout.py
"""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "learning-examples"))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from spl.token.instructions import get_associated_token_address # noqa: E402
from core.pubkeys import USDC_MINT, WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms.pumpfun.address_provider import ( # noqa: E402
PumpFunAddresses,
PumpFunAddressProvider,
)
from platforms.pumpfun.instruction_builder import ( # noqa: E402
_BUY_V2_ACCOUNTS,
_SELL_V2_ACCOUNTS,
)
IDL_PATH = PROJECT_ROOT / "idl" / "pump_fun_idl.json"
def load_idl_accounts(name: str) -> list[dict]:
"""Get the IDL account list for an instruction.
Args:
name: Instruction name
Returns:
List of IDL account definitions
"""
idl = json.loads(IDL_PATH.read_text())
for instruction in idl["instructions"]:
if instruction["name"] == name:
return instruction["accounts"]
raise KeyError(f"Instruction {name} not present in {IDL_PATH}")
def check_layout(name: str, layout: list[tuple[str, bool]]) -> list[str]:
"""Compare a hardcoded layout against the IDL's account list.
Args:
name: Instruction name
layout: Ordered (account name, is_writable) pairs from our builder
Returns:
List of human-readable problems (empty if the layout matches)
"""
idl_accounts = load_idl_accounts(name)
problems = []
if len(layout) != len(idl_accounts):
problems.append(
f"{name}: account count {len(layout)} != IDL {len(idl_accounts)}"
)
for index, (idl_account, ours) in enumerate(zip(idl_accounts, layout), start=1):
our_name, our_writable = ours
if idl_account["name"] != our_name:
problems.append(
f"{name}[{index}]: name {our_name!r} != IDL {idl_account['name']!r}"
)
idl_writable = bool(idl_account.get("writable"))
if idl_writable != our_writable:
problems.append(
f"{name}[{index}] {our_name}: writable={our_writable} "
f"!= IDL writable={idl_writable}"
)
if idl_account.get("signer") and our_name != "user":
problems.append(
f"{name}[{index}] {our_name}: IDL marks this a signer but only "
f"`user` is expected to sign"
)
return problems
def build_token_info(quote_mint: Pubkey, *, mayhem: bool) -> TokenInfo:
"""Construct a TokenInfo for a synthetic coin.
Args:
quote_mint: Quote mint to pair the coin against
mayhem: Whether the coin is in mayhem mode
Returns:
TokenInfo suitable for driving the address provider
"""
provider = PumpFunAddressProvider()
# Fixed, arbitrary mint/creator so results are reproducible.
mint = Pubkey.from_string("CU7nUQaJ4beyYjC3xAUrh5RiSjw14fhU6oWTwRBse8gj")
creator = Pubkey.from_string("5wyFsNExysbXf2hTtcn8Tqd3urs9Nv85Zx1zNdAfTMmX")
bonding_curve = provider.derive_pool_address(mint)
return TokenInfo(
name="layout-check",
symbol="CHK",
uri="",
mint=mint,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=provider.derive_associated_bonding_curve(
mint, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM
),
creator=creator,
creator_vault=provider.derive_creator_vault(creator),
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
is_mayhem_mode=mayhem,
quote_mint=quote_mint,
)
def check_derivations(quote_mint: Pubkey, *, mayhem: bool) -> list[str]:
"""Independently recompute every derived v2 account and compare.
Args:
quote_mint: Quote mint to pair the coin against
mayhem: Whether the coin is in mayhem mode
Returns:
List of mismatches (empty if all derivations agree)
"""
provider = PumpFunAddressProvider()
token_info = build_token_info(quote_mint, mayhem=mayhem)
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
accounts = provider.get_buy_v2_instruction_accounts(token_info, user)
pump = PumpFunAddresses.PROGRAM
fee_program = PumpFunAddresses.FEE_PROGRAM
quote_program = SystemAddresses.TOKEN_PROGRAM
base_program = SystemAddresses.TOKEN_2022_PROGRAM
mint = token_info.mint
bonding_curve = token_info.bonding_curve
creator_vault = token_info.creator_vault
uva, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)], pump
)
expected = {
"bonding_curve": Pubkey.find_program_address(
[b"bonding-curve", bytes(mint)], pump
)[0],
"creator_vault": Pubkey.find_program_address(
[b"creator-vault", bytes(token_info.creator)], pump
)[0],
"sharing_config": Pubkey.find_program_address(
[b"sharing-config", bytes(mint)], fee_program
)[0],
"global_volume_accumulator": Pubkey.find_program_address(
[b"global_volume_accumulator"], pump
)[0],
"user_volume_accumulator": uva,
"fee_config": Pubkey.find_program_address(
[b"fee_config", bytes(pump)], fee_program
)[0],
"event_authority": Pubkey.find_program_address([b"__event_authority"], pump)[0],
"associated_base_bonding_curve": get_associated_token_address(
bonding_curve, mint, base_program
),
"associated_quote_bonding_curve": get_associated_token_address(
bonding_curve, quote_mint, quote_program
),
"associated_base_user": get_associated_token_address(user, mint, base_program),
"associated_quote_user": get_associated_token_address(
user, quote_mint, quote_program
),
"associated_creator_vault": get_associated_token_address(
creator_vault, quote_mint, quote_program
),
"associated_quote_fee_recipient": get_associated_token_address(
accounts["fee_recipient"], quote_mint, quote_program
),
"associated_quote_buyback_fee_recipient": get_associated_token_address(
accounts["buyback_fee_recipient"], quote_mint, quote_program
),
"associated_user_volume_accumulator": get_associated_token_address(
uva, quote_mint, quote_program
),
"quote_mint": quote_mint,
"base_mint": mint,
"user": user,
"program": pump,
"fee_program": fee_program,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"associated_token_program": SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
"base_token_program": base_program,
"quote_token_program": quote_program,
"global": PumpFunAddresses.GLOBAL,
}
problems = [
f"{key}: provider={accounts[key]} != expected={value}"
for key, value in expected.items()
if accounts[key] != value
]
# The fee recipient must come from the right documented set.
recipient_set = (
PumpFunAddresses.RESERVED_FEE_RECIPIENTS
if mayhem
else PumpFunAddresses.NORMAL_FEE_RECIPIENTS
)
if accounts["fee_recipient"] not in recipient_set:
problems.append(
f"fee_recipient {accounts['fee_recipient']} not in "
f"{'reserved' if mayhem else 'normal'} fee recipient set"
)
if accounts["buyback_fee_recipient"] not in PumpFunAddresses.BUYBACK_FEE_RECIPIENTS:
problems.append(
f"buyback_fee_recipient {accounts['buyback_fee_recipient']} not in "
f"buyback fee recipient set"
)
# Every account must be distinct except where the program expects reuse.
if len(set(accounts.values())) != len(accounts):
duplicates = [
key
for key, value in accounts.items()
if list(accounts.values()).count(value) > 1
]
problems.append(f"duplicate account addresses for: {sorted(duplicates)}")
return problems
def check_instruction_encoding() -> list[str]:
"""Build real v2 instructions and assert their shape and data encoding.
Covers the USDC path, which cannot be exercised on-chain until a
USDC-paired coin exists.
Returns:
List of problems (empty if the instructions are well-formed)
"""
import asyncio
import struct
from interfaces.core import Platform as _Platform # noqa: F401
from platforms.pumpfun.instruction_builder import PumpFunInstructionBuilder
from utils.idl_manager import get_idl_manager
provider = PumpFunAddressProvider()
builder = PumpFunInstructionBuilder(get_idl_manager().get_parser(Platform.PUMP_FUN))
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
problems = []
for quote_mint, label, expect_quote_ata in (
(WSOL_MINT, "SOL-paired", False),
(USDC_MINT, "USDC-paired", True),
):
token_info = build_token_info(quote_mint, mayhem=False)
buy = asyncio.run(
builder.build_buy_v2_instruction(
token_info, user, 1_500_000, 20_000_000, provider
)
)
sell = asyncio.run(
builder.build_sell_v2_instruction(
token_info, user, 20_000_000, 900_000, provider
)
)
# Instruction counts: base ATA always, quote ATA only for non-SOL.
expected_buy_ix = 3 if expect_quote_ata else 2
if len(buy) != expected_buy_ix:
problems.append(
f"{label}: buy produced {len(buy)} instructions, "
f"expected {expected_buy_ix}"
)
expected_sell_ix = 2 if expect_quote_ata else 1
if len(sell) != expected_sell_ix:
problems.append(
f"{label}: sell produced {len(sell)} instructions, "
f"expected {expected_sell_ix}"
)
if len(buy[-1].accounts) != 27:
problems.append(
f"{label}: buy_v2 has {len(buy[-1].accounts)} accounts != 27"
)
if len(sell[-1].accounts) != 26:
problems.append(
f"{label}: sell_v2 has {len(sell[-1].accounts)} accounts != 26"
)
# buy_v2 data: 8-byte discriminator + amount (tokens) + max_sol_cost.
# No trailing track_volume OptionBool, unlike the legacy buy.
buy_data = bytes(buy[-1].data)
if len(buy_data) != 24:
problems.append(
f"{label}: buy_v2 data is {len(buy_data)} bytes, expected 24 "
f"(discriminator + 2 u64, no track_volume)"
)
else:
amount, max_cost = struct.unpack("<QQ", buy_data[8:])
if amount != 20_000_000 or max_cost != 1_500_000:
problems.append(
f"{label}: buy_v2 args decoded as amount={amount}, "
f"max_sol_cost={max_cost}; expected 20000000 and 1500000"
)
sell_data = bytes(sell[-1].data)
if len(sell_data) != 24:
problems.append(
f"{label}: sell_v2 data is {len(sell_data)} bytes, expected 24"
)
else:
amount, min_out = struct.unpack("<QQ", sell_data[8:])
if amount != 20_000_000 or min_out != 900_000:
problems.append(
f"{label}: sell_v2 args decoded as amount={amount}, "
f"min_sol_output={min_out}; expected 20000000 and 900000"
)
# Exactly one signer, and it must be the user.
signers = [meta.pubkey for meta in buy[-1].accounts if meta.is_signer]
if signers != [user]:
problems.append(f"{label}: buy_v2 signers {signers} != [{user}]")
return problems
def check_quote_config() -> list[str]:
"""Assert quote-amount config resolution and alias handling.
Returns:
List of problems (empty if config resolution behaves correctly)
"""
from core.pubkeys import resolve_quote_amounts, resolve_quote_mint
from trading.universal_trader import _resolve_quote_config
problems = []
if resolve_quote_mint("usdc") != USDC_MINT:
problems.append("alias 'usdc' did not resolve to the USDC mint")
if resolve_quote_mint("sol") != WSOL_MINT:
problems.append("alias 'sol' did not resolve to wrapped SOL")
if resolve_quote_mint(str(USDC_MINT)) != USDC_MINT:
problems.append("raw USDC mint string did not resolve")
try:
resolve_quote_amounts({"usdc": 0})
problems.append("resolve_quote_amounts accepted a zero amount")
except ValueError:
pass
try:
resolve_quote_mint("not-a-mint")
problems.append("resolve_quote_mint accepted an invalid mint")
except ValueError:
pass
# SOL always present from buy_amount; USDC only when configured.
amounts, allowed = _resolve_quote_config(0.0001, None, None)
if amounts.get(WSOL_MINT) != 0.0001:
problems.append("SOL amount did not fall back to buy_amount")
if USDC_MINT in amounts:
problems.append("USDC present in amounts without being configured")
if allowed is not None:
problems.append("allowed_quote_mints should be None when unset")
amounts, allowed = _resolve_quote_config(0.0001, {"usdc": 5.0}, ["sol", "usdc"])
if amounts.get(USDC_MINT) != 5.0:
problems.append("configured USDC amount not resolved")
if allowed != {WSOL_MINT, USDC_MINT}:
problems.append(f"allowed_quote_mints resolved to {allowed}")
return problems
def check_examples_toolkit() -> list[str]:
"""Check learning-examples/pump_v2.py agrees with the IDL and with src/.
The examples carry their own standalone copy of the v2 layout so they stay
readable without importing src/. That copy is exactly the kind of thing that
silently drifts, so diff it against both sources of truth.
Returns:
List of problems (empty if the toolkit agrees)
"""
import pump_v2
from platforms.pumpfun.address_provider import PumpFunAddresses
problems = []
user = Pubkey.from_string("Ba99j1dYxidfQZvuNGMaXGxJsUeWXu6VNW8damkrdLVd")
mint = Pubkey.from_string("CU7nUQaJ4beyYjC3xAUrh5RiSjw14fhU6oWTwRBse8gj")
creator = Pubkey.from_string("5wyFsNExysbXf2hTtcn8Tqd3urs9Nv85Zx1zNdAfTMmX")
# Fee recipient sets must match src/ exactly.
for label, theirs, ours in (
(
"normal",
pump_v2.NORMAL_FEE_RECIPIENTS,
PumpFunAddresses.NORMAL_FEE_RECIPIENTS,
),
(
"reserved",
pump_v2.RESERVED_FEE_RECIPIENTS,
PumpFunAddresses.RESERVED_FEE_RECIPIENTS,
),
(
"buyback",
pump_v2.BUYBACK_FEE_RECIPIENTS,
PumpFunAddresses.BUYBACK_FEE_RECIPIENTS,
),
):
if theirs != ours:
problems.append(f"pump_v2 {label} fee recipients differ from src/")
# Discriminators must match the IDL.
idl = json.loads(IDL_PATH.read_text())
by_name = {i["name"]: i for i in idl["instructions"]}
for name, disc in (
("buy_v2", pump_v2.BUY_V2_DISCRIMINATOR),
("sell_v2", pump_v2.SELL_V2_DISCRIMINATOR),
):
expected = bytes(by_name[name]["discriminator"])
if disc != expected:
problems.append(
f"pump_v2 {name} discriminator {list(disc)} != IDL {list(expected)}"
)
# Account lists must match the IDL in order and writability, for both
# quote assets and both mayhem states.
for quote_mint, label in (
(pump_v2.WSOL_MINT, "SOL"),
(pump_v2.USDC_MINT, "USDC"),
):
for mayhem in (False, True):
for name, builder in (
("buy_v2", pump_v2.build_buy_v2_instruction),
("sell_v2", pump_v2.build_sell_v2_instruction),
):
kwargs = {
"base_mint": mint,
"creator": creator,
"user": user,
"quote_mint": quote_mint,
"is_mayhem_mode": mayhem,
}
if name == "buy_v2":
instruction = builder(
token_amount_raw=1, max_quote_cost_raw=2, **kwargs
)
else:
instruction = builder(
token_amount_raw=1, min_quote_output_raw=2, **kwargs
)
idl_accounts = by_name[name]["accounts"]
if len(instruction.accounts) != len(idl_accounts):
problems.append(
f"pump_v2 {name} ({label}, mayhem={mayhem}): "
f"{len(instruction.accounts)} accounts != IDL "
f"{len(idl_accounts)}"
)
continue
for index, (meta, idl_account) in enumerate(
zip(instruction.accounts, idl_accounts), start=1
):
if meta.is_writable != bool(idl_account.get("writable")):
problems.append(
f"pump_v2 {name}[{index}] {idl_account['name']} "
f"({label}, mayhem={mayhem}): writable "
f"{meta.is_writable} != IDL "
f"{bool(idl_account.get('writable'))}"
)
# Cross-check every address against the src/ provider, which the
# checks above already validated.
provider = PumpFunAddressProvider()
token_info = build_token_info(quote_mint, mayhem=mayhem)
resolved = (
provider.get_buy_v2_instruction_accounts(token_info, user)
if name == "buy_v2"
else provider.get_sell_v2_instruction_accounts(token_info, user)
)
layout = _BUY_V2_ACCOUNTS if name == "buy_v2" else _SELL_V2_ACCOUNTS
for (account_name, _), meta in zip(layout, instruction.accounts):
# Fee recipients are picked at random from a set, so compare
# membership rather than identity.
if "fee_recipient" in account_name:
continue
if resolved[account_name] != meta.pubkey:
problems.append(
f"pump_v2 {name} {account_name} ({label}, "
f"mayhem={mayhem}): {meta.pubkey} != src/ "
f"{resolved[account_name]}"
)
return problems
def main() -> int:
"""Run all layout and derivation checks.
Returns:
Process exit code (0 on success)
"""
all_problems = []
for name, layout in (("buy_v2", _BUY_V2_ACCOUNTS), ("sell_v2", _SELL_V2_ACCOUNTS)):
problems = check_layout(name, layout)
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"{name}: {len(layout)} accounts vs IDL -> {status}")
all_problems.extend(problems)
for quote_mint, label in ((WSOL_MINT, "SOL-paired"), (USDC_MINT, "USDC-paired")):
for mayhem in (False, True):
problems = check_derivations(quote_mint, mayhem=mayhem)
tag = f"{label}, mayhem={mayhem}"
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"derivations ({tag}) -> {status}")
all_problems.extend(problems)
for name, check in (
("instruction encoding", check_instruction_encoding),
("quote config resolution", check_quote_config),
("learning-examples pump_v2 toolkit", check_examples_toolkit),
):
problems = check()
status = "OK" if not problems else f"{len(problems)} PROBLEM(S)"
print(f"{name} -> {status}")
all_problems.extend(problems)
if all_problems:
print("\nProblems found:")
for problem in all_problems:
print(f" - {problem}")
return 1
print("\nAll v2 account layouts and derivations match the IDL.")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -98,6 +98,9 @@ async def start_bot(config_path: str):
# Extreme fast mode settings
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts=cfg["trade"].get("quote_amounts"),
allowed_quote_mints=cfg["filters"].get("allowed_quote_mints"),
# Exit strategy configuration
exit_strategy=cfg["trade"].get("exit_strategy", "time_based"),
take_profit_percentage=cfg["trade"].get("take_profit_percentage"),
+10 -1
View File
@@ -69,7 +69,16 @@ class AccountCleanupManager:
balance = await self.client.get_token_account_balance(ata)
instructions = []
if balance > 0 and self.close_with_force_burn:
# Never burn wrapped SOL: closing a WSOL account already returns
# both the wrapped lamports and the rent to the owner, so burning
# first would destroy real value.
if balance > 0 and mint == SystemAddresses.WSOL_MINT:
logger.info(
f"Unwrapping {balance} lamports of wrapped SOL from {ata} "
f"by closing it (burn skipped)"
)
elif balance > 0 and self.close_with_force_burn:
logger.info(
f"Burning {balance} tokens from ATA {ata} (mint: {mint})..."
)
+33
View File
@@ -188,6 +188,10 @@ def validate_config(config: dict) -> None:
if "Missing required config key" not in str(e):
raise
# Quote-asset configuration must resolve before the bot starts trading,
# otherwise a bad mint alias only surfaces on the first non-SOL coin.
validate_quote_config(config)
# Platform-specific validation
platform_str = config.get("platform", "pump_fun")
try:
@@ -201,6 +205,35 @@ def validate_config(config: dict) -> None:
raise
def validate_quote_config(config: dict) -> None:
"""Validate trade.quote_amounts and filters.allowed_quote_mints.
Args:
config: Loaded bot configuration
Raises:
ValueError: If a quote mint alias/address or amount is invalid
"""
from core.pubkeys import resolve_quote_amounts, resolve_quote_mint
quote_amounts = config.get("trade", {}).get("quote_amounts")
if quote_amounts is not None:
if not isinstance(quote_amounts, dict):
raise ValueError(
"trade.quote_amounts must be a mapping of quote mint to amount"
)
resolve_quote_amounts(quote_amounts)
allowed = config.get("filters", {}).get("allowed_quote_mints")
if allowed is not None:
if not isinstance(allowed, list) or not allowed:
raise ValueError(
"filters.allowed_quote_mints must be a non-empty list of quote mints"
)
for mint in allowed:
resolve_quote_mint(mint)
def validate_platform_config(config: dict, platform: Platform) -> None:
"""Validate platform-specific configuration requirements."""
# Check if platform is supported
+70 -39
View File
@@ -19,6 +19,7 @@ from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from core.pubkeys import is_sol_paired
from core.rpc_rate_limiter import TokenBucketRateLimiter
from utils.logger import get_logger
@@ -359,21 +360,30 @@ class SolanaClient:
return None
async def get_buy_transaction_details(
self, signature: str, mint: Pubkey, sol_destination: Pubkey
self,
signature: str,
mint: Pubkey,
sol_destination: Pubkey,
quote_mint: Pubkey | None = None,
) -> tuple[int | None, int | None]:
"""Get actual tokens received and SOL spent from a buy transaction.
"""Get actual tokens received and quote spent from a buy transaction.
Uses preBalances/postBalances to find exact SOL transferred to the
pool/curve and pre/post token balance diff to find tokens received.
For coins paired against an SPL quote asset (e.g. USDC) the quote spend
does not show up in lamport balances, so it is read from the quote
mint's token balance deltas instead.
Args:
signature: Transaction signature
mint: Token mint address
sol_destination: Address where SOL is sent (bonding curve for pump.fun,
quote_vault for letsbonk)
quote_mint: Quote mint of the coin. Pass None or wrapped SOL for
SOL-paired coins.
Returns:
Tuple of (tokens_received_raw, sol_spent_lamports), or (None, None)
Tuple of (tokens_received_raw, quote_spent_raw), or (None, None)
"""
result = await self._get_transaction_result(signature)
if not result:
@@ -384,47 +394,29 @@ class SolanaClient:
# Check for transaction execution errors (e.g., MaxLoadedAccountsDataSizeExceeded)
tx_err = meta.get("err")
if tx_err:
logger.error(
f"Transaction {signature[:16]}... failed with error: {tx_err}"
)
logger.error(f"Transaction {signature[:16]}... failed with error: {tx_err}")
return None, None
mint_str = str(mint)
# Get tokens received from pre/post token balance diff
# This works for Token2022 where owner might be different
tokens_received = None
pre_token_balances = meta.get("preTokenBalances", [])
post_token_balances = meta.get("postTokenBalances", [])
tokens_received = self._extract_positive_token_diff(meta, str(mint))
if tokens_received is not None:
logger.info(f"Tokens received from tx: {tokens_received}")
# Build lookup by account index
pre_by_idx = {b.get("accountIndex"): b for b in pre_token_balances}
post_by_idx = {b.get("accountIndex"): b for b in post_token_balances}
# Find positive token diff for our mint (user receiving tokens)
all_indices = set(pre_by_idx.keys()) | set(post_by_idx.keys())
for idx in all_indices:
pre = pre_by_idx.get(idx)
post = post_by_idx.get(idx)
# Check if this is our mint
balance_mint = (post or pre).get("mint", "")
if balance_mint != mint_str:
continue
pre_amount = (
int(pre.get("uiTokenAmount", {}).get("amount", 0)) if pre else 0
)
post_amount = (
int(post.get("uiTokenAmount", {}).get("amount", 0)) if post else 0
)
diff = post_amount - pre_amount
# Positive diff means tokens received (not the bonding curve's negative)
if diff > 0:
tokens_received = diff
logger.info(f"Tokens received from tx: {tokens_received}")
break
# Non-SOL quote assets move as SPL token transfers, so the lamport
# deltas below would report only rent/fees. Read the quote spend from
# the quote mint's token balance deltas: the positive diff is the
# curve's quote vault receiving what the buyer paid.
if quote_mint is not None and not is_sol_paired(quote_mint):
quote_spent = self._extract_positive_token_diff(meta, str(quote_mint))
if quote_spent is None:
logger.warning(
f"No positive {quote_mint} balance diff found in tx "
f"{signature[:16]}...; cannot determine quote spent"
)
else:
logger.info(f"Quote spent from tx: {quote_spent} (mint {quote_mint})")
return tokens_received, quote_spent
# Get SOL spent from preBalances/postBalances at sol_destination
sol_destination_str = str(sol_destination)
@@ -451,6 +443,45 @@ class SolanaClient:
return tokens_received, sol_spent
@staticmethod
def _extract_positive_token_diff(meta: dict, mint_str: str) -> int | None:
"""Find the largest positive token balance change for a mint in a tx.
Args:
meta: Transaction meta containing pre/postTokenBalances
mint_str: Mint address to look for
Returns:
Raw positive balance delta, or None if no account gained this mint
"""
pre_by_idx = {
b.get("accountIndex"): b for b in meta.get("preTokenBalances", [])
}
post_by_idx = {
b.get("accountIndex"): b for b in meta.get("postTokenBalances", [])
}
best: int | None = None
for idx in set(pre_by_idx) | set(post_by_idx):
pre = pre_by_idx.get(idx)
post = post_by_idx.get(idx)
if (post or pre).get("mint", "") != mint_str:
continue
pre_amount = (
int(pre.get("uiTokenAmount", {}).get("amount", 0)) if pre else 0
)
post_amount = (
int(post.get("uiTokenAmount", {}).get("amount", 0)) if post else 0
)
diff = post_amount - pre_amount
if diff > 0 and (best is None or diff > best):
best = diff
return best
async def _get_transaction_result(self, signature: str) -> dict | None:
"""Fetch transaction result from RPC.
+158
View File
@@ -38,6 +38,161 @@ SOL_MINT: Final[Pubkey] = Pubkey.from_string(
"So11111111111111111111111111111111111111112"
)
# Quote mints supported by pump.fun's v2 trade instructions.
# `bonding_curve.quote_mint` is Pubkey::default() (all zeros) for SOL-paired
# coins; the v2 instructions still expect wrapped SOL to be passed explicitly.
# Doc: pump-public-docs README, "New Bonding Curve Trade Instructions".
DEFAULT_PUBKEY: Final[Pubkey] = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT: Final[Pubkey] = SOL_MINT
USDC_MINT: Final[Pubkey] = Pubkey.from_string(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
)
# Decimals per quote mint. SOL uses 9, USDC uses 6.
QUOTE_DECIMALS: Final[dict[Pubkey, int]] = {
WSOL_MINT: 9,
USDC_MINT: 6,
}
# Token program that owns each quote mint. Both current quote mints are
# legacy SPL Token, but the v2 instructions take quote_token_program
# separately from base_token_program, so keep them decoupled.
QUOTE_TOKEN_PROGRAMS: Final[dict[Pubkey, Pubkey]] = {
WSOL_MINT: TOKEN_PROGRAM,
USDC_MINT: TOKEN_PROGRAM,
}
def quote_decimals(quote_mint: Pubkey) -> int:
"""Get the decimal count for a quote mint, defaulting to SOL's 9.
Args:
quote_mint: Quote mint address
Returns:
Number of decimals used by the quote mint
"""
return QUOTE_DECIMALS.get(quote_mint, 9)
def quote_units_per_token(quote_mint: Pubkey) -> int:
"""Get the raw-units-per-whole-unit factor for a quote mint.
Args:
quote_mint: Quote mint address
Returns:
10 ** decimals for the quote mint (1e9 for SOL, 1e6 for USDC)
"""
return 10 ** quote_decimals(quote_mint)
def quote_token_program(quote_mint: Pubkey) -> Pubkey:
"""Get the token program owning a quote mint, defaulting to SPL Token.
Args:
quote_mint: Quote mint address
Returns:
Token program id for the quote mint
"""
return QUOTE_TOKEN_PROGRAMS.get(quote_mint, TOKEN_PROGRAM)
def normalize_quote_mint(quote_mint: Pubkey | None) -> Pubkey:
"""Resolve a bonding curve's quote_mint into a concrete mint address.
SOL-paired coins carry Pubkey::default() on-chain but must be traded with
wrapped SOL passed as the quote mint.
Args:
quote_mint: Raw quote_mint from the bonding curve, or None
Returns:
Wrapped SOL for SOL-paired coins, otherwise the quote mint unchanged
"""
if quote_mint is None or quote_mint == DEFAULT_PUBKEY:
return WSOL_MINT
return quote_mint
# Friendly aliases accepted in bot YAML so configs don't need raw mints.
QUOTE_MINT_ALIASES: Final[dict[str, Pubkey]] = {
"sol": WSOL_MINT,
"wsol": WSOL_MINT,
"usdc": USDC_MINT,
}
def resolve_quote_mint(value: str | Pubkey) -> Pubkey:
"""Resolve a config value into a quote mint address.
Accepts the aliases "sol"/"wsol"/"usdc" or a raw base58 mint address.
Args:
value: Alias or mint address from configuration
Returns:
Resolved quote mint
Raises:
ValueError: If the value is neither a known alias nor a valid address
"""
if isinstance(value, Pubkey):
return normalize_quote_mint(value)
alias = QUOTE_MINT_ALIASES.get(str(value).strip().lower())
if alias is not None:
return alias
try:
return normalize_quote_mint(Pubkey.from_string(str(value)))
except (ValueError, TypeError) as exc:
raise ValueError(
f"Unknown quote mint {value!r}. Use one of "
f"{sorted(QUOTE_MINT_ALIASES)} or a base58 mint address."
) from exc
def resolve_quote_amounts(
amounts: dict[str, float] | None,
) -> dict[Pubkey, float]:
"""Resolve a config map of quote mint -> spend amount.
Args:
amounts: Mapping of alias/mint address to amount in whole quote units
Returns:
Mapping keyed by resolved Pubkey (empty if amounts is None)
Raises:
ValueError: If a key is not a valid quote mint or an amount is not positive
"""
if not amounts:
return {}
resolved: dict[Pubkey, float] = {}
for key, amount in amounts.items():
mint = resolve_quote_mint(key)
if not isinstance(amount, int | float) or amount <= 0:
raise ValueError(
f"quote_amounts[{key!r}] must be a positive number, got {amount!r}"
)
resolved[mint] = float(amount)
return resolved
def is_sol_paired(quote_mint: Pubkey | None) -> bool:
"""Check whether a coin is SOL-paired (native SOL transfers).
Args:
quote_mint: Raw or normalized quote mint
Returns:
True if the coin trades against native/wrapped SOL
"""
return normalize_quote_mint(quote_mint) == WSOL_MINT
class SystemAddresses:
"""System-level Solana addresses shared across all platforms."""
@@ -49,6 +204,9 @@ class SystemAddresses:
ASSOCIATED_TOKEN_PROGRAM = ASSOCIATED_TOKEN_PROGRAM
RENT = RENT
SOL_MINT = SOL_MINT
WSOL_MINT = WSOL_MINT
USDC_MINT = USDC_MINT
DEFAULT_PUBKEY = DEFAULT_PUBKEY
@classmethod
def get_all_system_addresses(cls) -> dict[str, Pubkey]:
+7
View File
@@ -49,6 +49,13 @@ class TokenInfo:
is_mayhem_mode: bool = False # pump.fun mayhem mode flag
is_cashback_coin: bool = False # pump.fun cashback coin flag
# Quote asset (pump.fun v2 instructions). SOL-paired coins carry
# Pubkey::default() on-chain; normalize_quote_mint() maps that to wrapped
# SOL, which is what buy_v2/sell_v2 expect to be passed.
quote_mint: Pubkey | None = None
quote_token_program_id: Pubkey | None = None
virtual_quote_reserves: int | None = None
# Metadata
creation_timestamp: float | None = None
additional_data: dict[str, Any] | None = None
+9 -1
View File
@@ -18,6 +18,12 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Solana logsSubscribe / blockSubscribe frames routinely exceed the websockets
# library's 1 MiB default, which closes the connection with code 1009
# ("message too big"). Reconnecting recovers, but every dropped frame is a
# missed token, so raise the ceiling instead of eating the disconnects.
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class UniversalBlockListener(BaseTokenListener):
"""Universal block listener that works with any platform."""
@@ -97,7 +103,9 @@ class UniversalBlockListener(BaseTokenListener):
while True:
try:
async with websockets.connect(self.wss_endpoint) as websocket:
async with websockets.connect(
self.wss_endpoint, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
await self._subscribe_to_programs(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
+9 -1
View File
@@ -14,6 +14,12 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Solana logsSubscribe / blockSubscribe frames routinely exceed the websockets
# library's 1 MiB default, which closes the connection with code 1009
# ("message too big"). Reconnecting recovers, but every dropped frame is a
# missed token, so raise the ceiling instead of eating the disconnects.
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class UniversalLogsListener(BaseTokenListener):
"""Universal logs listener that works with any platform."""
@@ -96,7 +102,9 @@ class UniversalLogsListener(BaseTokenListener):
while True:
try:
async with websockets.connect(self.wss_endpoint) as websocket:
async with websockets.connect(
self.wss_endpoint, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
await self._subscribe_to_logs(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
+217 -10
View File
@@ -5,13 +5,14 @@ This module provides all pump.fun-specific addresses and PDA derivations
by implementing the AddressProvider interface.
"""
import secrets
from dataclasses import dataclass
from typing import ClassVar, Final
from solders.pubkey import Pubkey
from spl.token.instructions import get_associated_token_address
from core.pubkeys import SystemAddresses
from core.pubkeys import SystemAddresses, normalize_quote_mint, quote_token_program
from interfaces.core import AddressProvider, Platform, TokenInfo
@@ -43,10 +44,35 @@ class PumpFunAddresses:
FEE_PROGRAM: Final[Pubkey] = Pubkey.from_string(
"pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"
)
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
BREAKING_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
# 8 normal fee recipients — use one as fee_recipient for non-mayhem coins.
# See FEE_RECIPIENTS.md in the pump-fun public docs repository.
NORMAL_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV"),
Pubkey.from_string("7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"),
Pubkey.from_string("7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX"),
Pubkey.from_string("9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz"),
Pubkey.from_string("AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY"),
Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM"),
Pubkey.from_string("FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz"),
Pubkey.from_string("G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP"),
]
# 8 reserved fee recipients — use one as fee_recipient for mayhem coins.
RESERVED_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS"),
Pubkey.from_string("4budycTjhs9fD6xw62VBducVTNgMgJJ5BgtKq7mAZwn6"),
Pubkey.from_string("8SBKzEQU4nLSzcwF4a74F2iaUDQyTfjGndn6qUWBnrpR"),
Pubkey.from_string("4UQeTP1T39KZ9Sfxzo3WR5skgsaP6NZa87BAkuazLEKH"),
Pubkey.from_string("8sNeir4QsLsJdYpc9RZacohhK1Y5FLU3nC5LXgYB4aa6"),
Pubkey.from_string("Fh9HmeLNUMVCvejxCtCL2DbYaRyBFVJ5xrWkLnMH6fdk"),
Pubkey.from_string("463MEnMeGyJekNZFQSTUABBEbLnvMTALbT6ZmsxAbAdq"),
Pubkey.from_string("6AUH3WEHucYZyC61hqpqYUWVto5qA5hjHuNQ32GNnNxA"),
]
# 8 buyback fee recipients — one is required on every buy/sell, for every
# coin. On the legacy buy/sell these are appended (mutable) after
# bonding-curve-v2; on buy_v2/sell_v2 they are the buyback_fee_recipient
# account. Introduced by the 2026-04-28 program upgrade.
# See FEE_RECIPIENTS.md in the pump-fun public docs repository.
BUYBACK_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
@@ -56,16 +82,40 @@ class PumpFunAddresses:
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
# Back-compat alias for the pre-v2 naming used by the legacy buy/sell path.
BREAKING_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = BUYBACK_FEE_RECIPIENTS
@staticmethod
def pick_breaking_fee_recipient() -> Pubkey:
"""Pick one of the 8 breaking-upgrade fee recipients at random.
def pick_buyback_fee_recipient() -> Pubkey:
"""Pick one of the 8 buyback fee recipients at random.
Spreads load across recipients per pump.fun's recommendation.
"""
import random
return secrets.choice(PumpFunAddresses.BUYBACK_FEE_RECIPIENTS)
return random.choice(PumpFunAddresses.BREAKING_FEE_RECIPIENTS)
@staticmethod
def pick_breaking_fee_recipient() -> Pubkey:
"""Deprecated alias for :meth:`pick_buyback_fee_recipient`."""
return PumpFunAddresses.pick_buyback_fee_recipient()
@staticmethod
def find_sharing_config(base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA for a coin.
Mandatory on buy_v2/sell_v2. Lives under the pump fees program, not
the pump program.
Args:
base_mint: Base token mint address
Returns:
Pubkey of the derived sharing config account
"""
derived_address, _ = Pubkey.find_program_address(
[b"sharing-config", bytes(base_mint)],
PumpFunAddresses.FEE_PROGRAM,
)
return derived_address
@staticmethod
def find_global_volume_accumulator() -> Pubkey:
@@ -237,7 +287,10 @@ class PumpFunAddressProvider(AddressProvider):
return accounts
def derive_associated_bonding_curve(
self, mint: Pubkey, bonding_curve: Pubkey, token_program_id: Pubkey | None = None
self,
mint: Pubkey,
bonding_curve: Pubkey,
token_program_id: Pubkey | None = None,
) -> Pubkey:
"""Derive the associated bonding curve (ATA of bonding curve for the token).
@@ -314,6 +367,160 @@ class PumpFunAddressProvider(AddressProvider):
"""
return PumpFunAddresses.find_fee_config()
def derive_sharing_config(self, base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA for a coin.
Args:
base_mint: Base token mint address
Returns:
Sharing config address
"""
return PumpFunAddresses.find_sharing_config(base_mint)
def resolve_quote(self, token_info: TokenInfo) -> tuple[Pubkey, Pubkey]:
"""Resolve the quote mint and its token program for a coin.
SOL-paired coins store Pubkey::default() in bonding_curve.quote_mint
but must pass wrapped SOL to the v2 instructions.
Args:
token_info: Token information
Returns:
Tuple of (quote_mint, quote_token_program)
"""
quote_mint = normalize_quote_mint(token_info.quote_mint)
quote_program = token_info.quote_token_program_id or quote_token_program(
quote_mint
)
return quote_mint, quote_program
def derive_quote_token_account(
self, owner: Pubkey, quote_mint: Pubkey, quote_token_program_id: Pubkey
) -> Pubkey:
"""Derive an associated token account for the quote mint.
Args:
owner: Account that owns the ATA (may be a PDA)
quote_mint: Quote mint address
quote_token_program_id: Token program owning the quote mint
Returns:
Associated token account address
"""
return get_associated_token_address(owner, quote_mint, quote_token_program_id)
def _get_v2_common_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Build the account set shared by buy_v2 and sell_v2.
Both instructions take the same 26 accounts; buy_v2 additionally takes
global_volume_accumulator. All accounts are mandatory there are no
optional or conditional accounts on the v2 interface, regardless of
mayhem/cashback/quote-mint combination.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses keyed by IDL account name
"""
additional_accounts = self.get_additional_accounts(token_info)
base_mint = token_info.mint
base_token_program = (
token_info.token_program_id or SystemAddresses.TOKEN_2022_PROGRAM
)
quote_mint, quote_program = self.resolve_quote(token_info)
bonding_curve = additional_accounts.get(
"bonding_curve", token_info.bonding_curve
)
creator_vault = additional_accounts.get(
"creator_vault", token_info.creator_vault
)
fee_recipient = self.get_fee_recipient(token_info)
buyback_fee_recipient = PumpFunAddresses.pick_buyback_fee_recipient()
user_volume_accumulator = self.derive_user_volume_accumulator(user)
return {
"global": PumpFunAddresses.GLOBAL,
"base_mint": base_mint,
"quote_mint": quote_mint,
"base_token_program": base_token_program,
"quote_token_program": quote_program,
"associated_token_program": SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
"fee_recipient": fee_recipient,
"associated_quote_fee_recipient": self.derive_quote_token_account(
fee_recipient, quote_mint, quote_program
),
"buyback_fee_recipient": buyback_fee_recipient,
"associated_quote_buyback_fee_recipient": self.derive_quote_token_account(
buyback_fee_recipient, quote_mint, quote_program
),
"bonding_curve": bonding_curve,
"associated_base_bonding_curve": additional_accounts.get(
"associated_bonding_curve", token_info.associated_bonding_curve
),
"associated_quote_bonding_curve": self.derive_quote_token_account(
bonding_curve, quote_mint, quote_program
),
"user": user,
"associated_base_user": self.derive_user_token_account(
user, base_mint, base_token_program
),
"associated_quote_user": self.derive_quote_token_account(
user, quote_mint, quote_program
),
"creator_vault": creator_vault,
"associated_creator_vault": self.derive_quote_token_account(
creator_vault, quote_mint, quote_program
),
"sharing_config": self.derive_sharing_config(base_mint),
"user_volume_accumulator": user_volume_accumulator,
"associated_user_volume_accumulator": self.derive_quote_token_account(
user_volume_accumulator, quote_mint, quote_program
),
"fee_config": self.derive_fee_config(),
"fee_program": PumpFunAddresses.FEE_PROGRAM,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"event_authority": PumpFunAddresses.EVENT_AUTHORITY,
"program": PumpFunAddresses.PROGRAM,
}
def get_buy_v2_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all 27 accounts needed for a buy_v2 instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for the buy_v2 instruction
"""
accounts = self._get_v2_common_accounts(token_info, user)
accounts["global_volume_accumulator"] = self.derive_global_volume_accumulator()
return accounts
def get_sell_v2_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all 26 accounts needed for a sell_v2 instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for the sell_v2 instruction
"""
return self._get_v2_common_accounts(token_info, user)
def get_fee_recipient(self, token_info: TokenInfo) -> Pubkey:
"""Get the correct fee recipient based on mayhem mode.
+51 -22
View File
@@ -10,7 +10,13 @@ from typing import Any
from solders.pubkey import Pubkey
from core.client import SolanaClient
from core.pubkeys import LAMPORTS_PER_SOL, TOKEN_DECIMALS
from core.pubkeys import (
LAMPORTS_PER_SOL,
TOKEN_DECIMALS,
is_sol_paired,
normalize_quote_mint,
quote_units_per_token,
)
from interfaces.core import CurveManager, Platform
from utils.idl_parser import IDLParser
from utils.logger import get_logger
@@ -75,20 +81,17 @@ class PumpFunCurveManager(CurveManager):
pool_address: Address of the bonding curve
Returns:
Current token price in SOL
Current token price denominated in the curve's quote asset
(SOL for SOL-paired coins, USDC for USDC-paired coins)
"""
pool_state = await self.get_pool_state(pool_address)
# Use virtual reserves for price calculation
virtual_token_reserves = pool_state["virtual_token_reserves"]
virtual_sol_reserves = pool_state["virtual_sol_reserves"]
if virtual_token_reserves <= 0:
if pool_state["virtual_token_reserves"] <= 0:
return 0.0
# Price = sol_reserves / token_reserves
price_lamports = virtual_sol_reserves / virtual_token_reserves
return price_lamports * (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
# _decode_curve_state_with_idl already scales by the quote mint's
# decimals, so don't re-derive the price with a hardcoded 1e9 here.
return pool_state["price_per_token"]
async def calculate_buy_amount_out(
self, pool_address: Pubkey, amount_in: int
@@ -185,51 +188,77 @@ class PumpFunCurveManager(CurveManager):
if not decoded_curve_state:
raise ValueError("Failed to decode bonding curve state with IDL parser")
# Extract the fields we need for trading calculations
# Based on the BondingCurve structure from the IDL
# Extract the fields we need for trading calculations.
# The BondingCurve struct renamed its SOL fields to quote fields when
# pump.fun added non-SOL quote assets, and appended quote_mint. The
# old names are kept as aliases below so callers written against the
# SOL-only layout keep working for SOL-paired coins.
raw_quote_mint = decoded_curve_state.get("quote_mint")
quote_mint = normalize_quote_mint(
Pubkey.from_string(raw_quote_mint)
if isinstance(raw_quote_mint, str)
else raw_quote_mint
)
quote_unit = quote_units_per_token(quote_mint)
curve_data = {
"virtual_token_reserves": decoded_curve_state.get(
"virtual_token_reserves", 0
),
"virtual_sol_reserves": decoded_curve_state.get("virtual_sol_reserves", 0),
"virtual_quote_reserves": decoded_curve_state.get(
"virtual_quote_reserves", 0
),
"real_token_reserves": decoded_curve_state.get("real_token_reserves", 0),
"real_sol_reserves": decoded_curve_state.get("real_sol_reserves", 0),
"real_quote_reserves": decoded_curve_state.get("real_quote_reserves", 0),
"token_total_supply": decoded_curve_state.get("token_total_supply", 0),
"complete": decoded_curve_state.get("complete", False),
"creator": decoded_curve_state.get("creator", ""),
"is_mayhem_mode": decoded_curve_state.get("is_mayhem_mode", False),
"is_cashback_coin": decoded_curve_state.get("is_cashback_coin", False),
"quote_mint": quote_mint,
"is_sol_paired": is_sol_paired(quote_mint),
}
# Back-compat aliases for the pre-quote-mint field names.
curve_data["virtual_sol_reserves"] = curve_data["virtual_quote_reserves"]
curve_data["real_sol_reserves"] = curve_data["real_quote_reserves"]
# Calculate additional metrics
# Validate reserves are positive before calculating price
if curve_data["virtual_token_reserves"] <= 0:
raise ValueError(
f"Invalid virtual_token_reserves: {curve_data['virtual_token_reserves']} - cannot calculate price"
)
if curve_data["virtual_sol_reserves"] <= 0:
if curve_data["virtual_quote_reserves"] <= 0:
raise ValueError(
f"Invalid virtual_sol_reserves: {curve_data['virtual_sol_reserves']} - cannot calculate price"
f"Invalid virtual_quote_reserves: {curve_data['virtual_quote_reserves']} - cannot calculate price"
)
# Price is denominated in the curve's quote asset, so scale by that
# mint's decimals (1e9 for SOL, 1e6 for USDC) rather than assuming SOL.
curve_data["price_per_token"] = (
(curve_data["virtual_sol_reserves"] / curve_data["virtual_token_reserves"])
(
curve_data["virtual_quote_reserves"]
/ curve_data["virtual_token_reserves"]
)
* (10**TOKEN_DECIMALS)
/ LAMPORTS_PER_SOL
/ quote_unit
)
# Add convenience decimal fields
curve_data["token_reserves_decimal"] = (
curve_data["virtual_token_reserves"] / 10**TOKEN_DECIMALS
)
curve_data["sol_reserves_decimal"] = (
curve_data["virtual_sol_reserves"] / LAMPORTS_PER_SOL
curve_data["quote_reserves_decimal"] = (
curve_data["virtual_quote_reserves"] / quote_unit
)
curve_data["sol_reserves_decimal"] = curve_data["quote_reserves_decimal"]
logger.debug(
f"Decoded curve state: virtual_token_reserves={curve_data['virtual_token_reserves']}, "
f"virtual_sol_reserves={curve_data['virtual_sol_reserves']}, "
f"price={curve_data['price_per_token']:.8f} SOL"
f"virtual_quote_reserves={curve_data['virtual_quote_reserves']}, "
f"quote_mint={quote_mint}, "
f"price={curve_data['price_per_token']:.8f} quote/token"
)
return curve_data
+58 -1
View File
@@ -13,7 +13,11 @@ from typing import Any
from solders.pubkey import Pubkey
from solders.transaction import VersionedTransaction
from core.pubkeys import SystemAddresses
from core.pubkeys import (
SystemAddresses,
normalize_quote_mint,
quote_token_program,
)
from interfaces.core import EventParser, Platform, TokenInfo
from platforms.pumpfun.address_provider import PumpFunAddresses
from utils.idl_parser import IDLParser
@@ -21,6 +25,38 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# 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
# pump-fun public docs repository.
_CREATE_V2_QUOTE_MINT_ACCOUNT_INDEX = 16
# Length of a Solana public key in bytes.
PUBKEY_BYTE_LENGTH = 32
def _coerce_pubkey(value: object) -> Pubkey | None:
"""Coerce a decoded IDL pubkey field into a Pubkey.
Args:
value: Decoded field, which may be a str, Pubkey, bytes or None
Returns:
Pubkey, or None if the value cannot be interpreted
"""
if value is None:
return None
if isinstance(value, Pubkey):
return value
if isinstance(value, str):
try:
return Pubkey.from_string(value)
except (ValueError, TypeError):
return None
if isinstance(value, bytes | bytearray) and len(value) == PUBKEY_BYTE_LENGTH:
return Pubkey.from_bytes(bytes(value))
return None
class PumpFunEventParser(EventParser):
"""Pump.Fun implementation of EventParser interface with IDL-based event parsing."""
@@ -262,6 +298,14 @@ class PumpFunEventParser(EventParser):
f"✅ Successfully parsed CreateEvent for token: {fields.get('symbol', 'Unknown')}"
)
# CreateEvent gained quote_mint and virtual_quote_reserves
# when pump.fun added non-SOL quote assets. Carry them on
# TokenInfo so extreme_fast_mode — which skips the curve
# fetch — still knows which quote asset to trade against.
quote_mint = normalize_quote_mint(
_coerce_pubkey(fields.get("quote_mint"))
)
return TokenInfo(
name=fields["name"],
symbol=fields["symbol"],
@@ -276,6 +320,9 @@ class PumpFunEventParser(EventParser):
token_program_id=token_program_id,
is_mayhem_mode=fields.get("is_mayhem_mode", False),
is_cashback_coin=fields.get("is_cashback_enabled", False),
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
virtual_quote_reserves=fields.get("virtual_quote_reserves"),
creation_timestamp=monotonic(),
)
@@ -373,6 +420,14 @@ class PumpFunEventParser(EventParser):
else False
)
# create_v2 passes a non-native quote mint as optional remaining
# account 17 (index 16). Absent means the coin is SOL-paired.
quote_mint = normalize_quote_mint(
get_account_key(_CREATE_V2_QUOTE_MINT_ACCOUNT_INDEX)
if is_create_v2
else None
)
return TokenInfo(
name=args.get("name", ""),
symbol=args.get("symbol", ""),
@@ -387,6 +442,8 @@ class PumpFunEventParser(EventParser):
token_program_id=token_program_id,
is_mayhem_mode=bool(args.get("is_mayhem_mode", False)),
is_cashback_coin=is_cashback,
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
creation_timestamp=monotonic(),
)
+331 -7
View File
@@ -11,37 +11,119 @@ from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
from spl.token.instructions import create_idempotent_associated_token_account
from core.pubkeys import TOKEN_DECIMALS
from core.pubkeys import TOKEN_DECIMALS, is_sol_paired
from interfaces.core import AddressProvider, InstructionBuilder, Platform, TokenInfo
from utils.idl_parser import IDLParser
from utils.logger import get_logger
logger = get_logger(__name__)
# Account order for buy_v2 (27 accounts) and sell_v2 (26 accounts).
# Every account is mandatory and the order is identical for every coin type —
# SOL-paired or USDC-paired, mayhem or not, cashback or not. This is the whole
# point of the v2 interface. See BUY.md and SELL.md under docs/instructions in
# the pump-fun public docs repository.
_BUY_V2_ACCOUNTS: list[tuple[str, bool]] = [
("global", False),
("base_mint", False),
("quote_mint", False),
("base_token_program", False),
("quote_token_program", False),
("associated_token_program", False),
("fee_recipient", True),
("associated_quote_fee_recipient", True),
("buyback_fee_recipient", True),
("associated_quote_buyback_fee_recipient", True),
("bonding_curve", True),
("associated_base_bonding_curve", True),
("associated_quote_bonding_curve", True),
("user", True),
("associated_base_user", True),
("associated_quote_user", True),
("creator_vault", True),
("associated_creator_vault", True),
("sharing_config", False),
("global_volume_accumulator", False),
("user_volume_accumulator", True),
("associated_user_volume_accumulator", True),
("fee_config", False),
("fee_program", False),
("system_program", False),
("event_authority", False),
("program", False),
]
# sell_v2 is buy_v2 without global_volume_accumulator.
_SELL_V2_ACCOUNTS: list[tuple[str, bool]] = [
entry for entry in _BUY_V2_ACCOUNTS if entry[0] != "global_volume_accumulator"
]
class PumpFunInstructionBuilder(InstructionBuilder):
"""Pump.Fun implementation of InstructionBuilder interface with IDL-based discriminators."""
def __init__(self, idl_parser: IDLParser):
def __init__(self, idl_parser: IDLParser, *, use_legacy_instructions: bool = False):
"""Initialize pump.fun instruction builder with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for pump.fun platform
use_legacy_instructions: Build the pre-v2 buy/sell instructions
instead of buy_v2/sell_v2. Legacy instructions cannot trade
coins paired with anything other than SOL.
"""
self._idl_parser = idl_parser
self._use_legacy_instructions = use_legacy_instructions
# Get discriminators from injected IDL parser
discriminators = self._idl_parser.get_instruction_discriminators()
self._buy_discriminator = discriminators["buy"]
self._sell_discriminator = discriminators["sell"]
self._buy_v2_discriminator = discriminators["buy_v2"]
self._sell_v2_discriminator = discriminators["sell_v2"]
logger.info("Pump.Fun instruction builder initialized with injected IDL parser")
logger.info(
"Pump.Fun instruction builder initialized with injected IDL parser "
f"(instruction set: {'legacy' if use_legacy_instructions else 'v2'})"
)
@property
def platform(self) -> Platform:
"""Get the platform this builder serves."""
return Platform.PUMP_FUN
@staticmethod
def _build_account_metas(
layout: list[tuple[str, bool]],
accounts_info: dict[str, Pubkey],
signer: Pubkey,
) -> list[AccountMeta]:
"""Turn a v2 account layout into ordered AccountMetas.
Args:
layout: Ordered (account name, is_writable) pairs
accounts_info: Resolved account addresses keyed by IDL name
signer: The account that signs the transaction
Returns:
Ordered list of AccountMeta
Raises:
KeyError: If the address provider did not supply a required account
"""
metas = []
for name, is_writable in layout:
pubkey = accounts_info[name]
if pubkey is None:
raise KeyError(f"Missing required account for v2 instruction: {name}")
metas.append(
AccountMeta(
pubkey=pubkey,
is_signer=pubkey == signer,
is_writable=is_writable,
)
)
return metas
async def build_buy_instruction(
self,
token_info: TokenInfo,
@@ -52,6 +134,169 @@ class PumpFunInstructionBuilder(InstructionBuilder):
) -> list[Instruction]:
"""Build buy instruction(s) for pump.fun.
Dispatches to buy_v2 unless the builder was constructed with
``use_legacy_instructions=True``.
Args:
token_info: Token information
user: User's wallet address
amount_in: Maximum quote amount to spend (raw quote units)
minimum_amount_out: Minimum tokens expected (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
if not self._use_legacy_instructions:
return await self.build_buy_v2_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
return await self.build_buy_legacy_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
async def build_buy_v2_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build a buy_v2 instruction plus the ATAs it needs.
Args:
token_info: Token information
user: User's wallet address
amount_in: Maximum quote amount to spend (raw quote units:
lamports for SOL-paired coins, 1e-6 USDC for USDC-paired)
minimum_amount_out: Base tokens to buy (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
instructions = []
accounts_info = address_provider.get_buy_v2_instruction_accounts(
token_info, user
)
# Base-token ATA for the buyer. buy_v2 does not create this for us.
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["base_mint"],
accounts_info["base_token_program"],
)
)
# Quote ATA for the buyer. For SOL-paired coins the program transfers
# native SOL and only seed-checks this account, so creating it would
# burn ~0.002 SOL of rent for nothing. Non-SOL quotes need a real,
# funded token account.
if not is_sol_paired(accounts_info["quote_mint"]):
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["quote_mint"],
accounts_info["quote_token_program"],
)
)
# buy_v2 args: amount (base tokens out), max_sol_cost (max quote in).
# Unlike legacy buy there is no track_volume OptionBool — volume
# tracking is unconditional now that user_volume_accumulator is
# mandatory.
instruction_data = (
self._buy_v2_discriminator
+ struct.pack("<Q", minimum_amount_out)
+ struct.pack("<Q", amount_in)
)
instructions.append(
Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=self._build_account_metas(
_BUY_V2_ACCOUNTS, accounts_info, user
),
)
)
return instructions
async def build_sell_v2_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build a sell_v2 instruction plus the ATAs it needs.
Args:
token_info: Token information
user: User's wallet address
amount_in: Base tokens to sell (raw token units)
minimum_amount_out: Minimum quote amount to receive (raw quote units)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
instructions = []
accounts_info = address_provider.get_sell_v2_instruction_accounts(
token_info, user
)
# Proceeds of a non-SOL sale land in the seller's quote ATA, which must
# exist. SOL-paired sales pay out in native SOL.
if not is_sol_paired(accounts_info["quote_mint"]):
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["quote_mint"],
accounts_info["quote_token_program"],
)
)
# sell_v2 args: amount (base tokens in), min_sol_output (min quote out).
instruction_data = (
self._sell_v2_discriminator
+ struct.pack("<Q", amount_in)
+ struct.pack("<Q", minimum_amount_out)
)
instructions.append(
Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=self._build_account_metas(
_SELL_V2_ACCOUNTS, accounts_info, user
),
)
)
return instructions
async def build_buy_legacy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build the pre-v2 18-account buy instruction.
Only works for SOL-paired coins.
Args:
token_info: Token information
user: User's wallet address
@@ -186,6 +431,39 @@ class PumpFunInstructionBuilder(InstructionBuilder):
) -> list[Instruction]:
"""Build sell instruction(s) for pump.fun.
Dispatches to sell_v2 unless the builder was constructed with
``use_legacy_instructions=True``.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of tokens to sell (raw token units)
minimum_amount_out: Minimum quote amount expected (raw quote units)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
if not self._use_legacy_instructions:
return await self.build_sell_v2_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
return await self.build_sell_legacy_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
async def build_sell_legacy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build the pre-v2 16/17-account sell instruction.
Only works for SOL-paired coins.
Args:
token_info: Token information
user: User's wallet address
@@ -318,6 +596,12 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Returns:
List of account addresses that will be accessed
"""
if not self._use_legacy_instructions:
accounts_info = address_provider.get_buy_v2_instruction_accounts(
token_info, user
)
return self._writable_accounts(_BUY_V2_ACCOUNTS, accounts_info)
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
return [
@@ -336,6 +620,28 @@ class PumpFunInstructionBuilder(InstructionBuilder):
accounts_info["breaking_fee_recipient"],
]
@staticmethod
def _writable_accounts(
layout: list[tuple[str, bool]], accounts_info: dict[str, Pubkey]
) -> list[Pubkey]:
"""Collect the writable accounts from a v2 layout.
getRecentPrioritizationFees is only meaningful for accounts that are
write-locked, so program ids and sysvars are dropped.
Args:
layout: Ordered (account name, is_writable) pairs
accounts_info: Resolved account addresses keyed by IDL name
Returns:
List of writable account addresses
"""
return [
accounts_info[name]
for name, is_writable in layout
if is_writable and accounts_info.get(name) is not None
]
def get_required_accounts_for_sell(
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
@@ -349,6 +655,12 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Returns:
List of account addresses that will be accessed
"""
if not self._use_legacy_instructions:
accounts_info = address_provider.get_sell_v2_instruction_accounts(
token_info, user
)
return self._writable_accounts(_SELL_V2_ACCOUNTS, accounts_info)
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
return [
@@ -398,8 +710,15 @@ class PumpFunInstructionBuilder(InstructionBuilder):
"""
if config_override is not None:
return config_override
# Buy operations: ATA creation + buy instruction
return 100_000
if self._use_legacy_instructions:
# Buy operations: ATA creation + buy instruction
return 100_000
# buy_v2 touches 27 accounts, so it costs more than the legacy
# 18-account buy. Mainnet simulation of a SOL-paired Token-2022 buy
# (including base ATA creation) consumed ~125k CU; a non-SOL quote adds
# another ATA init on top. Re-measure with
# learning-examples/simulate_v2_trades.py after any program upgrade.
return 180_000
def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int:
"""Get the recommended compute unit limit for pump.fun sell operations.
@@ -412,5 +731,10 @@ class PumpFunInstructionBuilder(InstructionBuilder):
"""
if config_override is not None:
return config_override
# Sell operations: typically just sell instruction (ATA exists)
return 60_000
if self._use_legacy_instructions:
# Sell operations: typically just sell instruction (ATA exists)
return 60_000
# sell_v2 touches 26 accounts. Measured at ~85k CU by simulating a
# buy+sell in one transaction on mainnet (the combined tx consumed
# ~211k against ~126k for the buy alone).
return 120_000
+208 -51
View File
@@ -9,7 +9,13 @@ from solders.pubkey import Pubkey
from core.client import SolanaClient
from core.priority_fee.manager import PriorityFeeManager
from core.pubkeys import LAMPORTS_PER_SOL, TOKEN_DECIMALS
from core.pubkeys import (
TOKEN_DECIMALS,
WSOL_MINT,
is_sol_paired,
normalize_quote_mint,
quote_units_per_token,
)
from core.wallet import Wallet
from interfaces.core import AddressProvider, Platform, TokenInfo
from platforms import get_platform_implementations
@@ -19,6 +25,79 @@ from utils.logger import get_logger
logger = get_logger(__name__)
def _quote_symbol(quote_mint: Pubkey) -> str:
"""Human-readable label for a quote mint, for logging only.
Args:
quote_mint: Quote mint address
Returns:
"SOL" for wrapped SOL, otherwise a truncated mint address
"""
if is_sol_paired(quote_mint):
return "SOL"
mint_str = str(quote_mint)
return f"{mint_str[:4]}..{mint_str[-4:]}"
async def _read_pool_state_with_retry(
curve_manager: object,
pool_address: Pubkey,
attempts: int = 4,
delay_seconds: float = 0.15,
) -> dict:
"""Read bonding curve state, retrying briefly on a lagging RPC node.
A freshly created curve may not be visible at `confirmed` yet, and a node
can momentarily serve a slot that predates it both surface as "account
not found". Reading at `processed` and retrying a few times costs a handful
of RPC calls, which is far cheaper than trading on stale account data.
Args:
curve_manager: Platform curve manager
pool_address: Bonding curve / pool address
attempts: How many reads to try before giving up
delay_seconds: Pause between attempts
Returns:
Decoded pool state
Raises:
Exception: The last read error if every attempt fails
"""
last_error: Exception | None = None
for _ in range(attempts):
try:
return await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
except Exception as error: # noqa: BLE001
last_error = error
await asyncio.sleep(delay_seconds)
raise last_error or RuntimeError("pool_state unavailable after retries")
def _refresh_quote_mint(token_info: TokenInfo, pool_state: dict) -> Pubkey:
"""Sync token_info's quote asset from freshly-read curve state.
Listeners do not all carry quote_mint (pumpportal carries none of the
per-coin flags), and the curve is authoritative, so prefer its value.
Args:
token_info: Token information, mutated in place
pool_state: Decoded bonding curve state
Returns:
The resolved quote mint
"""
quote_mint = normalize_quote_mint(
pool_state.get("quote_mint", token_info.quote_mint)
)
token_info.quote_mint = quote_mint
return quote_mint
class PlatformAwareBuyer(Trader):
"""Platform-aware token buyer that works with any supported platform."""
@@ -33,8 +112,25 @@ class PlatformAwareBuyer(Trader):
extreme_fast_token_amount: int = 0,
extreme_fast_mode: bool = False,
compute_units: dict | None = None,
quote_amounts: dict[Pubkey, float] | None = None,
):
"""Initialize platform-aware token buyer."""
"""Initialize platform-aware token buyer.
Args:
client: Solana RPC client
wallet: Trading wallet
priority_fee_manager: Priority fee strategy
amount: Amount of SOL to spend per buy on SOL-paired coins
slippage: Acceptable price deviation
max_retries: Transaction submission attempts
extreme_fast_token_amount: Tokens to buy when skipping price checks
extreme_fast_mode: Skip curve stabilization and price check
compute_units: Optional CU overrides
quote_amounts: Per-quote-mint spend amounts in whole quote units,
for coins paired against something other than SOL. A coin whose
quote mint is absent from this map is skipped rather than
traded with a SOL-denominated amount.
"""
self.client = client
self.wallet = wallet
self.priority_fee_manager = priority_fee_manager
@@ -44,6 +140,23 @@ class PlatformAwareBuyer(Trader):
self.extreme_fast_mode = extreme_fast_mode
self.extreme_fast_token_amount = extreme_fast_token_amount
self.compute_units = compute_units or {}
# SOL-paired coins always use `amount`; other quotes need an explicit
# per-mint amount because 0.0001 USDC and 0.0001 SOL are not comparable.
self.quote_amounts: dict[Pubkey, float] = {
WSOL_MINT: amount,
**(quote_amounts or {}),
}
def _resolve_quote_amount(self, quote_mint: Pubkey) -> float | None:
"""Get the configured spend amount for a quote mint.
Args:
quote_mint: Normalized quote mint
Returns:
Amount in whole quote units, or None if this quote is not configured
"""
return self.quote_amounts.get(quote_mint)
async def execute(self, token_info: TokenInfo) -> TradeResult:
"""Execute buy operation using platform-specific implementations."""
@@ -56,13 +169,13 @@ class PlatformAwareBuyer(Trader):
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
# Convert amount to lamports
amount_lamports = int(self.amount * LAMPORTS_PER_SOL)
# Quote asset is resolved from the curve below; start from whatever
# the listener gave us so extreme_fast_mode has a usable default.
quote_mint = normalize_quote_mint(token_info.quote_mint)
if self.extreme_fast_mode:
# Skip the wait and directly calculate the amount
token_amount = self.extreme_fast_token_amount
token_price_sol = self.amount / token_amount if token_amount > 0 else 0
# Skip the price check; the token count is fixed by config and
# sizing is finished once the quote amount is resolved below.
# Even in extreme_fast_mode, refresh mayhem/cashback/creator from
# chain — listeners (especially pumpportal) often don't carry
# these, and the program rejects with NotAuthorized (0x1770) /
@@ -71,34 +184,23 @@ class PlatformAwareBuyer(Trader):
# readable, so retry briefly. One handful of RPC calls is cheap
# relative to a failed buy.
try:
pool_address = self._get_pool_address(
token_info, address_provider
pool_address = self._get_pool_address(token_info, address_provider)
# Geyser/logs fire on processed, so the BC is typically
# readable in the same slot; pumpportal occasionally races
# the on-chain commit, hence the retries.
pool_state = await _read_pool_state_with_retry(
curve_manager, pool_address
)
pool_state = None
last_err: Exception | None = None
# Use processed commitment — geyser/logs fire on processed
# so the BC is typically readable in the same slot. Most
# listeners only need 1 attempt; pumpportal occasionally
# races the on-chain commit, so allow a few quick retries.
for attempt in range(4):
try:
pool_state = await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
break
except Exception as inner: # noqa: BLE001
last_err = inner
await asyncio.sleep(0.15)
if pool_state is None:
raise last_err or RuntimeError(
"pool_state unavailable after retries"
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
# The quote asset decides which balance we spend and how
# amounts are scaled, so it must come from the curve rather
# than a listener guess.
quote_mint = _refresh_quote_mint(token_info, pool_state)
fresh_creator = pool_state.get("creator")
if fresh_creator and hasattr(
address_provider, "derive_creator_vault"
@@ -142,20 +244,48 @@ class PlatformAwareBuyer(Trader):
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
token_amount = self.amount / token_price_sol
quote_mint = _refresh_quote_mint(token_info, pool_state)
# A coin paired against a quote asset we have no configured amount
# for cannot be traded — spending `amount` of it would be a
# different order of magnitude entirely.
quote_amount = self._resolve_quote_amount(quote_mint)
if quote_amount is None:
return TradeResult(
success=False,
platform=token_info.platform,
error_message=(
f"No configured buy amount for quote mint {quote_mint}; "
f"set trade.quote_amounts for this mint to trade it"
),
)
quote_unit = quote_units_per_token(quote_mint)
quote_label = _quote_symbol(quote_mint)
# Both branches need the resolved quote amount to finish sizing the
# trade: extreme_fast_mode fixes the token count and back-derives an
# implied price, while the regular path fixes the spend and derives
# the token count from the curve price.
if self.extreme_fast_mode:
token_amount = self.extreme_fast_token_amount
token_price_sol = quote_amount / token_amount if token_amount > 0 else 0
else:
token_amount = quote_amount / token_price_sol
# Calculate minimum token amount with slippage
minimum_token_amount = token_amount * (1 - self.slippage)
minimum_token_amount_raw = int(minimum_token_amount * 10**TOKEN_DECIMALS)
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + self.slippage))
# Calculate maximum quote to spend with slippage, in the quote
# mint's own raw units (lamports for SOL, 1e-6 for USDC).
max_quote_amount_raw = int(quote_amount * quote_unit * (1 + self.slippage))
# Build buy instructions using platform-specific builder
instructions = await instruction_builder.build_buy_instruction(
token_info,
self.wallet.pubkey,
max_amount_lamports, # amount_in (SOL)
max_quote_amount_raw, # amount_in (raw quote units)
minimum_token_amount_raw, # minimum_amount_out (tokens)
address_provider,
)
@@ -166,10 +296,12 @@ class PlatformAwareBuyer(Trader):
)
logger.info(
f"Buying {token_amount:.6f} tokens at {token_price_sol:.8f} SOL per token on {token_info.platform.value}"
f"Buying {token_amount:.6f} tokens at {token_price_sol:.8f} "
f"{quote_label} per token on {token_info.platform.value}"
)
logger.info(
f"Total cost: {self.amount:.6f} SOL (max: {max_amount_lamports / LAMPORTS_PER_SOL:.6f} SOL)"
f"Total cost: {quote_amount:.6f} {quote_label} "
f"(max: {max_quote_amount_raw / quote_unit:.6f} {quote_label})"
)
# Send transaction
@@ -199,27 +331,33 @@ class PlatformAwareBuyer(Trader):
sol_destination = self._get_sol_destination(
token_info, address_provider
)
tokens_raw, sol_spent = await self.client.get_buy_transaction_details(
str(tx_signature), token_info.mint, sol_destination
tokens_raw, quote_spent = await self.client.get_buy_transaction_details(
str(tx_signature),
token_info.mint,
sol_destination,
quote_mint=quote_mint,
)
if tokens_raw is not None and sol_spent is not None:
if tokens_raw is not None and quote_spent is not None:
actual_amount = tokens_raw / 10**TOKEN_DECIMALS
actual_price = (sol_spent / LAMPORTS_PER_SOL) / actual_amount
actual_price = (quote_spent / quote_unit) / actual_amount
logger.info(
f"Actual tokens received: {actual_amount:.6f} "
f"(expected: {token_amount:.6f})"
)
logger.info(
f"Actual SOL spent: {sol_spent / LAMPORTS_PER_SOL:.10f} SOL"
f"Actual {quote_label} spent: "
f"{quote_spent / quote_unit:.10f} {quote_label}"
)
logger.info(
f"Actual price: {actual_price:.10f} {quote_label}/token"
)
logger.info(f"Actual price: {actual_price:.10f} SOL/token")
token_amount = actual_amount
token_price_sol = actual_price
else:
raise ValueError(
f"Failed to parse transaction details: tokens={tokens_raw}, "
f"sol_spent={sol_spent} (tx: {tx_signature}). "
f"quote_spent={quote_spent} (tx: {tx_signature}). "
f"The transaction may have failed on-chain — check explorer."
)
@@ -369,6 +507,9 @@ class PlatformAwareSeller(Trader):
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
# Fall back to the listener's quote asset if the refresh below fails.
quote_mint = normalize_quote_mint(token_info.quote_mint)
# Refresh mayhem-mode and cashback flags from curve state.
# The sell account list is 16 (non-cashback) vs 17 (cashback), and
# fee_recipient differs in mayhem mode — both can change between
@@ -376,13 +517,20 @@ class PlatformAwareSeller(Trader):
# flags carried in token_info.
try:
pool_address = self._get_pool_address(token_info, address_provider)
pool_state = await curve_manager.get_pool_state(pool_address)
# Retry rather than reading once at `confirmed`: a node serving a
# slightly stale slot reports the curve as missing, and silently
# falling back to create-time values risks a wrong creator_vault
# (ConstraintSeeds 0x7d6) or wrong mayhem fee_recipient.
pool_state = await _read_pool_state_with_retry(
curve_manager, pool_address
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
quote_mint = _refresh_quote_mint(token_info, pool_state)
# Refresh creator/creator_vault from current BC state. Post
# 2026-04-28 the program may delegate BC.creator to a PFEE-owned
# PDA after the initial creator buy, so the create-time vault
@@ -408,13 +556,18 @@ class PlatformAwareSeller(Trader):
f"is_cashback_coin={token_info.is_cashback_coin}"
)
quote_unit = quote_units_per_token(quote_mint)
quote_label = _quote_symbol(quote_mint)
# Use pre-known amount and price (no RPC delay)
token_balance_decimal = token_amount
token_balance = int(token_amount * 10**TOKEN_DECIMALS)
token_price_sol = token_price
logger.info(f"Token balance: {token_balance_decimal:.6f}")
logger.info(f"Price per Token (from buy): {token_price_sol:.8f} SOL")
logger.info(
f"Price per Token (from buy): {token_price_sol:.8f} {quote_label}"
)
if token_balance == 0:
logger.info("No tokens to sell.")
@@ -424,19 +577,23 @@ class PlatformAwareSeller(Trader):
error_message="No tokens to sell",
)
# Calculate expected SOL output with slippage protection
expected_sol_output = token_balance_decimal * token_price_sol
min_sol_output = max(
# Calculate expected quote output with slippage protection, in the
# quote mint's raw units.
expected_quote_output = token_balance_decimal * token_price_sol
min_quote_output = max(
1,
int((expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL),
int((expected_quote_output * (1 - self.slippage)) * quote_unit),
)
logger.info(
f"Selling {token_balance_decimal} tokens on {token_info.platform.value}"
)
logger.info(f"Expected SOL output: {expected_sol_output:.10f} SOL")
logger.info(
f"Minimum SOL output (with {self.slippage * 100:.1f}% slippage): "
f"{min_sol_output / LAMPORTS_PER_SOL:.10f} SOL ({min_sol_output} lamports)"
f"Expected {quote_label} output: {expected_quote_output:.10f} {quote_label}"
)
logger.info(
f"Minimum {quote_label} output (with {self.slippage * 100:.1f}% slippage): "
f"{min_quote_output / quote_unit:.10f} {quote_label} "
f"({min_quote_output} raw units)"
)
# Build sell instructions using platform-specific builder
@@ -444,7 +601,7 @@ class PlatformAwareSeller(Trader):
token_info,
self.wallet.pubkey,
token_balance, # amount_in (tokens)
min_sol_output, # minimum_amount_out (SOL)
min_quote_output, # minimum_amount_out (raw quote units)
address_provider,
)
+84 -21
View File
@@ -19,6 +19,12 @@ from cleanup.modes import (
)
from core.client import SolanaClient
from core.priority_fee.manager import PriorityFeeManager
from core.pubkeys import (
WSOL_MINT,
normalize_quote_mint,
resolve_quote_amounts,
resolve_quote_mint,
)
from core.wallet import Wallet
from interfaces.core import Platform, TokenInfo
from monitoring.listener_factory import ListenerFactory
@@ -46,6 +52,34 @@ except ImportError:
logger = get_logger(__name__)
def _resolve_quote_config(
buy_amount: float,
quote_amounts: dict[str, float] | None,
allowed_quote_mints: list[str] | None,
) -> tuple[dict[Pubkey, float], set[Pubkey] | None]:
"""Resolve quote-asset configuration into per-mint amounts and an allowlist.
Keys may be mint addresses or the aliases "sol"/"usdc". SOL always falls
back to trade.buy_amount, so a config that never mentions quote assets
keeps its existing SOL-only behaviour.
Args:
buy_amount: SOL amount per buy from trade.buy_amount
quote_amounts: Optional map of quote mint -> amount in whole units
allowed_quote_mints: Optional list of quote mints permitted to trade
Returns:
Tuple of (amount per quote mint, allowed quote mints or None for any)
"""
amounts = {WSOL_MINT: buy_amount, **resolve_quote_amounts(quote_amounts)}
allowed = (
{resolve_quote_mint(mint) for mint in allowed_quote_mints}
if allowed_quote_mints
else None
)
return amounts, allowed
class UniversalTrader:
"""Universal trading coordinator that works with any supported platform."""
@@ -68,6 +102,9 @@ class UniversalTrader:
# Trading configuration
extreme_fast_mode: bool = False,
extreme_fast_token_amount: int = 30,
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts: dict[str, float] | None = None,
allowed_quote_mints: list[str] | None = None,
# Exit strategy configuration
exit_strategy: str = "time_based",
take_profit_percentage: float | None = None,
@@ -137,29 +174,34 @@ class UniversalTrader:
self.platform, self.solana_client
)
# Store compute unit configuration
# Store compute unit and quote-asset configuration
self.compute_units = compute_units or {}
# Create platform-aware traders
self.buyer = PlatformAwareBuyer(
self.solana_client,
self.wallet,
self.priority_fee_manager,
buy_amount,
buy_slippage,
max_retries,
extreme_fast_token_amount,
extreme_fast_mode,
compute_units=self.compute_units,
self.quote_amounts, self.allowed_quote_mints = _resolve_quote_config(
buy_amount, quote_amounts, allowed_quote_mints
)
self.seller = PlatformAwareSeller(
self.solana_client,
self.wallet,
self.priority_fee_manager,
sell_slippage,
max_retries,
compute_units=self.compute_units,
# Create platform-aware traders
self.buyer, self.seller = (
PlatformAwareBuyer(
self.solana_client,
self.wallet,
self.priority_fee_manager,
buy_amount,
buy_slippage,
max_retries,
extreme_fast_token_amount,
extreme_fast_mode,
compute_units=self.compute_units,
quote_amounts=self.quote_amounts,
),
PlatformAwareSeller(
self.solana_client,
self.wallet,
self.priority_fee_manager,
sell_slippage,
max_retries,
compute_units=self.compute_units,
),
)
# Initialize the appropriate listener with platform filtering
@@ -427,6 +469,25 @@ class UniversalTrader:
)
return
# Skip coins paired against a quote asset we are not set up to
# trade. Cheaper to drop here than to fail a buy on-chain.
token_quote_mint = normalize_quote_mint(token_info.quote_mint)
if (
self.allowed_quote_mints is not None
and token_quote_mint not in self.allowed_quote_mints
):
logger.info(
f"Skipping {token_info.symbol} - quote mint {token_quote_mint} "
f"not in allowed_quote_mints"
)
return
if token_quote_mint not in self.quote_amounts:
logger.info(
f"Skipping {token_info.symbol} - no buy amount configured for "
f"quote mint {token_quote_mint}"
)
return
# Wait for pool/curve to stabilize (unless in extreme fast mode)
if not self.extreme_fast_mode:
await self._save_token_info(token_info)
@@ -437,7 +498,9 @@ class UniversalTrader:
# Buy token
logger.info(
f"Buying {self.buy_amount:.6f} SOL worth of {token_info.symbol} on {token_info.platform.value}..."
f"Buying {self.quote_amounts[token_quote_mint]:.6f} of quote "
f"{token_quote_mint} worth of {token_info.symbol} "
f"on {token_info.platform.value}..."
)
buy_result: TradeResult = await self.buyer.execute(token_info)