mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-07-29 08:17:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2611
-2896
File diff suppressed because it is too large
Load Diff
+1123
-1
File diff suppressed because it is too large
Load Diff
@@ -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()))
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()))
|
||||
@@ -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())
|
||||
@@ -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
@@ -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})..."
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user