Files
75d9087d05 feat(cookbook): launch any coin create_v2 supports, as a v0 or a v1 transaction (#234)
* feat(cookbook): cover every create_v2 coin parameter in the launch scripts

Both create scripts now build create_v2 through pumpfun_instructions_v2
instead of each carrying their own copy of the discriminators, PDAs and
account list.

- A holder-reward coin's curve creator is derived rather than assumed, so
  the opening buy finds the matching creator vault instead of reverting.
- create_v2's three trailing arguments are settable independently. None
  omits an argument, a value sends it, so all four wire forms are
  reachable and an explicit default is distinct from an omission.
- create_v2 carries all four remaining accounts, quote_control included.
  Without them the program ignores creator_fee_bps.
- --creator, --creator-fee-bps and --quote-mint added. A creator fee on a
  SOL-paired coin warns, because the program stores zero for it; the
  create-and-buy script refuses one outright before sending anything.
- The opening buy is sized from Global's reserves and fee rates.
- Mayhem mode is off by default.

Removes the fee-recipient lookup whose result was discarded and the buy
wrapper whose parameters were unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(regression): verify the launch path's create_v2 arguments

Eight offline checks: the substituted creator on a holder-reward coin,
every committed create_v2 fixture round-tripping through the trailing
argument encoder, all four wire forms being reachable, --creator reaching
the wire, the opening buy following Global rather than constants, the
Global decoder stopping cleanly on a short buffer, and the four quote
accounts being present with the ATA under the quote mint's own program.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct create_v2's remaining accounts and the creator-fee rule

create_v2 takes four remaining accounts, not three; the fourth is the
QuoteControl PDA. Omitting them is accepted and yields a SOL-paired coin,
which is also how a creator fee goes missing: the program applies
creator_fee_bps only to a coin priced in something other than SOL, and
otherwise stores zero without an error.

Also records that solders documents TransactionConfig.priority_fee as
micro-lamports while SIMD-0385 defines the field as total lamports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cookbook): price a new coin in an asset other than SOL

--quote-mint on both create scripts, checked against both registries that
admit a quote asset. QuoteControl carries most of them with a per-mint
opening reserve; Global.whitelisted_quote_mints carries USDC, which
QuoteControl does not list, so consulting either alone refuses a mint the
program accepts. A mint neither admits is refused rather than priced from
a default, since the opening reserve spans orders of magnitude.

The quote mint's token program and decimals are resolved from chain, and
--amount is denominated in the quote asset rather than in SOL. A paused
mint is refused before anything is sent, and a scaled-UI mint says so,
because amounts here are raw units.

A creator fee now works, since pump.fun applies one only to a coin priced
in something other than SOL. On a SOL-paired coin it is refused instead of
being accepted and stored as zero.

The create-and-buy script requires the wallet to already hold the quote
asset; it does not acquire one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cookbook): launch a coin in one v1 transaction

Create, extend, the buyer's token account and the buy now travel under a
single signature, so nobody can buy between the create and the opening
buy. They were split because the two together are 1479 bytes against the
1232 a v0 transaction is held to; SIMD-0296 raised the limit to 4096 for
v1 and the combined message uses about a third of it.

v1 has no address lookup tables -- the larger limit exists so the whole
account list goes inline -- and it carries the compute budget in the
message header, so the script sends no ComputeBudget instructions. The
priority fee is now one total in lamports rather than micro-lamports per
compute unit; the constant is converted, not renamed.

The two-transaction path is removed rather than kept behind a flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): state the loaded-accounts data size limit on the v1 launch

A v1 message carries this limit in its header, and left unset it is zero
rather than the network default, so the runtime rejects the transaction
for exceeding it. The launch skips preflight, so the rejection never
reached the caller: send returned a signature that never landed.

Measured against a forked mainnet, the launch is rejected at 128 KiB and
lands at 256 KiB. The limit is set to 1 MiB, which leaves room for a quote
mint whose accounts are larger, and the verifier fails if it is dropped or
set at or below the measured failing size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cookbook): offer the launch as a v0 or a v1 transaction

The Solana transaction version changes what a launch can be, so it is the
script that changes rather than a flag on one of them.

_txv1 sends create and buy under a single signature, so the coin is bought
by the transaction that creates it. _txv0 sends them as two transactions,
because together they do not fit what a v0 transaction can carry, and
anyone watching the create can buy in the gap. Use it when the endpoint
will not take a v1 send.

The coin is identical either way: quote asset, creator fee, holder rewards
and mayhem mode all behave the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(cookbook): drop the instruction version from script names

The cookbook ships one form of each pump.fun action, so `_v2` in a
basename distinguished nothing — and it read as a Solana transaction
version, which is a different thing and now appears in the same names.
`pumpfun_buy_token_exact_sol_in.py` already went without it.

Every trade script loses the suffix, the shared module becomes
`pumpfun_instructions.py`, and the retired path is named for what it is
rather than for the instruction set it uses. The instruction each script
builds is named in its docstring, where it cannot be mistaken for a
transaction version. `txv0`/`txv1` is now the only version marker in any
filename, and it is the one the reader chooses between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): refuse mayhem mode paired with a non-SOL quote asset

The program rejects the pairing with MayhemModeQuoteMintNotAllowed (6071),
so a mayhem coin is always SOL-paired — and therefore can never carry a
creator fee, which needs a non-SOL quote. Both create scripts now refuse
the combination before sending, rather than letting it land as a reverted
transaction that has already cost fees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): size the v1 loaded-accounts limit for what mainnet loads

The limit counts the executable data of every program a transaction
touches, not just the accounts it names, and the pump program's own code
is the bulk of it. The previous figure was set against a local fork, which
does not charge for executables; on mainnet the launch was rejected with
MaxLoadedAccountsDataSizeExceeded.

Set to the network default a v0 transaction gets when nothing narrows it,
which is the behaviour being reproduced. The verifier now holds the
mainnet figure as its floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): a creator fee needs a QuoteControl quote mint, not just a non-SOL one

Measured on mainnet: the fee is applied on a coin priced in a mint
QuoteControl admits, whether that mint is SPL Token or Token-2022, and is
not applied on wrapped SOL or on USDC — which the older Global whitelist
carries and QuoteControl does not. The deciding property is registry
membership, so the previous check let a USDC-priced coin through and the
program stored zero without an error.

Also stops the sell script calling a price "SOL" when the curve is priced
in something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cookbook): offer the create-only script as a v0 or a v1 transaction

Same split as the create-and-buy pair, for the same reason: the Solana
transaction version is the reader's choice, so it is the script that
changes rather than a flag. _txv1 carries the compute budget in the
message header and sends no ComputeBudget instructions; _txv0 sends them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): refuse a paused quote mint on the create-and-buy path too

The check only ran in the create-only scripts, so a launch that also buys
could reach the chain with a quote mint whose transfers are paused. On the
two-transaction path that leaves a created coin nobody bought, after the
create has already cost rent.

Also corrects the v1 script's run docstring, which still described two
transactions and a spend denominated in SOL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): say --amount is in whole quote units, not raw ones

The scaled-UI notice told the reader the opposite of what the scripts do:
--amount is multiplied by the mint's decimals, so it is a whole-token
figure. A reader who followed the notice would have overspent by a factor
of the decimals.

Reworded so the multiplier is described as what it is — a display scale a
wallet applies — rather than as something the amount has to account for.
The create-only scripts carried the same wording without even taking an
amount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:48:31 +02:00

1476 lines
56 KiB
Python

"""Self-contained pump.fun v2 trade helpers for the cookbook scripts.
`buy_v2` / `sell_v2` take 27 and 26 mandatory accounts in a fixed order,
identical for every coin. Imports nothing from `src/`.
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 collections.abc import Awaitable, Callable, Mapping
from typing import Any
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 program that owns each quote mint below. WSOL and USDC are legacy SPL
# Token, but create_v2 can pair a coin with a Token-2022 quote mint (error 6064
# accepts "SPL Token or Token-2022"), so this map is not exhaustive -- see
# resolve_quote_token_program for mints outside it.
QUOTE_TOKEN_PROGRAMS = {WSOL_MINT: TOKEN_PROGRAM, USDC_MINT: TOKEN_PROGRAM}
TOKEN_DECIMALS = 6
LAMPORTS_PER_SOL = 1_000_000_000
# Account discriminator for the BondingCurve account.
BONDING_CURVE_DISCRIMINATOR = struct.pack("<Q", 6966180631402821399)
_SHORT_CURVE_MSG = "Bonding curve data ends before {name} at byte {offset}"
# create_v2 mints under this authority, and the five mayhem accounts below are
# mandatory on every create_v2 whatever is_mayhem_mode says -- the IDL marks none
# of them optional and omitting them fails with AnchorError 3005
# (AccountNotEnoughKeys) on sol_vault. The argument, not the account list, is what
# makes a coin a mayhem coin.
PUMP_MINT_AUTHORITY = Pubkey.from_string("TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM")
MAYHEM_PROGRAM = Pubkey.from_string("MAyhSmzXzV1pTf7LsNkrNwkWKTo4ougAJ1PPg47MD4e")
MAYHEM_GLOBAL_PARAMS = Pubkey.from_string(
"13ec7XdrjF3h3YcqBTFDSReRcUFwbCnJaAQspM4j6DDJ"
)
MAYHEM_SOL_VAULT = Pubkey.from_string("BwWK17cbHxwWBKZkUYvzxLcNQ1YVyaFezduWbtm2de6s")
# Instruction discriminators (first 8 bytes of sha256("global:<name>")).
CREATE_V2_DISCRIMINATOR = bytes([214, 144, 76, 236, 95, 139, 49, 180])
EXTEND_ACCOUNT_DISCRIMINATOR = bytes([234, 102, 194, 203, 150, 72, 62, 229])
BUY_V2_DISCRIMINATOR = bytes([184, 23, 238, 97, 103, 197, 211, 61])
SELL_V2_DISCRIMINATOR = bytes([93, 246, 130, 60, 231, 233, 64, 178])
# buy_exact_quote_in_v2 takes the same 27 accounts as buy_v2, in the same
# order — only the discriminator and the two arguments differ.
BUY_EXACT_QUOTE_IN_V2_DISCRIMINATOR = bytes([194, 171, 28, 70, 104, 77, 91, 47])
# buy_exact_sol_in is SOL-only and pre-dates the v2 account list. The IDL
# under-reports it at 16 accounts; on chain it needs 18 (see the builder).
BUY_EXACT_SOL_IN_DISCRIMINATOR = bytes([56, 252, 116, 8, 158, 223, 205, 95])
# Both payouts are permissionless: the IDL marks no account a signer, because
# the funds can only move to the wallet they already belong to.
COLLECT_CREATOR_FEE_V2_DISCRIMINATOR = bytes([207, 17, 138, 242, 4, 34, 19, 56])
CLAIM_CASHBACK_V2_DISCRIMINATOR = bytes([122, 243, 204, 65, 94, 116, 29, 55])
# 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
# Byte offset of `decimals` in a Mint account. SPL Token and Token-2022 share the
# same base layout -- COption<Pubkey> mint_authority (4-byte tag + 32-byte
# pubkey), u64 supply, then u8 decimals at byte 44. Token-2022 extensions are
# appended after the 82-byte base struct, never rearranging it. Mirrors
# core/pubkeys.py, duplicated because this module imports nothing from src/.
_MINT_DECIMALS_OFFSET = 44
def _parse_mint_decimals(data: bytes) -> int:
"""Read the `decimals` field out of a raw Mint account's byte layout.
Args:
data: Raw account data, e.g. `.data` on an `AsyncClient.get_account_info`
response's `.value`
Returns:
The mint's decimal count
Raises:
ValueError: If the data is too short to be a Mint account
"""
if len(data) <= _MINT_DECIMALS_OFFSET:
raise ValueError(
f"Account data is only {len(data)} bytes, too short to be a "
f"Mint account (decimals lives at byte {_MINT_DECIMALS_OFFSET})"
)
return data[_MINT_DECIMALS_OFFSET]
# Process-lifetime cache of quote mint -> decimals, pre-seeded so WSOL/USDC never
# cost an RPC call. Warmed by `resolve_quote_token_program` off the mint-account
# fetch it already makes, so it costs no extra RPC calls.
_QUOTE_DECIMALS_CACHE = dict(QUOTE_DECIMALS)
def quote_units(quote_mint: Pubkey) -> int:
"""Raw units per whole unit of a quote mint, from the warm cache.
Pre-seeded with WSOL (9) and USDC (6). Every other quote mint must be
resolved by `resolve_quote_token_program` first, which caches its decimals
off the same account read it already makes.
Raises rather than guessing: `QuoteControl` admits mints at 6, 8 and 9
decimals, so a default of 9 overstates a slippage cap by 10x or 1000x, in the
same direction as the price error it causes, so the two compound instead of
cancelling.
Returns:
10 ** decimals for the quote mint (1e9 for SOL, 1e6 for USDC)
Raises:
ValueError: If the mint's decimals have not been resolved yet
"""
decimals = _QUOTE_DECIMALS_CACHE.get(quote_mint)
if decimals is None:
raise ValueError(
f"Decimals for quote mint {quote_mint} are unknown. Call "
f"resolve_quote_token_program() for it before pricing or sizing a "
f"trade -- guessing here misprices the trade by a power of ten."
)
return 10**decimals
def quote_token_program_for(quote_mint: Pubkey) -> Pubkey:
"""Token program owning a quote mint, defaulting to SPL Token.
Returns:
Token program id for quote_mint if known, else SPL Token
"""
return QUOTE_TOKEN_PROGRAMS.get(quote_mint, TOKEN_PROGRAM)
# Process-lifetime cache of quote mint -> owning token program, pre-seeded so
# WSOL/USDC never cost an RPC call.
_QUOTE_TOKEN_PROGRAM_CACHE = dict(QUOTE_TOKEN_PROGRAMS)
async def resolve_quote_token_program(
quote_mint: Pubkey,
get_account_info: Callable[[Pubkey], Awaitable[Any]],
) -> Pubkey:
"""Resolve and cache the token program *and* decimals for a quote mint.
Returns with no RPC call for a mint already known or resolved earlier in this
process. Otherwise fetches the mint account once and caches both facts: the
account's owner *is* the token program, and its data carries `decimals` at a
fixed offset (see `_parse_mint_decimals`). `build_v2_accounts` and its callers
are synchronous, so call this first and pass the result as
`quote_token_program_id` for a mint outside QUOTE_TOKEN_PROGRAMS.
Args:
quote_mint: Quote mint address to resolve
get_account_info: Async getter returning an account object with
`.owner` (Pubkey) and `.data` (bytes) attributes, e.g.
`AsyncClient.get_account_info` (unwrap `.value` from the RPC
response first)
Returns:
Token program id that owns the quote mint
Raises:
ValueError: If the mint's owner is neither SPL Token nor Token-2022,
or its account data is too short to carry a decimals field
"""
cached = _QUOTE_TOKEN_PROGRAM_CACHE.get(quote_mint)
if cached is not None:
return cached
account = await get_account_info(quote_mint)
owner = account.owner
if owner not in (TOKEN_PROGRAM, TOKEN_2022_PROGRAM):
raise ValueError(
f"Quote mint {quote_mint} is owned by {owner}, expected "
f"{TOKEN_PROGRAM} (SPL Token) or {TOKEN_2022_PROGRAM} (Token-2022)"
)
decimals = _parse_mint_decimals(bytes(account.data))
_QUOTE_TOKEN_PROGRAM_CACHE[quote_mint] = owner
_QUOTE_DECIMALS_CACHE[quote_mint] = decimals
return owner
def find_bonding_curve(mint: Pubkey) -> Pubkey:
"""Derive the bonding curve PDA.
Args:
mint: Base token mint
"""
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
"""
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."""
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
"""
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)."""
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
"""
return Pubkey.find_program_address(
[b"sharing-config", bytes(base_mint)], PUMP_FEE_PROGRAM
)[0]
def find_mayhem_state(mint: Pubkey) -> Pubkey:
"""Derive a coin's mayhem state PDA (under the mayhem program).
The seed is hyphenated, unlike the pump program's volume-accumulator seeds.
Args:
mint: Base token mint
"""
return Pubkey.find_program_address([b"mayhem-state", bytes(mint)], MAYHEM_PROGRAM)[
0
]
def find_holder_rewards(mint: Pubkey) -> Pubkey:
"""Derive the address that replaces the creator on a holder-reward coin.
A coin created with `is_holder_reward` does not carry its creator in
`BondingCurve.creator`; the program substitutes this PDA so the creator fee
accrues to holders. Trades must derive `creator_vault` from it, not from the
wallet that signed the create, or the vault seeds do not match (2006).
Args:
mint: Base token mint
"""
return Pubkey.find_program_address([b"holder-rewards", bytes(mint)], PUMP_PROGRAM)[
0
]
def find_quote_control() -> Pubkey:
"""Derive the QuoteControl PDA, the registry of permitted quote mints.
`Global.whitelisted_quote_mints` is the older, much shorter list; this
account is the one a create is actually checked against.
"""
return Pubkey.find_program_address([b"quote-control"], PUMP_PROGRAM)[0]
def curve_creator(mint: Pubkey, creator: Pubkey, *, is_holder_reward: bool) -> Pubkey:
"""The creator a coin's bonding curve will actually carry.
Args:
mint: Base token mint
creator: Creator passed to create_v2
is_holder_reward: Whether the coin was created as a holder-reward coin
"""
return find_holder_rewards(mint) if is_holder_reward else creator
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
"""
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
"""
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."""
return secrets.choice(BUYBACK_FEE_RECIPIENTS)
class BondingCurveState:
"""Parsed pump.fun BondingCurve account.
The account is 125 bytes as created, and `extend_account` can grow it to 151,
256 or any other length the program allows. The struct below covers the
leading fields, which sit at the same offsets regardless of total length. The
SOL-named reserve fields were renamed to quote fields when non-SOL quote
assets landed; the old names are kept as aliases.
The fields are declared below rather than left to the struct parse, so an
editor and a type checker can both see what a curve holds.
"""
virtual_token_reserves: int
virtual_quote_reserves: int
real_token_reserves: int
real_quote_reserves: int
token_total_supply: int
complete: bool
virtual_sol_reserves: int
real_sol_reserves: int
#: None when the account is too short to carry the field.
creator: Pubkey | None
is_mayhem_mode: bool
is_cashback_coin: bool
quote_mint: Pubkey | None
is_sol_paired: bool
creator_fee_bps: int | None
can_edit_creator_fee: bool | None
is_holder_reward: bool | None
_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
# The three fields create_v2 added after quote_mint, filling the 36 reserved
# bytes the old 151-byte layout carried. A legacy `create` curve ends before
# them, so they read as None rather than as a value the coin never had.
_CREATOR_FEE_BPS_OFFSET = 115
_CAN_EDIT_CREATOR_FEE_OFFSET = 123
_IS_HOLDER_REWARD_OFFSET = 124
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")
parsed = self._STRUCT.parse(data[8:])
self.virtual_token_reserves = parsed.virtual_token_reserves
self.virtual_quote_reserves = parsed.virtual_quote_reserves
self.real_token_reserves = parsed.real_token_reserves
self.real_quote_reserves = parsed.real_quote_reserves
self.token_total_supply = parsed.token_total_supply
self.complete = parsed.complete
# 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, "is_mayhem_mode"
)
self.is_cashback_coin = self._read_flag(
data, self._CASHBACK_OFFSET, "is_cashback_coin"
)
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)
self.creator_fee_bps = self._read_u64(data, self._CREATOR_FEE_BPS_OFFSET)
self.can_edit_creator_fee = self._read_optional_flag(
data, self._CAN_EDIT_CREATOR_FEE_OFFSET
)
self.is_holder_reward = self._read_optional_flag(
data, self._IS_HOLDER_REWARD_OFFSET
)
@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_u64(data: bytes, offset: int) -> int | None:
"""Read a little-endian u64 if the data extends that far.
Args:
data: Raw account data
offset: Byte offset
Returns:
Value, or None if the field is absent
"""
if len(data) < offset + 8:
return None
return struct.unpack_from("<Q", data, offset)[0]
@staticmethod
def _read_optional_flag(data: bytes, offset: int) -> bool | None:
"""Read a single-byte bool that a shorter curve legitimately lacks.
Unlike `_read_flag`, absence is an answer here: a legacy `create` curve
predates these fields, and None says the coin cannot have the property
rather than that it does not.
Args:
data: Raw account data
offset: Byte offset
Returns:
Flag value, or None if the field is absent
"""
if len(data) <= offset:
return None
return bool(data[offset])
@staticmethod
def _read_flag(data: bytes, offset: int, name: str) -> bool:
"""Read a single-byte bool, refusing to guess when the field is absent.
Args:
data: Raw account data
offset: Byte offset
name: Field name, used in the error
Returns:
Flag value
Raises:
ValueError: If the data ends before the flag. is_mayhem_mode picks
the fee recipient for a buy, and the wrong one reverts with
NotAuthorized (6000), so defaulting it only moves the failure
to somewhere harder to read.
"""
if len(data) <= offset:
raise ValueError(_SHORT_CURVE_MSG.format(name=name, offset=offset))
return bool(data[offset])
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 / quote_units(self.quote_mint)) / (
self.virtual_token_reserves / 10**TOKEN_DECIMALS
)
# Global's fields in declaration order, walked rather than indexed: an offset
# table silently reads the wrong field the day a field is inserted above it,
# and every value below is one set_params call away from changing.
_GLOBAL_LAYOUT: tuple[tuple[str, str], ...] = (
("initialized", "bool"),
("authority", "pubkey"),
("fee_recipient", "pubkey"),
("initial_virtual_token_reserves", "u64"),
("initial_virtual_sol_reserves", "u64"),
("initial_real_token_reserves", "u64"),
("token_total_supply", "u64"),
("fee_basis_points", "u64"),
("withdraw_authority", "pubkey"),
("enable_migrate", "bool"),
("pool_migration_fee", "u64"),
("creator_fee_basis_points", "u64"),
("fee_recipients", "pubkey[7]"),
("set_creator_authority", "pubkey"),
("admin_set_creator_authority", "pubkey"),
("create_v2_enabled", "bool"),
("whitelist_pda", "pubkey"),
("reserved_fee_recipient", "pubkey"),
("mayhem_mode_enabled", "bool"),
("reserved_fee_recipients", "pubkey[7]"),
("is_cashback_enabled", "bool"),
("buyback_fee_recipients", "pubkey[8]"),
("buyback_basis_points", "u64"),
("initial_virtual_quote_reserves", "u64"),
("whitelisted_quote_mints", "pubkey[1]"),
("creator_fee_configurable", "bool"),
("max_configurable_creator_fee_bps", "u64"),
("holder_reward_claim_authority", "pubkey"),
("is_holder_reward_enabled", "bool"),
)
_GLOBAL_SIZES = {"bool": 1, "u64": 8, "pubkey": 32}
def decode_global(data: bytes) -> dict[str, Any]:
"""Decode the pump.fun Global account.
Stops at the last field the buffer holds rather than raising, so a Global
that has grown a new trailing field still yields every field before it.
Args:
data: Raw account data, discriminator included
Returns:
Field name to value; pubkey arrays come back as lists
"""
fields: dict[str, Any] = {}
offset = 8
for name, kind in _GLOBAL_LAYOUT:
base, _, count = kind.partition("[")
repeat = int(count.rstrip("]")) if count else 1
width = _GLOBAL_SIZES[base] * repeat
if offset + width > len(data):
break
chunk = data[offset : offset + width]
if base == "bool":
fields[name] = bool(chunk[0])
elif base == "u64":
fields[name] = struct.unpack("<Q", chunk)[0]
elif count:
fields[name] = [
Pubkey.from_bytes(chunk[i : i + 32]) for i in range(0, width, 32)
]
else:
fields[name] = Pubkey.from_bytes(chunk)
offset += width
return fields
async def fetch_global(
get_account_info: Callable[[Pubkey], Awaitable[Any]],
) -> dict[str, Any]:
"""Read and decode Global.
Args:
get_account_info: Async getter returning an account object with a
`.data` attribute, as `resolve_quote_token_program` takes
"""
return decode_global(bytes((await get_account_info(PUMP_GLOBAL)).data))
# QuoteControl: admin (32) then 64 reserved bytes, then a vec of
# (mint, initial_virtual_quote_reserves).
_QUOTE_CONTROL_MINTS_OFFSET = 8 + 32 + 64
_QUOTE_CONTROL_ENTRY_SIZE = 32 + 8
def decode_quote_control(data: bytes) -> dict[Pubkey, int]:
"""Decode QuoteControl into quote mint -> opening virtual quote reserves.
Each admitted mint carries its own opening reserve, which is the coin's
starting price in that asset. It is not a scaled version of the SOL one.
Args:
data: Raw account data, discriminator included
Returns:
Mint to initial_virtual_quote_reserves, in the mint's raw units
Raises:
ValueError: If the account is too short to hold the vec length
"""
if len(data) < _QUOTE_CONTROL_MINTS_OFFSET + 4:
msg = f"QuoteControl account is only {len(data)} bytes"
raise ValueError(msg)
count = struct.unpack_from("<I", data, _QUOTE_CONTROL_MINTS_OFFSET)[0]
start = _QUOTE_CONTROL_MINTS_OFFSET + 4
entries = {}
for index in range(count):
at = start + index * _QUOTE_CONTROL_ENTRY_SIZE
if at + _QUOTE_CONTROL_ENTRY_SIZE > len(data):
break
entries[Pubkey.from_bytes(data[at : at + 32])] = struct.unpack_from(
"<Q", data, at + 32
)[0]
return entries
async def fetch_quote_control(
get_account_info: Callable[[Pubkey], Awaitable[Any]],
) -> dict[Pubkey, int]:
"""Read and decode the QuoteControl registry.
Args:
get_account_info: Async getter returning an account object with `.data`
"""
account = await get_account_info(find_quote_control())
return decode_quote_control(bytes(account.data))
def opening_quote_reserves(
registry: dict[Pubkey, int], quote_mint: Pubkey, global_state: dict[str, Any]
) -> int:
"""Opening virtual quote reserves for a coin priced in `quote_mint`.
Three sources, because there are three kinds of admitted mint and they do
not carry the same figure:
- wrapped SOL takes `Global.initial_virtual_sol_reserves`;
- a `QuoteControl` entry carries its own reserve, which is the coin's
opening price in that asset and is not a scaled version of the SOL one;
- a mint in the older `Global.whitelisted_quote_mints` that QuoteControl
does not list takes `Global.initial_virtual_quote_reserves`. USDC is one,
so treating QuoteControl as the whole registry refuses a mint the program
accepts.
Raises rather than substituting a default for an unlisted mint: across the
admitted mints the figure spans several orders of magnitude, so a stand-in
misprices the opening buy instead of approximating it.
Args:
registry: Decoded QuoteControl, from `decode_quote_control`
quote_mint: Asset the coin is priced in
global_state: Decoded Global
Returns:
Opening virtual quote reserves, in the quote mint's raw units
Raises:
ValueError: If neither registry admits the mint
"""
if is_sol_paired(quote_mint) or quote_mint == WSOL_MINT:
return global_state["initial_virtual_sol_reserves"]
reserves = registry.get(quote_mint)
if reserves is not None:
return reserves
if quote_mint in (global_state.get("whitelisted_quote_mints") or []):
return global_state["initial_virtual_quote_reserves"]
msg = (
f"Neither QuoteControl nor Global admits {quote_mint}, so a coin "
f"cannot be priced in it. QuoteControl lists {len(registry)} mints; "
f"pumpfun_read_quote_mints.py prints them."
)
raise ValueError(msg)
def check_quote_mint_tradable(
quote_mint: Pubkey, parsed_info: Mapping[str, Any]
) -> float | None:
"""Refuse a quote mint that cannot currently settle a trade.
Tokenised equities among the admitted mints carry Token-2022 extensions
their issuer controls. `pausableConfig` stops every transfer of the mint
while it is set, which fails every trade on every coin priced in it, so a
paused mint is refused rather than discovered at buy time.
`scaledUiAmountConfig` means a wallet shows a balance the issuer scales by a
multiplier it can move, so the displayed figure is not the one the token's
own decimals give. Amounts in these scripts come from the decimals, so the
multiplier is returned for a caller to warn about rather than to apply.
Args:
quote_mint: Asset the coin is priced in
parsed_info: The `info` mapping from a jsonParsed mint account
Returns:
The scaled-UI multiplier, or None when the mint is not scaled
Raises:
ValueError: If the mint is paused
"""
extensions = {
entry.get("extension"): entry.get("state", {})
for entry in (parsed_info.get("extensions") or [])
if isinstance(entry, dict)
}
if (extensions.get("pausableConfig") or {}).get("paused"):
msg = (
f"Quote mint {quote_mint} is paused by its issuer. Every trade on "
f"every coin priced in it fails until that is lifted."
)
raise ValueError(msg)
multiplier = (extensions.get("scaledUiAmountConfig") or {}).get("multiplier")
return float(multiplier) if multiplier not in (None, "") else None
def check_creator_fee_quote(
registry: dict[Pubkey, int], quote_mint: Pubkey, creator_fee_bps: int | None
) -> None:
"""Refuse a creator fee the program will accept and then not apply.
A creator fee is only honoured on a coin priced in a mint `QuoteControl`
admits. Anywhere else -- wrapped SOL, or a mint carried only by the older
`Global.whitelisted_quote_mints` such as USDC -- the program takes the
argument, emits `CreateEvent.creator_fee_bps = 0` and stores 0 on the curve,
with no error. The token program is not what decides it: the fee lands on
SPL Token and Token-2022 quote mints alike, provided QuoteControl lists them.
Args:
registry: Decoded QuoteControl, from `decode_quote_control`
quote_mint: Asset the coin is priced in
creator_fee_bps: Requested fee, or None if the arg is being omitted
Raises:
ValueError: If a fee is requested against a mint QuoteControl does not
admit
"""
if not creator_fee_bps:
return
if quote_mint in registry:
return
msg = (
f"A creator fee is only applied to a coin priced in a mint QuoteControl "
f"admits, and {quote_mint} is not one -- the program would accept "
f"--creator-fee-bps and store 0. pumpfun_read_quote_mints.py prints the "
f"mints that do carry a fee."
)
raise ValueError(msg)
def check_mayhem_quote_pairing(quote_mint: Pubkey, *, is_mayhem_mode: bool) -> None:
"""Refuse a mayhem coin priced in anything but SOL.
The program rejects the pairing with `MayhemModeQuoteMintNotAllowed` (6071).
Checked before sending, because the create otherwise lands as a reverted
transaction that has already cost fees.
Args:
quote_mint: Asset the coin would be priced in
is_mayhem_mode: Whether the coin opts into mayhem mode
Raises:
ValueError: If a mayhem coin is paired with a non-SOL quote asset
"""
if not is_mayhem_mode:
return
if is_sol_paired(quote_mint) or quote_mint == WSOL_MINT:
return
msg = (
f"Mayhem mode cannot be combined with a non-SOL quote asset: the "
f"program rejects it with MayhemModeQuoteMintNotAllowed (6071). Drop "
f"--mayhem, or drop --quote-mint {quote_mint}."
)
raise ValueError(msg)
def encode_create_v2_trailing_args(
*,
is_cashback_enabled: bool | None = None,
creator_fee_bps: int | None = None,
is_holder_reward: bool | None = None,
) -> bytes:
"""Encode create_v2's three trailing args.
They are positional with no presence tag, so setting a later one forces the
earlier ones onto the wire, while a shorter form cannot have set a later one.
Four wire lengths follow `is_mayhem_mode`: 0, 1, 9 and 10 bytes. Neither
OptionBool nor OptionU64 is a discriminated Option -- each is a single-field
struct serializing as its bare inner value.
`None` means omit, mirroring the decoder, which reports an absent arg as
unset rather than as its default. That distinction is the caller's to make
and cannot be inferred: coins on chain send `is_cashback_enabled=False`, and
`creator_fee_bps=0`, explicitly, so collapsing a default to an omission
would make those wire forms unreachable. Pass 0 or False to send the value,
leave it None to leave it off.
An earlier arg left None while a later one is set is filled with the value
the program would have defaulted it to.
Args:
is_cashback_enabled: create_v2 rejects True with 6082
(CashbackDeprecated); settable anyway so the encoder matches the
instruction rather than the subset currently accepted
creator_fee_bps: Bounded by Global's max_configurable_creator_fee_bps
is_holder_reward: Sets the creator fee aside for holders
Returns:
0, 1, 9 or 10 bytes to append after is_mayhem_mode
"""
send_holder = is_holder_reward is not None
send_fee = send_holder or creator_fee_bps is not None
send_cashback = send_fee or is_cashback_enabled is not None
if not send_cashback:
return b""
data = struct.pack("<?", bool(is_cashback_enabled))
if send_fee:
data += struct.pack("<Q", creator_fee_bps or 0)
if send_holder:
data += struct.pack("<?", bool(is_holder_reward))
return data
def build_create_v2_instruction( # noqa: PLR0913
*,
mint: Pubkey,
user: Pubkey,
creator: Pubkey,
name: str,
symbol: str,
uri: str,
is_mayhem_mode: bool = False,
creator_fee_bps: int | None = None,
is_holder_reward: bool | None = None,
quote_mint: Pubkey = WSOL_MINT,
quote_token_program: Pubkey | None = None,
) -> Instruction:
"""Build create_v2, which mints the coin under Token-2022.
Args:
mint: The new coin's mint, a keypair you generate; it signs too
user: Wallet paying for the accounts
creator: Creator written into the curve, which need not be `user`. On a
holder-reward coin the program substitutes its own address anyway --
see `curve_creator`
name: Coin name
symbol: Coin ticker
uri: Metadata URI
is_mayhem_mode: Whether the coin opts into mayhem mode
creator_fee_bps: Creator fee, bounded by Global's
max_configurable_creator_fee_bps and only settable while
creator_fee_configurable. None omits the arg entirely
is_holder_reward: Set the creator fee aside for holders instead. None
omits the arg, which is itself proof the coin is not one
quote_mint: Asset the coin is priced in. Wrapped SOL gives an ordinary
SOL-paired coin. **A creator fee only takes effect on a coin priced
in something other than SOL** -- pump.fun accepts creator_fee_bps on
a SOL-paired coin and stores zero
quote_token_program: Token program owning quote_mint. Resolve it from
chain with `resolve_quote_token_program`; omit only for a mint in
QUOTE_TOKEN_PROGRAMS
Returns:
The create_v2 instruction, 20 accounts
"""
bonding_curve = find_bonding_curve(mint)
accounts = [
AccountMeta(pubkey=mint, is_signer=True, is_writable=True),
AccountMeta(pubkey=PUMP_MINT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=bonding_curve, is_signer=False, is_writable=True),
AccountMeta(
pubkey=find_associated_token_account(
bonding_curve, mint, TOKEN_2022_PROGRAM
),
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
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=ASSOCIATED_TOKEN_PROGRAM, is_signer=False, is_writable=False
),
AccountMeta(pubkey=MAYHEM_PROGRAM, is_signer=False, is_writable=True),
AccountMeta(pubkey=MAYHEM_GLOBAL_PARAMS, is_signer=False, is_writable=False),
AccountMeta(pubkey=MAYHEM_SOL_VAULT, is_signer=False, is_writable=True),
AccountMeta(pubkey=find_mayhem_state(mint), is_signer=False, is_writable=True),
AccountMeta(
pubkey=find_associated_token_account(
MAYHEM_SOL_VAULT, mint, TOKEN_2022_PROGRAM
),
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),
]
# Accounts 17-20 are remaining accounts: all four or none. They are sent for
# SOL-paired coins too, carrying wrapped SOL, so a 20-account create_v2 is
# not by itself evidence of a non-SOL quote asset -- read quote_mint off the
# curve for that. Omitting them is what silently drops a creator fee.
quote_program = quote_token_program or quote_token_program_for(quote_mint)
accounts += [
AccountMeta(pubkey=quote_mint, is_signer=False, is_writable=False),
AccountMeta(
pubkey=find_associated_token_account(
bonding_curve, quote_mint, quote_program
),
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=quote_program, is_signer=False, is_writable=False),
AccountMeta(pubkey=find_quote_control(), is_signer=False, is_writable=False),
]
def borsh_string(value: str) -> bytes:
encoded = value.encode("utf-8")
return struct.pack("<I", len(encoded)) + encoded
data = (
CREATE_V2_DISCRIMINATOR
+ borsh_string(name)
+ borsh_string(symbol)
+ borsh_string(uri)
+ bytes(creator)
+ struct.pack("<?", is_mayhem_mode)
+ encode_create_v2_trailing_args(
creator_fee_bps=creator_fee_bps, is_holder_reward=is_holder_reward
)
)
return Instruction(PUMP_PROGRAM, data, accounts)
def build_extend_account_instruction(
bonding_curve: Pubkey, user: Pubkey
) -> Instruction:
"""Build extend_account, which takes no arguments.
Growing the curve past the 125 bytes create_v2 allocates is what makes a coin
visible on pump.fun's frontend. The coin trades without it.
Args:
bonding_curve: The coin's bonding curve
user: Wallet paying for the extra space
"""
return Instruction(
PUMP_PROGRAM,
EXTEND_ACCOUNT_DISCRIMINATOR,
[
AccountMeta(pubkey=bonding_curve, 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=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
],
)
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,
quote_token_program_id: Pubkey | None = None,
) -> 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
quote_token_program_id: Token program owning quote_mint. Omit to use
`quote_token_program`'s SPL Token default; pass the result of
`resolve_quote_token_program` for a mint outside that default,
e.g. a Token-2022-paired coin.
Returns:
Ordered AccountMeta list (27 entries for buy_v2, 26 for sell_v2)
"""
quote_program = quote_token_program_id or quote_token_program_for(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,
quote_token_program_id: Pubkey | None = None,
) -> 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
quote_token_program_id: Token program owning quote_mint; see
`build_v2_accounts`
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,
quote_token_program_id=quote_token_program_id,
),
)
def build_buy_exact_quote_in_v2_instruction(
*,
base_mint: Pubkey,
creator: Pubkey,
user: Pubkey,
spendable_quote_in_raw: int,
min_tokens_out_raw: int,
quote_mint: Pubkey = WSOL_MINT,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
quote_token_program_id: Pubkey | None = None,
) -> Instruction:
"""Build a buy_exact_quote_in_v2 instruction.
The mirror of `buy_v2`: that fixes how many tokens you receive and caps the
spend, this fixes the spend and floors what you receive — the natural form
when the quote asset is a budget you hold.
Fees come out of `spendable_quote_in_raw`, so the whole amount leaves the
wallet and the tokens arrive against what is left.
Args:
base_mint: Coin to buy
creator: Coin creator, from bonding_curve.creator
user: Buyer / signer
spendable_quote_in_raw: Exact amount to spend, in the quote mint's raw
units, fees included
min_tokens_out_raw: Floor on base tokens received, in raw units. This is
the slippage protection; the caller owns it.
quote_mint: Normalized quote mint
base_token_program: Token program owning base_mint
is_mayhem_mode: Whether the coin is in mayhem mode
quote_token_program_id: Token program owning quote_mint; see
`build_v2_accounts`
Returns:
The buy_exact_quote_in_v2 instruction
"""
return Instruction(
program_id=PUMP_PROGRAM,
data=BUY_EXACT_QUOTE_IN_V2_DISCRIMINATOR
+ struct.pack("<Q", spendable_quote_in_raw)
+ struct.pack("<Q", min_tokens_out_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,
quote_token_program_id=quote_token_program_id,
),
)
def build_buy_exact_sol_in_instruction(
*,
mint: Pubkey,
creator: Pubkey,
user: Pubkey,
spendable_sol_in_lamports: int,
min_tokens_out_raw: int,
base_token_program: Pubkey = TOKEN_2022_PROGRAM,
is_mayhem_mode: bool = False,
track_volume: bool = True,
) -> Instruction:
"""Build a buy_exact_sol_in instruction.
SOL only. This one pre-dates non-SOL quote assets and has no quote accounts
at all, so it cannot trade a coin paired with USDC, another coin or a
tokenized equity — use `build_buy_exact_quote_in_v2_instruction` for those.
**The IDL lists 16 accounts and the program requires 18.** The two it omits
are the `bonding-curve-v2` PDA and a buyback fee recipient, both writable,
both appended after `fee_program`. Sending the IDL's 16 fails with
AnchorError 6062 (BuybackFeeRecipientMissing), which names the missing
account but not where it goes.
Args:
mint: Coin to buy
creator: Coin creator, from bonding_curve.creator
user: Buyer / signer
spendable_sol_in_lamports: Exact lamports to spend, fees included
min_tokens_out_raw: Floor on base tokens received, in raw units
base_token_program: Token program owning the mint
is_mayhem_mode: Whether the coin is in mayhem mode, which selects the
reserved fee recipient set instead of the normal one
track_volume: Whether to credit the user's volume accumulator
"""
bonding_curve = find_bonding_curve(mint)
accounts = [
AccountMeta(pubkey=PUMP_GLOBAL, is_signer=False, is_writable=False),
AccountMeta(
pubkey=pick_fee_recipient(is_mayhem_mode=is_mayhem_mode),
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=find_associated_token_account(
bonding_curve, mint, base_token_program
),
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=find_associated_token_account(user, mint, base_token_program),
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=base_token_program, is_signer=False, is_writable=False),
AccountMeta(
pubkey=find_creator_vault(creator), 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=True
),
AccountMeta(
pubkey=find_user_volume_accumulator(user), is_signer=False, is_writable=True
),
AccountMeta(pubkey=find_fee_config(), is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_FEE_PROGRAM, is_signer=False, is_writable=False),
# The two the IDL omits.
AccountMeta(
pubkey=Pubkey.find_program_address(
[b"bonding-curve-v2", bytes(mint)], PUMP_PROGRAM
)[0],
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=pick_buyback_fee_recipient(), is_signer=False, is_writable=True
),
]
return Instruction(
program_id=PUMP_PROGRAM,
data=BUY_EXACT_SOL_IN_DISCRIMINATOR
+ struct.pack("<Q", spendable_sol_in_lamports)
+ struct.pack("<Q", min_tokens_out_raw)
# OptionBool is a single-field Anchor struct with no presence tag, so
# it serializes as its bare inner bool.
+ struct.pack("<?", track_volume),
accounts=accounts,
)
def build_collect_creator_fee_v2_instruction(
*,
creator: Pubkey,
quote_mint: Pubkey = WSOL_MINT,
quote_token_program_id: Pubkey | None = None,
) -> Instruction:
"""Build a collect_creator_fee_v2 instruction.
Sweeps whatever has accrued in the creator's vault for one quote asset into
the creator's own token account. Takes no arguments and no signer — the
money can only go to the wallet it already belongs to, so anyone may run it.
A creator vault is per (creator, quote asset), so a creator whose coins are
priced in several assets collects each one separately.
Args:
creator: The coin creator whose vault to sweep
quote_mint: Quote asset to collect, normalized
quote_token_program_id: Token program owning quote_mint; resolved from
the cache when omitted
Returns:
The collect_creator_fee_v2 instruction
"""
program = quote_token_program_id or quote_token_program_for(quote_mint)
vault = find_creator_vault(creator)
accounts = [
AccountMeta(pubkey=creator, is_signer=False, is_writable=True),
AccountMeta(
pubkey=find_associated_token_account(creator, quote_mint, program),
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=vault, is_signer=False, is_writable=True),
AccountMeta(
pubkey=find_associated_token_account(vault, quote_mint, program),
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=quote_mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=program, is_signer=False, is_writable=False),
AccountMeta(
pubkey=ASSOCIATED_TOKEN_PROGRAM, is_signer=False, is_writable=False
),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
]
return Instruction(
program_id=PUMP_PROGRAM,
data=COLLECT_CREATOR_FEE_V2_DISCRIMINATOR,
accounts=accounts,
)
def build_claim_cashback_v2_instruction(
*,
user: Pubkey,
quote_mint: Pubkey = WSOL_MINT,
quote_token_program_id: Pubkey | None = None,
) -> Instruction:
"""Build a claim_cashback_v2 instruction.
Pays out the cashback accrued on the user's volume accumulator. `create_v2`
refuses to mint new cashback coins (error 6082, CashbackDeprecated), but
older coins keep accruing and stay claimable, so this path is still live.
Args:
user: Wallet whose cashback to pay out
quote_mint: Quote asset the cashback accrued in, normalized
quote_token_program_id: Token program owning quote_mint; resolved from
the cache when omitted
Returns:
The claim_cashback_v2 instruction
"""
program = quote_token_program_id or quote_token_program_for(quote_mint)
accumulator = find_user_volume_accumulator(user)
accounts = [
AccountMeta(pubkey=user, is_signer=False, is_writable=True),
AccountMeta(pubkey=accumulator, is_signer=False, is_writable=True),
AccountMeta(pubkey=quote_mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=program, is_signer=False, is_writable=False),
AccountMeta(
pubkey=ASSOCIATED_TOKEN_PROGRAM, is_signer=False, is_writable=False
),
AccountMeta(
pubkey=find_associated_token_account(accumulator, quote_mint, program),
is_signer=False,
is_writable=True,
),
# Any token account of quote_mint owned by the user works here; the
# associated one is the obvious choice.
AccountMeta(
pubkey=find_associated_token_account(user, quote_mint, program),
is_signer=False,
is_writable=True,
),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_PROGRAM, is_signer=False, is_writable=False),
]
return Instruction(
program_id=PUMP_PROGRAM,
data=CLAIM_CASHBACK_V2_DISCRIMINATOR,
accounts=accounts,
)
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,
quote_token_program_id: Pubkey | None = None,
) -> 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
quote_token_program_id: Token program owning quote_mint; see
`build_v2_accounts`
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,
quote_token_program_id=quote_token_program_id,
),
)