mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-02 10:17:44 +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:
@@ -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())
|
||||
Reference in New Issue
Block a user