feat(trading): sell a held coin after its bonding curve graduates (#237)

A graduated curve takes no trades. Curve managers now report it and refuse a
price, and the trader prices and sells through the platform's post-graduation
market: the canonical PumpSwap pool for pump.fun, the Raydium CPMM pool for
LaunchLab. Both pools are derived from the mint.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-10-04 22:29:08 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 457aeeb6d8
commit b5d0fd7761
23 changed files with 3291 additions and 104 deletions
+4 -4
View File
@@ -178,10 +178,10 @@ in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
- `confirm_transaction` and `verify_transaction_succeeded` deliberately stay
bools. Returning the enum would be silent: every member is truthy, so every
`if await client.confirm_transaction(sig):` would start passing unconditionally.
- `calculate_price` returns `0.0` for a curve with no virtual token reserves — it
does not raise, and the seller rejects it before its own error handling. Never
store a non-positive read as the last known price or floor a sell against one;
`0.0` also satisfies the stop-loss comparison.
- `calculate_price` returns `0.0` for a curve with no virtual token reserves, and
raises `CurveGraduatedError` for a graduated one; price and sell then go through
the platform's `GraduatedMarket`. Never store a non-positive read as the last
known price or floor a sell against one; `0.0` satisfies the stop-loss check.
## Config notes
+2
View File
@@ -164,6 +164,8 @@ SOL, a tokenized stock, another coin. Amounts are in that asset. *Reward* coins
| [`stonkfun/read/stonkfun_read_pool.py`](stonkfun/read/stonkfun_read_pool.py) | Price, quote asset, fees, transfer fee and graduation progress for one coin |
| 💸 [`stonkfun/trade/stonkfun_buy_token.py`](stonkfun/trade/stonkfun_buy_token.py) | Spend an exact amount of the quote asset. `--dry-run` simulates |
| 💸 [`stonkfun/trade/stonkfun_sell_token.py`](stonkfun/trade/stonkfun_sell_token.py) | Sell your position; `--close` also harvests withheld fees and closes the account |
| 💸 [`stonkfun/trade/stonkfun_buy_token_cpmm.py`](stonkfun/trade/stonkfun_buy_token_cpmm.py) | Buy a graduated coin from its Raydium CPMM pool. `--dry-run` simulates |
| 💸 [`stonkfun/trade/stonkfun_sell_token_cpmm.py`](stonkfun/trade/stonkfun_sell_token_cpmm.py) | Sell a graduated coin into its Raydium CPMM pool |
## legacy
+4 -8
View File
@@ -118,14 +118,10 @@ POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
GLOBALCONFIG_DISCRIMINATOR_SIZE = 8
GLOBALCONFIG_ADMIN_SIZE = 32
GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32
GLOBALCONFIG_RESERVED_FEE_OFFSET = (
GLOBALCONFIG_DISCRIMINATOR_SIZE
+ GLOBALCONFIG_ADMIN_SIZE
+ GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE
)
# GlobalConfig.reserved_fee_recipient, the mayhem-mode fee recipient: after the
# discriminator, admin, lp and protocol fee bps (u64 each), disable_flags (u8),
# protocol_fee_recipients[8], coin_creator_fee bps (u64) and two authorities.
GLOBALCONFIG_RESERVED_FEE_OFFSET = 8 + 32 + 8 + 8 + 1 + 8 * 32 + 8 + 32 + 32
# Fee recipients
STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string(
+4 -8
View File
@@ -108,14 +108,10 @@ POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
GLOBALCONFIG_DISCRIMINATOR_SIZE = 8
GLOBALCONFIG_ADMIN_SIZE = 32
GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32
GLOBALCONFIG_RESERVED_FEE_OFFSET = (
GLOBALCONFIG_DISCRIMINATOR_SIZE
+ GLOBALCONFIG_ADMIN_SIZE
+ GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE
)
# GlobalConfig.reserved_fee_recipient, the mayhem-mode fee recipient: after the
# discriminator, admin, lp and protocol fee bps (u64 each), disable_flags (u8),
# protocol_fee_recipients[8], coin_creator_fee bps (u64) and two authorities.
GLOBALCONFIG_RESERVED_FEE_OFFSET = 8 + 32 + 8 + 8 + 1 + 8 * 32 + 8 + 32 + 32
# Fee recipients
STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string(
@@ -0,0 +1,363 @@
"""Buy a graduated StonkFun coin from its Raydium CPMM pool, using swap_base_input.
WARNING: this submits a real transaction and spends real funds.
Usage:
uv run cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py <MINT> <AMOUNT>
uv run cookbook/stonkfun/trade/stonkfun_buy_token_cpmm.py <MINT> 0.001 --dry-run
AMOUNT is in the pool's quote asset and is spent in full. For SOL the script
wraps it into a throwaway account; for anything else you must already hold it.
When a LaunchLab curve raises its target, Raydium migrates the coin to a CPMM
pool and the curve stops taking trades. The pool is not searched for: CPMM
derives it from the AMM config the platform names (`PlatformConfig.cpswap_config`)
and the two mints, ordered by their bytes.
The price is the vault balances less the fees sitting in them unclaimed. The
pool takes its trade fee and, when enabled, a creator fee; a *reward* coin then
loses its transfer fee on the way to you. The expected amount accounts for all
three, the creator fee as if charged on the input, which is close enough for a
slippage floor.
"""
import argparse
import asyncio
import hashlib
import os
import secrets
import struct
import sys
from pathlib import Path
# solana_transaction_status.py lives in cookbook/solana/.
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "solana"))
import base58
import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.core import DataSliceOpts, MemcmpOpts, TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
close_account,
create_idempotent_associated_token_account,
get_associated_token_address,
initialize_account,
)
from spl.token.models import CloseAccountParams, InitializeAccountParams
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
PRIVATE_KEY = os.environ.get("SOLANA_PRIVATE_KEY")
DEFAULT_SLIPPAGE = 0.1
PRIORITY_FEE_MICROLAMPORTS = 1_000
COMPUTE_UNIT_LIMIT = 150_000
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
CPMM_PROGRAM = Pubkey.from_string("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C")
CPMM_AUTHORITY = Pubkey.find_program_address(
[b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM
)[0]
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
SWAP_BASE_INPUT = hashlib.sha256(b"global:swap_base_input").digest()[:8]
TOKEN_ACCOUNT_SIZE = 165
TOKEN_ACCOUNT_RENT = 2_039_280
FEE_RATE_DENOMINATOR = 1_000_000
MIGRATED = 2
# LaunchLab PoolState: discriminator, epoch, auth_bump, status, then after the
# amounts and the vesting schedule, global_config and platform_config.
LAUNCHLAB_POOL_SIZE = 429
LAUNCHLAB_STATUS_OFFSET = 17
LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32
LAUNCHLAB_BASE_MINT_OFFSET = LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32
LAUNCHLAB_QUOTE_MINT_OFFSET = LAUNCHLAB_BASE_MINT_OFFSET + 32
# PlatformConfig.cpswap_config: after the scales, fee_rate, name, web and img.
PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256
# CPMM PoolState: ten pubkeys, five u8, seven u64, two u8, padding, two u64.
CPMM_KEYS = (
"amm_config",
"pool_creator",
"token_0_vault",
"token_1_vault",
"lp_mint",
"token_0_mint",
"token_1_mint",
"token_0_program",
"token_1_program",
"observation_key",
)
CPMM_DECIMALS_OFFSET = 8 + 10 * 32 + 3
CPMM_FEES_OFFSET = 8 + 10 * 32 + 5 + 8
CPMM_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1
CPMM_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6
# AmmConfig: bump, disable flag, index u16, trade_fee_rate, three more u64, two
# owners, creator_fee_rate.
CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2
CONFIG_CREATOR_FEE_OFFSET = CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32
TOKEN_2022_EXTENSIONS_OFFSET = 166
TRANSFER_FEE_CONFIG_EXTENSION = 1
def transfer_fee(mint_data: bytes) -> tuple[int, int]:
"""The higher of a mint's scheduled transfer fees, as (basis points, max raw)."""
offset = TOKEN_2022_EXTENSIONS_OFFSET
while offset + 4 <= len(mint_data):
kind, length = struct.unpack_from("<HH", mint_data, offset)
if kind == 0 and length == 0:
break
if kind == TRANSFER_FEE_CONFIG_EXTENSION:
body = mint_data[offset + 4 : offset + 4 + length]
_, older_max, older_bps = struct.unpack_from("<QQH", body, 72)
_, newer_max, newer_bps = struct.unpack_from("<QQH", body, 90)
return max((older_bps, older_max), (newer_bps, newer_max))
offset += 4 + length
return 0, 0
async def find_cpmm_pool(client: AsyncClient, mint: Pubkey) -> tuple[Pubkey, dict]:
"""Find a graduated LaunchLab coin's CPMM pool, oriented around the coin.
Raises:
ValueError: If the coin is not a LaunchLab coin or has not migrated
"""
found = await client.get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
LAUNCHLAB_POOL_SIZE,
MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
if not found.value:
raise ValueError(f"No LaunchLab pool found for {mint}")
curve = (await client.get_account_info(found.value[0].pubkey)).value.data
if curve[LAUNCHLAB_STATUS_OFFSET] != MIGRATED:
raise ValueError(f"{mint} is still on its curve — use stonkfun_buy_token.py")
platform_config = Pubkey.from_bytes(
curve[LAUNCHLAB_PLATFORM_CONFIG_OFFSET : LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32]
)
quote_mint = Pubkey.from_bytes(
curve[LAUNCHLAB_QUOTE_MINT_OFFSET : LAUNCHLAB_QUOTE_MINT_OFFSET + 32]
)
platform = (await client.get_account_info(platform_config)).value.data
amm_config = Pubkey.from_bytes(
platform[PLATFORM_CPSWAP_CONFIG_OFFSET : PLATFORM_CPSWAP_CONFIG_OFFSET + 32]
)
token_0, token_1 = sorted([mint, quote_mint], key=bytes)
address = Pubkey.find_program_address(
[b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM
)[0]
data = (await client.get_account_info(address)).value.data
keys = {
name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)])
for i, name in enumerate(CPMM_KEYS)
}
base, quote = (0, 1) if keys["token_0_mint"] == mint else (1, 0)
fees = struct.unpack_from("<4Q", data, CPMM_FEES_OFFSET)
creator_fees = struct.unpack_from("<2Q", data, CPMM_CREATOR_FEES_OFFSET)
accrued = [fees[i] + fees[2 + i] + creator_fees[i] for i in (0, 1)]
return address, {
"amm_config": keys["amm_config"],
"observation": keys["observation_key"],
"base_mint": mint,
"quote_mint": keys[f"token_{quote}_mint"],
"base_vault": keys[f"token_{base}_vault"],
"quote_vault": keys[f"token_{quote}_vault"],
"base_program": keys[f"token_{base}_program"],
"quote_program": keys[f"token_{quote}_program"],
"base_decimals": data[CPMM_DECIMALS_OFFSET + base],
"quote_decimals": data[CPMM_DECIMALS_OFFSET + quote],
"base_accrued": accrued[base],
"quote_accrued": accrued[quote],
"creator_fee_enabled": bool(data[CPMM_CREATOR_FEE_FLAG_OFFSET]),
}
async def buy(mint: Pubkey, amount: float, slippage: float, *, dry_run: bool) -> None:
"""Spend exactly `amount` of the pool's quote asset on the coin."""
payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY))
user = payer.pubkey()
async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client:
address, pool = await find_cpmm_pool(client, mint)
quote_unit = 10 ** pool["quote_decimals"]
base_unit = 10 ** pool["base_decimals"]
amount_in = int(amount * quote_unit)
config, base_vault, quote_vault, mint_info = (
await client.get_multiple_accounts(
[pool["amm_config"], pool["base_vault"], pool["quote_vault"], mint]
)
).value
fee_rate = struct.unpack_from("<Q", config.data, CONFIG_TRADE_FEE_OFFSET)[0]
if pool["creator_fee_enabled"]:
fee_rate += struct.unpack_from(
"<Q", config.data, CONFIG_CREATOR_FEE_OFFSET
)[0]
base_reserve = (
struct.unpack_from("<Q", base_vault.data, 64)[0] - pool["base_accrued"]
)
quote_reserve = (
struct.unpack_from("<Q", quote_vault.data, 64)[0] - pool["quote_accrued"]
)
net_in = amount_in - -(-amount_in * fee_rate // FEE_RATE_DENOMINATOR)
out = base_reserve * net_in // (quote_reserve + net_in)
bps, max_fee = transfer_fee(mint_info.data)
expected = out - (min(-(-out * bps // 10_000), max_fee) if bps else 0)
minimum_out = int(expected * (1 - slippage))
print(f"Mint: {mint}")
print(f"CPMM pool: {address}")
print(f"Quote asset: {pool['quote_mint']}")
print(f"Spending: {amount} ({amount_in} raw)")
print(f"Pool fees: {fee_rate / FEE_RATE_DENOMINATOR:.2%}")
print(f"Transfer tax: {bps / 100:.2f}%")
print(f"Expecting: ~{expected / base_unit:,.6f} tokens")
print(f"Accepting: >= {minimum_out / base_unit:,.6f} tokens")
user_base = get_associated_token_address(user, mint, pool["base_program"])
instructions = [
set_compute_unit_limit(COMPUTE_UNIT_LIMIT),
set_compute_unit_price(PRIORITY_FEE_MICROLAMPORTS),
create_idempotent_associated_token_account(
user, user, mint, token_program_id=pool["base_program"]
),
]
close_after = []
if pool["quote_mint"] == WSOL_MINT:
seed = secrets.token_hex(16)
user_quote = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM)
instructions += [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=user_quote,
base=user,
seed=seed,
lamports=amount_in + TOKEN_ACCOUNT_RENT,
space=TOKEN_ACCOUNT_SIZE,
owner=TOKEN_PROGRAM,
)
),
initialize_account(
InitializeAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote,
mint=WSOL_MINT,
owner=user,
)
),
]
close_after.append(
close_account(
CloseAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote,
dest=user,
owner=user,
)
)
)
else:
user_quote = get_associated_token_address(
user, pool["quote_mint"], pool["quote_program"]
)
held = 0
if (await client.get_account_info(user_quote)).value is not None:
held = int(
(await client.get_token_account_balance(user_quote)).value.amount
)
if held < amount_in:
raise ValueError(
f"Need {amount_in} raw units of {pool['quote_mint']}, hold {held}"
)
accounts = [
AccountMeta(user, is_signer=True, is_writable=True),
AccountMeta(CPMM_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pool["amm_config"], is_signer=False, is_writable=False),
AccountMeta(address, is_signer=False, is_writable=True),
AccountMeta(user_quote, is_signer=False, is_writable=True),
AccountMeta(user_base, is_signer=False, is_writable=True),
AccountMeta(pool["quote_vault"], is_signer=False, is_writable=True),
AccountMeta(pool["base_vault"], is_signer=False, is_writable=True),
AccountMeta(pool["quote_program"], is_signer=False, is_writable=False),
AccountMeta(pool["base_program"], is_signer=False, is_writable=False),
AccountMeta(pool["quote_mint"], is_signer=False, is_writable=False),
AccountMeta(mint, is_signer=False, is_writable=False),
AccountMeta(pool["observation"], is_signer=False, is_writable=True),
]
data = SWAP_BASE_INPUT + struct.pack("<QQ", amount_in, minimum_out)
instructions += [Instruction(CPMM_PROGRAM, data, accounts), *close_after]
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(user, instructions, [], blockhash), [payer]
)
if dry_run:
result = await client.simulate_transaction(transaction)
print(f"\nSimulated: error={result.value.err}")
print(f"Compute units: {result.value.units_consumed}")
if result.value.err:
for line in result.value.logs or []:
print(f" {line}")
return
signature = (
await client.send_transaction(
transaction,
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
await tx_status.confirm_and_assert(client, signature)
print("Confirmed")
def main() -> None:
"""Parse the command line and run the buy."""
parser = argparse.ArgumentParser(
description="Buy a graduated StonkFun coin from its Raydium CPMM pool"
)
parser.add_argument("mint", help="The coin's mint address")
parser.add_argument(
"amount", type=float, help="Amount to spend, in the quote asset"
)
parser.add_argument(
"--slippage",
type=float,
default=DEFAULT_SLIPPAGE,
help=f"How far below the expected token count to accept (default {DEFAULT_SLIPPAGE})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the buy instead of submitting it — spends nothing",
)
args = parser.parse_args()
asyncio.run(
buy(
Pubkey.from_string(args.mint),
args.amount,
args.slippage,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,366 @@
"""Sell a graduated StonkFun coin into its Raydium CPMM pool, using swap_base_input.
WARNING: this submits a real transaction and spends real funds.
Usage:
uv run cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py <MINT>
uv run cookbook/stonkfun/trade/stonkfun_sell_token_cpmm.py <MINT> --amount 1000 --dry-run
When a LaunchLab curve raises its target, Raydium migrates the coin to a CPMM
pool and the curve stops taking trades. The pool is not searched for: CPMM
derives it from the AMM config the platform names (`PlatformConfig.cpswap_config`)
and the two mints, ordered by their bytes.
The price is the vault balances less the fees sitting in them unclaimed. The
pool charges its trade fee and, when enabled, a creator fee; a *reward* coin
also loses its transfer fee on the way into the pool. The expected payout
accounts for all three. SOL proceeds arrive through a throwaway wrapped-SOL
account closed in the same transaction.
"""
import argparse
import asyncio
import hashlib
import os
import secrets
import struct
import sys
from pathlib import Path
# solana_transaction_status.py lives in cookbook/solana/.
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "solana"))
import base58
import solana_transaction_status as tx_status
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.core import DataSliceOpts, MemcmpOpts, TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from solders.transaction import VersionedTransaction
from spl.token.instructions import (
close_account,
create_idempotent_associated_token_account,
get_associated_token_address,
initialize_account,
)
from spl.token.models import CloseAccountParams, InitializeAccountParams
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
PRIVATE_KEY = os.environ.get("SOLANA_PRIVATE_KEY")
DEFAULT_SLIPPAGE = 0.1
PRIORITY_FEE_MICROLAMPORTS = 1_000
COMPUTE_UNIT_LIMIT = 150_000
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
CPMM_PROGRAM = Pubkey.from_string("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C")
CPMM_AUTHORITY = Pubkey.find_program_address(
[b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM
)[0]
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
SWAP_BASE_INPUT = hashlib.sha256(b"global:swap_base_input").digest()[:8]
TOKEN_ACCOUNT_SIZE = 165
TOKEN_ACCOUNT_RENT = 2_039_280
FEE_RATE_DENOMINATOR = 1_000_000
MIGRATED = 2
# LaunchLab PoolState: discriminator, epoch, auth_bump, status, then after the
# amounts and the vesting schedule, global_config and platform_config.
LAUNCHLAB_POOL_SIZE = 429
LAUNCHLAB_STATUS_OFFSET = 17
LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32
LAUNCHLAB_BASE_MINT_OFFSET = LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32
LAUNCHLAB_QUOTE_MINT_OFFSET = LAUNCHLAB_BASE_MINT_OFFSET + 32
# PlatformConfig.cpswap_config: after the scales, fee_rate, name, web and img.
PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256
# CPMM PoolState: ten pubkeys, five u8, seven u64, two u8, padding, two u64.
CPMM_KEYS = (
"amm_config",
"pool_creator",
"token_0_vault",
"token_1_vault",
"lp_mint",
"token_0_mint",
"token_1_mint",
"token_0_program",
"token_1_program",
"observation_key",
)
CPMM_DECIMALS_OFFSET = 8 + 10 * 32 + 3
CPMM_FEES_OFFSET = 8 + 10 * 32 + 5 + 8
CPMM_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1
CPMM_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6
# AmmConfig: bump, disable flag, index u16, trade_fee_rate, three more u64, two
# owners, creator_fee_rate.
CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2
CONFIG_CREATOR_FEE_OFFSET = CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32
TOKEN_2022_EXTENSIONS_OFFSET = 166
TRANSFER_FEE_CONFIG_EXTENSION = 1
def transfer_fee(mint_data: bytes) -> tuple[int, int]:
"""The higher of a mint's scheduled transfer fees, as (basis points, max raw)."""
offset = TOKEN_2022_EXTENSIONS_OFFSET
while offset + 4 <= len(mint_data):
kind, length = struct.unpack_from("<HH", mint_data, offset)
if kind == 0 and length == 0:
break
if kind == TRANSFER_FEE_CONFIG_EXTENSION:
body = mint_data[offset + 4 : offset + 4 + length]
_, older_max, older_bps = struct.unpack_from("<QQH", body, 72)
_, newer_max, newer_bps = struct.unpack_from("<QQH", body, 90)
return max((older_bps, older_max), (newer_bps, newer_max))
offset += 4 + length
return 0, 0
async def find_cpmm_pool(client: AsyncClient, mint: Pubkey) -> tuple[Pubkey, dict]:
"""Find a graduated LaunchLab coin's CPMM pool, oriented around the coin.
Raises:
ValueError: If the coin is not a LaunchLab coin or has not migrated
"""
found = await client.get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
LAUNCHLAB_POOL_SIZE,
MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
if not found.value:
raise ValueError(f"No LaunchLab pool found for {mint}")
curve = (await client.get_account_info(found.value[0].pubkey)).value.data
if curve[LAUNCHLAB_STATUS_OFFSET] != MIGRATED:
raise ValueError(f"{mint} is still on its curve — use stonkfun_sell_token.py")
platform_config = Pubkey.from_bytes(
curve[LAUNCHLAB_PLATFORM_CONFIG_OFFSET : LAUNCHLAB_PLATFORM_CONFIG_OFFSET + 32]
)
quote_mint = Pubkey.from_bytes(
curve[LAUNCHLAB_QUOTE_MINT_OFFSET : LAUNCHLAB_QUOTE_MINT_OFFSET + 32]
)
platform = (await client.get_account_info(platform_config)).value.data
amm_config = Pubkey.from_bytes(
platform[PLATFORM_CPSWAP_CONFIG_OFFSET : PLATFORM_CPSWAP_CONFIG_OFFSET + 32]
)
token_0, token_1 = sorted([mint, quote_mint], key=bytes)
address = Pubkey.find_program_address(
[b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM
)[0]
data = (await client.get_account_info(address)).value.data
keys = {
name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)])
for i, name in enumerate(CPMM_KEYS)
}
base, quote = (0, 1) if keys["token_0_mint"] == mint else (1, 0)
fees = struct.unpack_from("<4Q", data, CPMM_FEES_OFFSET)
creator_fees = struct.unpack_from("<2Q", data, CPMM_CREATOR_FEES_OFFSET)
accrued = [fees[i] + fees[2 + i] + creator_fees[i] for i in (0, 1)]
return address, {
"amm_config": keys["amm_config"],
"observation": keys["observation_key"],
"base_mint": mint,
"quote_mint": keys[f"token_{quote}_mint"],
"base_vault": keys[f"token_{base}_vault"],
"quote_vault": keys[f"token_{quote}_vault"],
"base_program": keys[f"token_{base}_program"],
"quote_program": keys[f"token_{quote}_program"],
"base_decimals": data[CPMM_DECIMALS_OFFSET + base],
"quote_decimals": data[CPMM_DECIMALS_OFFSET + quote],
"base_accrued": accrued[base],
"quote_accrued": accrued[quote],
"creator_fee_enabled": bool(data[CPMM_CREATOR_FEE_FLAG_OFFSET]),
}
async def sell(
mint: Pubkey, amount: float | None, slippage: float, *, dry_run: bool
) -> None:
"""Sell `amount` tokens, or the whole balance, into the coin's CPMM pool."""
payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY))
user = payer.pubkey()
async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client:
address, pool = await find_cpmm_pool(client, mint)
user_base = get_associated_token_address(user, mint, pool["base_program"])
held = int((await client.get_token_account_balance(user_base)).value.amount)
base_unit = 10 ** pool["base_decimals"]
amount_in = held if amount is None else int(amount * base_unit)
if amount_in <= 0 or amount_in > held:
raise ValueError(f"Cannot sell {amount_in} raw; you hold {held}")
config, base_vault, quote_vault, mint_info = (
await client.get_multiple_accounts(
[pool["amm_config"], pool["base_vault"], pool["quote_vault"], mint]
)
).value
fee_rate = struct.unpack_from("<Q", config.data, CONFIG_TRADE_FEE_OFFSET)[0]
if pool["creator_fee_enabled"]:
fee_rate += struct.unpack_from(
"<Q", config.data, CONFIG_CREATOR_FEE_OFFSET
)[0]
base_reserve = (
struct.unpack_from("<Q", base_vault.data, 64)[0] - pool["base_accrued"]
)
quote_reserve = (
struct.unpack_from("<Q", quote_vault.data, 64)[0] - pool["quote_accrued"]
)
bps, max_fee = transfer_fee(mint_info.data)
arrived = amount_in - (
min(-(-amount_in * bps // 10_000), max_fee) if bps else 0
)
gross = quote_reserve * arrived // (base_reserve + arrived)
expected = gross - -(-gross * fee_rate // FEE_RATE_DENOMINATOR)
minimum_out = max(1, int(expected * (1 - slippage)))
quote_unit = 10 ** pool["quote_decimals"]
print(f"Mint: {mint}")
print(f"CPMM pool: {address}")
print(f"Quote asset: {pool['quote_mint']}")
print(f"Selling: {amount_in / base_unit:,.6f} tokens")
print(f"Pool fees: {fee_rate / FEE_RATE_DENOMINATOR:.2%}")
print(f"Transfer tax: {bps / 100:.2f}%")
print(f"Expecting: ~{expected / quote_unit:.10f} quote")
print(f"Accepting: >= {minimum_out / quote_unit:.10f} quote")
instructions = [
set_compute_unit_limit(COMPUTE_UNIT_LIMIT),
set_compute_unit_price(PRIORITY_FEE_MICROLAMPORTS),
]
close_after = []
if pool["quote_mint"] == WSOL_MINT:
seed = secrets.token_hex(16)
user_quote = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM)
instructions += [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=user_quote,
base=user,
seed=seed,
lamports=TOKEN_ACCOUNT_RENT,
space=TOKEN_ACCOUNT_SIZE,
owner=TOKEN_PROGRAM,
)
),
initialize_account(
InitializeAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote,
mint=WSOL_MINT,
owner=user,
)
),
]
close_after.append(
close_account(
CloseAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote,
dest=user,
owner=user,
)
)
)
else:
user_quote = get_associated_token_address(
user, pool["quote_mint"], pool["quote_program"]
)
instructions.append(
create_idempotent_associated_token_account(
user,
user,
pool["quote_mint"],
token_program_id=pool["quote_program"],
)
)
accounts = [
AccountMeta(user, is_signer=True, is_writable=True),
AccountMeta(CPMM_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pool["amm_config"], is_signer=False, is_writable=False),
AccountMeta(address, is_signer=False, is_writable=True),
AccountMeta(user_base, is_signer=False, is_writable=True),
AccountMeta(user_quote, is_signer=False, is_writable=True),
AccountMeta(pool["base_vault"], is_signer=False, is_writable=True),
AccountMeta(pool["quote_vault"], is_signer=False, is_writable=True),
AccountMeta(pool["base_program"], is_signer=False, is_writable=False),
AccountMeta(pool["quote_program"], is_signer=False, is_writable=False),
AccountMeta(mint, is_signer=False, is_writable=False),
AccountMeta(pool["quote_mint"], is_signer=False, is_writable=False),
AccountMeta(pool["observation"], is_signer=False, is_writable=True),
]
data = SWAP_BASE_INPUT + struct.pack("<QQ", amount_in, minimum_out)
instructions += [Instruction(CPMM_PROGRAM, data, accounts), *close_after]
blockhash = (await client.get_latest_blockhash()).value.blockhash
transaction = VersionedTransaction(
MessageV0.try_compile(user, instructions, [], blockhash), [payer]
)
if dry_run:
result = await client.simulate_transaction(transaction)
print(f"\nSimulated: error={result.value.err}")
print(f"Compute units: {result.value.units_consumed}")
if result.value.err:
for line in result.value.logs or []:
print(f" {line}")
return
signature = (
await client.send_transaction(
transaction,
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
)
).value
print(f"Sent: https://explorer.solana.com/tx/{signature}")
await tx_status.confirm_and_assert(client, signature)
print("Confirmed")
def main() -> None:
"""Parse the command line and run the sell."""
parser = argparse.ArgumentParser(
description="Sell a graduated StonkFun coin into its Raydium CPMM pool"
)
parser.add_argument("mint", help="The coin's mint address")
parser.add_argument(
"--amount",
type=float,
default=None,
help="Tokens to sell, in whole tokens (default: your whole balance)",
)
parser.add_argument(
"--slippage",
type=float,
default=DEFAULT_SLIPPAGE,
help=f"How far below the expected payout to accept (default {DEFAULT_SLIPPAGE})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the sell instead of submitting it — spends nothing",
)
args = parser.parse_args()
asyncio.run(
sell(
Pubkey.from_string(args.mint),
args.amount,
args.slippage,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()
+19 -2
View File
@@ -80,8 +80,25 @@ StonkFun reward coins are Token-2022 mints with a 1% or 3% transfer fee.
first. Standard coins' mints have no transfer-fee extension, and harvesting
against one fails, so only harvest when the account holds withheld fees.
## Graduation
When a curve raises its target, Raydium's migrate wallet moves it to a pool, and
the curve takes no more trades. `PoolState.status` is 1 while it waits and 2 once
migrated; a migrated pool keeps its final reserves, so the curve manager reports
`graduated` and `calculate_price` raises rather than return a frozen price.
`migrate_type` 1 means Raydium CPMM, which is where every StonkFun coin goes. The
CPMM pool is `PDA(["pool", amm_config, token_0, token_1])` under the CPMM program,
with `amm_config` the platform's `PlatformConfig.cpswap_config` and the two mints
ordered by their bytes, so it is derived, never searched for. A sell is
`swap_base_input`, 13 accounts; the creator fee accrues inside the pool and adds
none. Price is the vault balances less the protocol, fund and creator fees still
sitting in them. `migrate_type` 0, Raydium AMM v4, has no market in the bot; a
coin there is reported, not sold.
## Verifiers
`verify_launchlab_trades`, `verify_launchlab_launch_parsing` and
`verify_cleanup_harvests_withheld_fees` cover all of the above against captured
`verify_launchlab_trades`, `verify_launchlab_launch_parsing`,
`verify_cleanup_harvests_withheld_fees` and `verify_sell_after_graduation` cover
all of the above against captured
mainnet transactions.
+13
View File
@@ -115,6 +115,19 @@ The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-publi
byte-identical to upstream, so it cannot be the reference: it lists 23 for `buy`
against 25-26 on chain, and 21 for `sell` against 23-26.
## Graduation
A complete curve has handed its reserves to PumpSwap: every reserve reads zero
and `complete` is set. The curve manager reports `graduated` instead of raising
on the zero reserves, and `calculate_price` raises `CurveGraduatedError`.
The pool a migration creates is canonical and derived, not searched for:
`PDA(["pool", u16 0, pool_authority, base_mint, quote_mint])` under pump-amm, with
`pool_authority = PDA(["pool-authority", base_mint])` under the pump.fun program.
`platforms/pumpfun/pumpswap_market.py` sells into it with the account rules
above, taking the fee, mayhem and buyback recipients from PumpSwap's
`GlobalConfig` — the mayhem one is `reserved_fee_recipient`, at offset 385.
## Coin creation
- `create_v2` takes **eight args**: `name (str), symbol (str), uri (str),
+1
View File
@@ -42,6 +42,7 @@ individual scripts for a subset.
| `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx |
| `verify_no_rpc_credentials_logged.py` | credentials masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first |
| `verify_prices_without_hardcoded_sol_unit.py` | nothing in `core/`, `trading/` or `platforms/` scales an amount by `LAMPORTS_PER_SOL` |
| `verify_sell_after_graduation.py` | a graduated pump.fun curve and a migrated LaunchLab pool report `graduated` and refuse a price; the PumpSwap and CPMM pools derive from the mint; each market's sell matches a real sell on chain; prices come from the pools; the seller and the trader's price read switch to the market, and a pool not created yet sends nothing |
| `verify_pumpswap_account_layout.py` | pump-amm's `pool-v2` account is gated on `coin_creator`, the buyback pair stays last, and base-token decimals are resolved rather than assumed; `--live` re-reads the authorized recipients from `GlobalConfig` |
| `verify_launchlab_trades.py` | against real StonkFun trades, the LaunchLab curve math reproduces each buy and sell to the unit — fee rate read from the configs, transfer fee from the mint; price comes from virtual plus real reserves; the swap names the on-chain 18 accounts with fee vaults keyed by the pool's quote mint; an exact-in buy gets the configured amount unpadded; buy and sell floors are net of curve fee and transfer fee; single-token mode waits past a coin its filters refuse instead of exiting on it |
| `verify_launchlab_launch_parsing.py` | legacy, v0 and v1 StonkFun launches parse from every account slot over blockSubscribe and geyser alike, lookup-table accounts included; the creator slot wins over the payer; a router-issued launch is found; letsbonk and StonkFun each keep to their own platform configs; LaunchLab listeners subscribe on platform configs, pump.fun on its program |
+81
View File
@@ -0,0 +1,81 @@
"""The token account a trade's quote side settles through."""
import secrets
from solders.instruction import Instruction
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from spl.token.instructions import (
close_account,
create_idempotent_associated_token_account,
get_associated_token_address,
initialize_account,
)
from spl.token.models import CloseAccountParams, InitializeAccountParams
from core.pubkeys import (
TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE,
TOKEN_ACCOUNT_SIZE,
WSOL_MINT,
SystemAddresses,
)
def quote_settlement_account(
user: Pubkey, quote_mint: Pubkey, quote_token_program: Pubkey, funding: int = 0
) -> tuple[Pubkey, list[Instruction], list[Instruction]]:
"""The account to pay from or into, with its setup and teardown instructions.
Wrapped SOL settles through a throwaway account at a fresh seed, funded with
`funding` lamports and closed in the same transaction, so proceeds arrive as
SOL and any WSOL the wallet already holds is left alone. Any other quote
uses the wallet's ATA, created if missing; buying with it needs the balance
already there.
Args:
funding: Lamports to wrap, for a buy; 0 for a sell
Returns:
(account, instructions before the trade, instructions after it)
"""
if quote_mint != WSOL_MINT:
create_ata = create_idempotent_associated_token_account(
user, user, quote_mint, quote_token_program
)
ata = get_associated_token_address(user, quote_mint, quote_token_program)
return ata, [create_ata], []
seed = secrets.token_hex(16)
wsol_account = Pubkey.create_with_seed(user, seed, SystemAddresses.TOKEN_PROGRAM)
open_ixs = [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=wsol_account,
base=user,
seed=seed,
lamports=funding + TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE,
space=TOKEN_ACCOUNT_SIZE,
owner=SystemAddresses.TOKEN_PROGRAM,
)
),
initialize_account(
InitializeAccountParams(
program_id=SystemAddresses.TOKEN_PROGRAM,
account=wsol_account,
mint=WSOL_MINT,
owner=user,
)
),
]
close_ixs = [
close_account(
CloseAccountParams(
program_id=SystemAddresses.TOKEN_PROGRAM,
account=wsol_account,
dest=user,
owner=user,
)
)
]
return wsol_account, open_ixs, close_ixs
+54
View File
@@ -292,6 +292,60 @@ class InstructionBuilder(ABC):
pass
class CurveGraduatedError(Exception):
"""The bonding curve has graduated and takes no more trades.
Raised by `CurveManager.calculate_price`; the coin's price is now its
`GraduatedMarket`'s.
"""
class GraduatedMarket(ABC):
"""The AMM pool a coin trades in once its bonding curve has graduated.
A curve that has graduated can no longer be sold into. Its sell methods
match `InstructionBuilder`'s, so the seller swaps one for the other.
"""
@abstractmethod
async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]:
"""Read the pool the coin migrated to.
Returns:
At least `price_per_token`, in whole quote units per whole token,
and `fee_fraction`, the share of a sell's proceeds taken as fees
Raises:
ValueError: If the pool does not exist yet — migration lands a
moment after graduation — or cannot be read
"""
@abstractmethod
async def build_sell_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build a sell of exactly `amount_in` raw base tokens into the pool.
Args:
minimum_amount_out: Raw quote units that must be paid out
"""
@abstractmethod
def get_required_accounts_for_sell(
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
"""Writable accounts a sell locks, for priority-fee estimation."""
@abstractmethod
def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int:
"""Compute units for a sell into the pool."""
class CurveManager(ABC):
"""Abstract interface for platform-specific price calculations and pool state management."""
+13
View File
@@ -8,6 +8,7 @@ from interfaces.core import (
AddressProvider,
CurveManager,
EventParser,
GraduatedMarket,
InstructionBuilder,
Platform,
)
@@ -25,6 +26,8 @@ class PlatformImplementations:
instruction_builder: InstructionBuilder
curve_manager: CurveManager
event_parser: EventParser
# Where the coin trades once its curve graduates; None where unsupported.
graduated_market: GraduatedMarket | None = None
class PlatformRegistry:
@@ -41,6 +44,7 @@ class PlatformRegistry:
instruction_builder_class: type[InstructionBuilder],
curve_manager_class: type[CurveManager],
event_parser_class: type[EventParser],
graduated_market_class: type[GraduatedMarket] | None = None,
) -> None:
"""Register platform implementations.
@@ -52,6 +56,7 @@ class PlatformRegistry:
"instruction_builder": instruction_builder_class,
"curve_manager": curve_manager_class,
"event_parser": event_parser_class,
"graduated_market": graduated_market_class,
}
def create_platform_implementations(
@@ -112,11 +117,13 @@ class PlatformRegistry:
curve_manager = impl_classes["curve_manager"](client)
event_parser = impl_classes["event_parser"]()
market_class = impl_classes["graduated_market"]
implementations = PlatformImplementations(
address_provider=address_provider,
instruction_builder=instruction_builder,
curve_manager=curve_manager,
event_parser=event_parser,
graduated_market=market_class(client) if market_class else None,
)
self._instances[cache_key] = implementations
@@ -192,6 +199,7 @@ class PlatformFactory:
PumpFunEventParser,
PumpFunInstructionBuilder,
)
from platforms.pumpfun.pumpswap_market import PumpSwapMarket
self.registry.register_platform(
Platform.PUMP_FUN,
@@ -199,6 +207,7 @@ class PlatformFactory:
PumpFunInstructionBuilder,
PumpFunCurveManager,
PumpFunEventParser,
PumpSwapMarket,
)
except ImportError as e:
@@ -212,6 +221,7 @@ class PlatformFactory:
LetsBonkEventParser,
LetsBonkInstructionBuilder,
)
from platforms.launchlab.cpmm_market import CpmmMarket
self.registry.register_platform(
Platform.LETS_BONK,
@@ -219,6 +229,7 @@ class PlatformFactory:
LetsBonkInstructionBuilder,
LetsBonkCurveManager,
LetsBonkEventParser,
CpmmMarket,
)
except ImportError as e:
@@ -231,6 +242,7 @@ class PlatformFactory:
StonkFunEventParser,
StonkFunInstructionBuilder,
)
from platforms.launchlab.cpmm_market import CpmmMarket
self.registry.register_platform(
Platform.STONK_FUN,
@@ -238,6 +250,7 @@ class PlatformFactory:
StonkFunInstructionBuilder,
StonkFunCurveManager,
StonkFunEventParser,
CpmmMarket,
)
except ImportError as e:
+277
View File
@@ -0,0 +1,277 @@
"""Raydium CPMM, where a LaunchLab pool migrates when its curve graduates."""
import hashlib
import struct
from dataclasses import dataclass
from typing import Any, Final
from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
from spl.token.instructions import get_associated_token_address
from core.client import SolanaClient
from core.pubkeys import normalize_quote_mint
from core.quote_account import quote_settlement_account
from interfaces.core import AddressProvider, GraduatedMarket, TokenInfo
CPMM_PROGRAM: Final[Pubkey] = Pubkey.from_string(
"CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C"
)
_AUTHORITY: Final[Pubkey] = Pubkey.find_program_address(
[b"vault_and_lp_mint_auth_seed"], CPMM_PROGRAM
)[0]
_SWAP_BASE_INPUT: Final[bytes] = hashlib.sha256(b"global:swap_base_input").digest()[:8]
FEE_RATE_DENOMINATOR = 1_000_000
# PlatformConfig.cpswap_config: discriminator, epoch, two wallets, three u64
# scales, fee_rate, name[64], web[256], img[256].
_PLATFORM_CPSWAP_CONFIG_OFFSET = 8 + 8 + 32 + 32 + 3 * 8 + 8 + 64 + 256 + 256
# LaunchLab PoolState.migrate_type: discriminator, epoch, auth_bump, status and
# the two decimals bytes. 0 migrates to AMM v4, 1 to CPMM.
_LAUNCHLAB_MIGRATE_TYPE_OFFSET = 8 + 8 + 4
_MIGRATE_TO_AMM_V4 = 0
# LaunchLab PoolState.platform_config: discriminator, epoch, five u8 flags, ten
# u64 amounts, a five-u64 vesting schedule, global_config.
_LAUNCHLAB_PLATFORM_CONFIG_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 32
# CPMM PoolState: discriminator, then ten pubkeys, five u8, seven u64, two u8,
# six bytes of padding and two u64 creator-fee counters.
_POOL_KEYS = (
"amm_config",
"pool_creator",
"token_0_vault",
"token_1_vault",
"lp_mint",
"token_0_mint",
"token_1_mint",
"token_0_program",
"token_1_program",
"observation_key",
)
_POOL_DECIMALS_OFFSET = 8 + 10 * 32 + 3
_POOL_FEES_OFFSET = 8 + 10 * 32 + 5 + 8
_POOL_CREATOR_FEE_FLAG_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 1
_POOL_CREATOR_FEES_OFFSET = 8 + 10 * 32 + 5 + 7 * 8 + 2 + 6
# AmmConfig: discriminator, bump, disable_create_pool, index u16, trade_fee_rate,
# three more u64, two owners, creator_fee_rate.
_CONFIG_TRADE_FEE_OFFSET = 8 + 1 + 1 + 2
_CONFIG_CREATOR_FEE_OFFSET = _CONFIG_TRADE_FEE_OFFSET + 4 * 8 + 2 * 32
# SPL token account amount.
_TOKEN_AMOUNT_OFFSET = 64
@dataclass(frozen=True)
class CpmmPool:
"""One CPMM pool, oriented around the coin being sold."""
address: Pubkey
amm_config: Pubkey
observation: Pubkey
base_mint: Pubkey
quote_mint: Pubkey
base_vault: Pubkey
quote_vault: Pubkey
base_program: Pubkey
quote_program: Pubkey
base_decimals: int
quote_decimals: int
base_is_token_0: bool
def cpmm_pool_address(amm_config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey:
"""CPMM orders a pair's mints by their raw bytes before deriving the pool."""
token_0, token_1 = sorted([mint_a, mint_b], key=bytes)
return Pubkey.find_program_address(
[b"pool", bytes(amm_config), bytes(token_0), bytes(token_1)], CPMM_PROGRAM
)[0]
def decode_pool(address: Pubkey, data: bytes, base_mint: Pubkey) -> CpmmPool:
"""Decode a CPMM PoolState, oriented so `base` is `base_mint`."""
keys = {
name: Pubkey.from_bytes(data[8 + 32 * i : 8 + 32 * (i + 1)])
for i, name in enumerate(_POOL_KEYS)
}
if base_mint not in (keys["token_0_mint"], keys["token_1_mint"]):
raise ValueError(f"CPMM pool {address} does not trade {base_mint}") # noqa: TRY003
base = 0 if keys["token_0_mint"] == base_mint else 1
quote = 1 - base
def pick(field: str, index: int) -> Pubkey:
return keys[f"token_{index}_{field}"]
decimals = data[_POOL_DECIMALS_OFFSET : _POOL_DECIMALS_OFFSET + 2]
return CpmmPool(
address=address,
amm_config=keys["amm_config"],
observation=keys["observation_key"],
base_mint=base_mint,
quote_mint=pick("mint", quote),
base_vault=pick("vault", base),
quote_vault=pick("vault", quote),
base_program=pick("program", base),
quote_program=pick("program", quote),
base_decimals=decimals[base],
quote_decimals=decimals[quote],
base_is_token_0=base == 0,
)
def accrued_fees(data: bytes, base_is_token_0: bool) -> tuple[int, int]:
"""Fees sitting in the vaults that are not reserves, as (base, quote) raw.
The vaults hold the protocol, fund and creator fees until they are claimed;
the curve trades on the vault balance less these.
"""
p0, p1, f0, f1 = struct.unpack_from("<4Q", data, _POOL_FEES_OFFSET)
c0, c1 = struct.unpack_from("<2Q", data, _POOL_CREATOR_FEES_OFFSET)
token_0, token_1 = p0 + f0 + c0, p1 + f1 + c1
return (token_0, token_1) if base_is_token_0 else (token_1, token_0)
class CpmmMarket(GraduatedMarket):
"""Sells a graduated LaunchLab coin into its Raydium CPMM pool.
The pool is derived, not searched for: CPMM keys it by the platform's
`cpswap_config` and the two mints. Platform configs and AMM configs are
shared by many pools and read once.
"""
def __init__(self, client: SolanaClient) -> None:
self.client = client
self._cpswap_configs: dict[Pubkey, Pubkey] = {}
self._pools: dict[Pubkey, CpmmPool] = {}
async def _platform_config(self, token_info: TokenInfo) -> Pubkey:
if token_info.platform_config:
return token_info.platform_config
pool = await self.client.get_account_info(token_info.pool_state)
offset = _LAUNCHLAB_PLATFORM_CONFIG_OFFSET
return Pubkey.from_bytes(pool.data[offset : offset + 32])
async def _resolve(self, token_info: TokenInfo) -> CpmmPool:
"""Find and decode the coin's CPMM pool, cached per mint."""
cached = self._pools.get(token_info.mint)
if cached:
return cached
platform_config = await self._platform_config(token_info)
amm_config = self._cpswap_configs.get(platform_config)
if amm_config is None:
config = await self.client.get_account_info(platform_config)
offset = _PLATFORM_CPSWAP_CONFIG_OFFSET
amm_config = Pubkey.from_bytes(config.data[offset : offset + 32])
self._cpswap_configs[platform_config] = amm_config
quote_mint = normalize_quote_mint(token_info.quote_mint)
address = cpmm_pool_address(amm_config, token_info.mint, quote_mint)
account = await self.client.get_account_info(address)
pool = decode_pool(address, account.data, token_info.mint)
self._pools[token_info.mint] = pool
return pool
async def _migrated_to_amm_v4(self, token_info: TokenInfo) -> bool:
"""Whether the LaunchLab pool was set to migrate to AMM v4, not CPMM."""
if not token_info.pool_state:
return False
try:
pool = await self.client.get_account_info(token_info.pool_state)
except ValueError:
return False
return pool.data[_LAUNCHLAB_MIGRATE_TYPE_OFFSET] == _MIGRATE_TO_AMM_V4
async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]:
"""Price from the vault balances less accrued fees; fees from the AMM config.
Raises:
ValueError: If the CPMM pool does not exist yet
"""
try:
pool = await self._resolve(token_info)
except ValueError as e:
if await self._migrated_to_amm_v4(token_info):
raise ValueError( # noqa: TRY003
f"{token_info.mint} migrated to Raydium AMM v4, which the bot "
f"cannot sell into"
) from e
raise ValueError( # noqa: TRY003
f"No CPMM pool for {token_info.mint} yet: {e}"
) from e
accounts = await self.client.get_multiple_accounts(
[pool.address, pool.amm_config, pool.base_vault, pool.quote_vault]
)
if any(account is None for account in accounts):
raise ValueError(f"CPMM pool {pool.address} accounts unreadable") # noqa: TRY003
pool_account, config, base_vault, quote_vault = accounts
base_fees, quote_fees = accrued_fees(pool_account.data, pool.base_is_token_0)
base_reserve = (
struct.unpack_from("<Q", base_vault.data, _TOKEN_AMOUNT_OFFSET)[0]
- base_fees
)
quote_reserve = (
struct.unpack_from("<Q", quote_vault.data, _TOKEN_AMOUNT_OFFSET)[0]
- quote_fees
)
trade_fee = struct.unpack_from("<Q", config.data, _CONFIG_TRADE_FEE_OFFSET)[0]
creator_fee = (
struct.unpack_from("<Q", config.data, _CONFIG_CREATOR_FEE_OFFSET)[0]
if pool_account.data[_POOL_CREATOR_FEE_FLAG_OFFSET]
else 0
)
return {
"price_per_token": (quote_reserve / 10**pool.quote_decimals)
/ (base_reserve / 10**pool.base_decimals)
if base_reserve > 0
else 0.0,
"fee_fraction": (trade_fee + creator_fee) / FEE_RATE_DENOMINATOR,
"pool": pool.address,
}
async def build_sell_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider, # noqa: ARG002
) -> list[Instruction]:
"""Build swap_base_input selling `amount_in` raw tokens for the quote."""
pool = await self._resolve(token_info)
user_base = get_associated_token_address(
user, pool.base_mint, pool.base_program
)
quote_account, open_ixs, close_ixs = quote_settlement_account(
user, pool.quote_mint, pool.quote_program
)
accounts = [
AccountMeta(user, is_signer=True, is_writable=True),
AccountMeta(_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(pool.amm_config, is_signer=False, is_writable=False),
AccountMeta(pool.address, is_signer=False, is_writable=True),
AccountMeta(user_base, is_signer=False, is_writable=True),
AccountMeta(quote_account, is_signer=False, is_writable=True),
AccountMeta(pool.base_vault, is_signer=False, is_writable=True),
AccountMeta(pool.quote_vault, is_signer=False, is_writable=True),
AccountMeta(pool.base_program, is_signer=False, is_writable=False),
AccountMeta(pool.quote_program, is_signer=False, is_writable=False),
AccountMeta(pool.base_mint, is_signer=False, is_writable=False),
AccountMeta(pool.quote_mint, is_signer=False, is_writable=False),
AccountMeta(pool.observation, is_signer=False, is_writable=True),
]
data = _SWAP_BASE_INPUT + struct.pack("<QQ", amount_in, minimum_amount_out)
return [*open_ixs, Instruction(CPMM_PROGRAM, data, accounts), *close_ixs]
def get_required_accounts_for_sell(
self,
token_info: TokenInfo,
user: Pubkey, # noqa: ARG002
address_provider: AddressProvider, # noqa: ARG002
) -> list[Pubkey]:
"""The pool and its vaults, once the pool has been resolved."""
pool = self._pools.get(token_info.mint)
if pool is None:
return [CPMM_PROGRAM]
return [pool.address, pool.base_vault, pool.quote_vault, CPMM_PROGRAM]
def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int:
"""Compute units for a CPMM sell."""
return config_override if config_override is not None else 150_000
+16 -3
View File
@@ -7,7 +7,7 @@ from typing import Any
from solders.pubkey import Pubkey
from core.client import SolanaClient
from interfaces.core import CurveManager
from interfaces.core import CurveGraduatedError, CurveManager
from platforms.launchlab.curve_math import (
FEE_RATE_DENOMINATOR,
buy_amount_out,
@@ -52,6 +52,7 @@ class LaunchLabCurveManager(CurveManager):
- `fee_rate`: total curve fee in parts per million, and `fee_fraction`
- `transfer_fee_bps` / `transfer_fee_max`: the coin's Token-2022 transfer
fee, zero for a mint without one
- `graduated`: the curve takes no more trades; the coin trades on CPMM
"""
def __init__(self, client: SolanaClient, idl_parser: IDLParser) -> None:
@@ -176,11 +177,23 @@ class LaunchLabCurveManager(CurveManager):
"fee_fraction": fee_rate / FEE_RATE_DENOMINATOR,
"transfer_fee_bps": transfer_bps,
"transfer_fee_max": transfer_max,
# Status 1 is waiting for Raydium to migrate it, 2 is migrated. The
# curve takes no trades either way, and a migrated pool keeps its
# final reserves, so its price would read as frozen.
"graduated": pool["status"] != 0,
}
async def calculate_price(self, pool_address: Pubkey) -> float:
"""Spot price in whole quote units per whole token; 0.0 for an empty curve."""
return (await self.get_pool_state(pool_address))["price_per_token"]
"""Spot price in whole quote units per whole token; 0.0 for an empty curve.
Raises:
CurveGraduatedError: If the curve has graduated — a migrated pool
keeps its final reserves, so its price would read as frozen
"""
pool_state = await self.get_pool_state(pool_address)
if pool_state["graduated"]:
raise CurveGraduatedError(str(pool_address))
return pool_state["price_per_token"]
async def calculate_buy_amount_out(
self, pool_address: Pubkey, amount_in: int
+4 -66
View File
@@ -1,25 +1,12 @@
"""Raydium LaunchLab buy_exact_in / sell_exact_in instructions, for any quote mint."""
import secrets
import struct
from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
from solders.system_program import CreateAccountWithSeedParams, create_account_with_seed
from spl.token.instructions import (
close_account,
create_idempotent_associated_token_account,
get_associated_token_address,
initialize_account,
)
from spl.token.models import CloseAccountParams, InitializeAccountParams
from spl.token.instructions import create_idempotent_associated_token_account
from core.pubkeys import (
TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE,
TOKEN_ACCOUNT_SIZE,
WSOL_MINT,
SystemAddresses,
)
from core.quote_account import quote_settlement_account
from interfaces.core import InstructionBuilder, TokenInfo
from platforms.launchlab.address_provider import LaunchLabAddressProvider
from utils.idl_parser import IDLParser
@@ -139,58 +126,9 @@ class LaunchLabInstructionBuilder(InstructionBuilder):
def _quote_account(
self, accounts: dict[str, Pubkey], user: Pubkey, funding: int
) -> tuple[Pubkey, list[Instruction], list[Instruction]]:
"""The account the quote side settles through, with its setup and teardown.
Returns:
(account, instructions before the swap, instructions after it)
"""
quote_mint = accounts["quote_token_mint"]
if quote_mint != WSOL_MINT:
quote_program = accounts["quote_token_program"]
create_ata = create_idempotent_associated_token_account(
user, user, quote_mint, quote_program
)
ata = get_associated_token_address(user, quote_mint, quote_program)
return ata, [create_ata], []
# A fresh seed per trade: two trades in flight never collide on one
# address, and any WSOL the wallet already holds is left alone.
seed = secrets.token_hex(16)
wsol_account = Pubkey.create_with_seed(
user, seed, SystemAddresses.TOKEN_PROGRAM
return quote_settlement_account(
user, accounts["quote_token_mint"], accounts["quote_token_program"], funding
)
open_ixs = [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=wsol_account,
base=user,
seed=seed,
lamports=funding + TOKEN_ACCOUNT_RENT_EXEMPT_RESERVE,
space=TOKEN_ACCOUNT_SIZE,
owner=SystemAddresses.TOKEN_PROGRAM,
)
),
initialize_account(
InitializeAccountParams(
program_id=SystemAddresses.TOKEN_PROGRAM,
account=wsol_account,
mint=WSOL_MINT,
owner=user,
)
),
]
close_ixs = [
close_account(
CloseAccountParams(
program_id=SystemAddresses.TOKEN_PROGRAM,
account=wsol_account,
dest=user,
owner=user,
)
)
]
return wsol_account, open_ixs, close_ixs
def _swap(
self,
+12 -2
View File
@@ -11,7 +11,7 @@ from core.pubkeys import (
is_sol_paired,
normalize_quote_mint,
)
from interfaces.core import CurveManager, Platform
from interfaces.core import CurveGraduatedError, CurveManager, Platform
from utils.idl_parser import IDLParser
from utils.logger import get_logger
@@ -122,6 +122,8 @@ class PumpFunCurveManager(CurveManager):
"""
pool_state = await self.get_pool_state(pool_address)
if pool_state["graduated"]:
raise CurveGraduatedError(str(pool_address))
if pool_state["virtual_token_reserves"] <= 0:
return 0.0
@@ -256,6 +258,8 @@ class PumpFunCurveManager(CurveManager):
"real_quote_reserves": decoded_curve_state.get("real_quote_reserves", 0),
"token_total_supply": decoded_curve_state.get("token_total_supply", 0),
"complete": decoded_curve_state.get("complete", False),
# The coin now trades on PumpSwap; the curve's reserves are zero.
"graduated": decoded_curve_state.get("complete", False),
"creator": decoded_curve_state.get("creator", ""),
"is_mayhem_mode": decoded_curve_state.get("is_mayhem_mode", False),
# Decoded for every coin. create_v2 no longer mints cashback coins
@@ -272,7 +276,13 @@ class PumpFunCurveManager(CurveManager):
curve_data["virtual_sol_reserves"] = curve_data["virtual_quote_reserves"]
curve_data["real_sol_reserves"] = curve_data["real_quote_reserves"]
# Calculate additional metrics
# A graduated curve has handed its reserves to PumpSwap and has no
# price; reporting that, rather than raising, lets a holder route the
# sell to the pool.
if curve_data["graduated"]:
curve_data["price_per_token"] = None
return curve_data
# Validate reserves are positive before calculating price
if curve_data["virtual_token_reserves"] <= 0:
raise ValueError(
+305
View File
@@ -0,0 +1,305 @@
"""PumpSwap, where a pump.fun coin trades once its bonding curve completes."""
import random
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Final
from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
from spl.token.instructions import get_associated_token_address
from core.client import SolanaClient
from core.pubkeys import (
TOKEN_DECIMALS,
SystemAddresses,
normalize_quote_mint,
quote_units_per_token,
)
from core.quote_account import quote_settlement_account
from interfaces.core import AddressProvider, GraduatedMarket, TokenInfo
from platforms.pumpfun.address_provider import PumpFunAddresses
from utils.idl_parser import IDLParser
PUMP_AMM_PROGRAM: Final[Pubkey] = Pubkey.from_string(
"pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA"
)
PUMP_FEE_PROGRAM: Final[Pubkey] = Pubkey.from_string(
"pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"
)
def _amm_pda(*seeds: bytes) -> Pubkey:
return Pubkey.find_program_address(list(seeds), PUMP_AMM_PROGRAM)[0]
_PUMP_SWAP_IDL = Path(__file__).resolve().parents[3] / "idl" / "pump_swap_idl.json"
GLOBAL_CONFIG: Final[Pubkey] = _amm_pda(b"global_config")
EVENT_AUTHORITY: Final[Pubkey] = _amm_pda(b"__event_authority")
FEE_CONFIG: Final[Pubkey] = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_AMM_PROGRAM)], PUMP_FEE_PROGRAM
)[0]
# Pool, after the discriminator: pool_bump u8, index u16, creator, base_mint,
# quote_mint, lp_mint, the two pool token accounts, lp_supply u64, coin_creator,
# is_mayhem_mode, is_cashback_coin, virtual_quote_reserves i128.
_POOL_BASE_ACCOUNT_OFFSET = 139
_POOL_QUOTE_ACCOUNT_OFFSET = 171
_POOL_COIN_CREATOR_OFFSET = 211
_POOL_MAYHEM_OFFSET = 243
_POOL_CASHBACK_OFFSET = 244
_POOL_VIRTUAL_QUOTE_OFFSET = 245
# GlobalConfig, after the discriminator: admin, lp/protocol fee bps (u64 each),
# disable_flags u8, protocol_fee_recipients[8], coin_creator_fee bps u64, two
# authorities, reserved_fee_recipient, mayhem flag, reserved_fee_recipients[7],
# cashback flag, buyback_fee_recipients[8].
_CONFIG_LP_FEE_OFFSET = 40
_CONFIG_PROTOCOL_FEE_OFFSET = 48
_CONFIG_PROTOCOL_RECIPIENTS_OFFSET = 57
_CONFIG_CREATOR_FEE_OFFSET = 313
_CONFIG_RESERVED_RECIPIENT_OFFSET = 385
_CONFIG_BUYBACK_RECIPIENTS_OFFSET = 643
_BASIS_POINTS = 10_000
_TOKEN_AMOUNT_OFFSET = 64
@dataclass(frozen=True)
class PumpSwapPool:
"""A canonical PumpSwap pool: the one a pump.fun curve migrated into."""
address: Pubkey
base_mint: Pubkey
quote_mint: Pubkey
base_account: Pubkey
quote_account: Pubkey
base_program: Pubkey
quote_program: Pubkey
coin_creator: Pubkey
is_mayhem_mode: bool
is_cashback_coin: bool
def canonical_pool_address(base_mint: Pubkey, quote_mint: Pubkey) -> Pubkey:
"""The pool a pump.fun migration creates: index 0, owned by the curve's authority."""
pool_authority = Pubkey.find_program_address(
[b"pool-authority", bytes(base_mint)], PumpFunAddresses.PROGRAM
)[0]
return _amm_pda(
b"pool",
struct.pack("<H", 0),
bytes(pool_authority),
bytes(base_mint),
bytes(quote_mint),
)
def _pubkeys(data: bytes, offset: int, count: int) -> list[Pubkey]:
return [
Pubkey.from_bytes(data[offset + 32 * i : offset + 32 * (i + 1)])
for i in range(count)
]
class PumpSwapMarket(GraduatedMarket):
"""Sells a graduated pump.fun coin into its canonical PumpSwap pool.
The pool is derived from the mint, so finding it costs no search. The
accounts follow PumpSwap's `sell`: the IDL's 21, then the cashback pair on a
cashback pool, `pool-v2` on a pool with a coin creator — which every
canonical pool has — and a buyback recipient with its quote account last,
which the program reads from the end of the list.
"""
def __init__(self, client: SolanaClient) -> None:
self.client = client
self._sell_discriminator = IDLParser(
str(_PUMP_SWAP_IDL)
).get_instruction_discriminators()["sell"]
self._config: bytes | None = None
self._pools: dict[Pubkey, PumpSwapPool] = {}
async def _global_config(self) -> bytes:
if self._config is None:
self._config = (await self.client.get_account_info(GLOBAL_CONFIG)).data
return self._config
async def _resolve(self, token_info: TokenInfo) -> tuple[PumpSwapPool, bytes]:
"""The pool, and its current account data. Raises ValueError if it does not exist."""
quote_mint = normalize_quote_mint(token_info.quote_mint)
address = canonical_pool_address(token_info.mint, quote_mint)
pool_data = (await self.client.get_account_info(address)).data
pool = self._pools.get(token_info.mint)
if pool is None:
base_account, quote_account = (
Pubkey.from_bytes(pool_data[o : o + 32])
for o in (_POOL_BASE_ACCOUNT_OFFSET, _POOL_QUOTE_ACCOUNT_OFFSET)
)
vaults = await self.client.get_multiple_accounts(
[base_account, quote_account]
)
pool = PumpSwapPool(
address=address,
base_mint=token_info.mint,
quote_mint=quote_mint,
base_account=base_account,
quote_account=quote_account,
base_program=vaults[0].owner,
quote_program=vaults[1].owner,
coin_creator=Pubkey.from_bytes(
pool_data[
_POOL_COIN_CREATOR_OFFSET : _POOL_COIN_CREATOR_OFFSET + 32
]
),
is_mayhem_mode=bool(pool_data[_POOL_MAYHEM_OFFSET]),
is_cashback_coin=bool(pool_data[_POOL_CASHBACK_OFFSET]),
)
self._pools[token_info.mint] = pool
return pool, pool_data
async def get_market_state(self, token_info: TokenInfo) -> dict[str, Any]:
"""Price from the pool's balances plus its virtual quote reserves.
Raises:
ValueError: If the pool does not exist yet
"""
try:
pool, pool_data = await self._resolve(token_info)
except ValueError as e:
raise ValueError(f"No PumpSwap pool for {token_info.mint} yet: {e}") from e # noqa: TRY003
base_vault, quote_vault = await self.client.get_multiple_accounts(
[pool.base_account, pool.quote_account]
)
base_reserve = struct.unpack_from("<Q", base_vault.data, _TOKEN_AMOUNT_OFFSET)[
0
]
virtual_quote = int.from_bytes(
pool_data[_POOL_VIRTUAL_QUOTE_OFFSET : _POOL_VIRTUAL_QUOTE_OFFSET + 16],
"little",
signed=True,
)
quote_reserve = (
struct.unpack_from("<Q", quote_vault.data, _TOKEN_AMOUNT_OFFSET)[0]
+ virtual_quote
)
config = await self._global_config()
fee_bps = sum(
struct.unpack_from("<Q", config, offset)[0]
for offset in (
_CONFIG_LP_FEE_OFFSET,
_CONFIG_PROTOCOL_FEE_OFFSET,
_CONFIG_CREATOR_FEE_OFFSET,
)
)
return {
"price_per_token": (quote_reserve / quote_units_per_token(pool.quote_mint))
/ (base_reserve / 10**TOKEN_DECIMALS)
if base_reserve > 0
else 0.0,
"fee_fraction": fee_bps / _BASIS_POINTS,
"pool": pool.address,
}
async def build_sell_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider, # noqa: ARG002
) -> list[Instruction]:
"""Build PumpSwap `sell` for `amount_in` raw tokens."""
pool, _ = await self._resolve(token_info)
config = await self._global_config()
quote_mint, quote_program = pool.quote_mint, pool.quote_program
if pool.is_mayhem_mode:
fee_recipient = _pubkeys(config, _CONFIG_RESERVED_RECIPIENT_OFFSET, 1)[0]
else:
fee_recipient = random.choice(
_pubkeys(config, _CONFIG_PROTOCOL_RECIPIENTS_OFFSET, 8)
)
buyback_recipient = random.choice(
_pubkeys(config, _CONFIG_BUYBACK_RECIPIENTS_OFFSET, 8)
)
creator_vault = _amm_pda(b"creator_vault", bytes(pool.coin_creator))
user_quote, open_ixs, close_ixs = quote_settlement_account(
user, quote_mint, quote_program
)
def ata(owner: Pubkey) -> Pubkey:
return get_associated_token_address(owner, quote_mint, quote_program)
accounts = [
AccountMeta(pool.address, is_signer=False, is_writable=True),
AccountMeta(user, is_signer=True, is_writable=True),
AccountMeta(GLOBAL_CONFIG, is_signer=False, is_writable=False),
AccountMeta(pool.base_mint, is_signer=False, is_writable=False),
AccountMeta(quote_mint, is_signer=False, is_writable=False),
AccountMeta(
get_associated_token_address(user, pool.base_mint, pool.base_program),
is_signer=False,
is_writable=True,
),
AccountMeta(user_quote, is_signer=False, is_writable=True),
AccountMeta(pool.base_account, is_signer=False, is_writable=True),
AccountMeta(pool.quote_account, is_signer=False, is_writable=True),
AccountMeta(fee_recipient, is_signer=False, is_writable=False),
AccountMeta(ata(fee_recipient), is_signer=False, is_writable=True),
AccountMeta(pool.base_program, is_signer=False, is_writable=False),
AccountMeta(quote_program, is_signer=False, is_writable=False),
AccountMeta(
SystemAddresses.SYSTEM_PROGRAM, is_signer=False, is_writable=False
),
AccountMeta(
SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
is_signer=False,
is_writable=False,
),
AccountMeta(EVENT_AUTHORITY, is_signer=False, is_writable=False),
AccountMeta(PUMP_AMM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(ata(creator_vault), is_signer=False, is_writable=True),
AccountMeta(creator_vault, is_signer=False, is_writable=False),
AccountMeta(FEE_CONFIG, is_signer=False, is_writable=False),
AccountMeta(PUMP_FEE_PROGRAM, is_signer=False, is_writable=False),
]
if pool.is_cashback_coin:
accumulator = _amm_pda(b"user_volume_accumulator", bytes(user))
accounts += [
AccountMeta(ata(accumulator), is_signer=False, is_writable=True),
AccountMeta(accumulator, is_signer=False, is_writable=True),
]
if pool.coin_creator != Pubkey.default():
accounts.append(
AccountMeta(
_amm_pda(b"pool-v2", bytes(pool.base_mint)),
is_signer=False,
is_writable=False,
)
)
accounts += [
AccountMeta(buyback_recipient, is_signer=False, is_writable=False),
AccountMeta(ata(buyback_recipient), is_signer=False, is_writable=True),
]
data = self._sell_discriminator + struct.pack(
"<QQ", amount_in, minimum_amount_out
)
return [*open_ixs, Instruction(PUMP_AMM_PROGRAM, data, accounts), *close_ixs]
def get_required_accounts_for_sell(
self,
token_info: TokenInfo,
user: Pubkey, # noqa: ARG002
address_provider: AddressProvider, # noqa: ARG002
) -> list[Pubkey]:
"""The pool and its token accounts, once the pool has been resolved."""
pool = self._pools.get(token_info.mint)
if pool is None:
return [PUMP_AMM_PROGRAM]
return [pool.address, pool.base_account, pool.quote_account, PUMP_AMM_PROGRAM]
def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int:
"""Compute units for a PumpSwap sell."""
return config_override if config_override is not None else 200_000
+31 -3
View File
@@ -697,6 +697,8 @@ class PlatformAwareSeller(Trader):
# the transfer fee on the way into the pool and takes its curve fee
# from the quote side. Zero where the pool state carries neither.
sell_deductions = (0.0, 0)
# Unknown when the refresh below fails; the curve is the default.
graduated = False
# The sell account list is 16 (non-cashback) vs 17 (cashback) and
# fee_recipient differs in mayhem mode; both can change between buy
@@ -721,6 +723,7 @@ class PlatformAwareSeller(Trader):
pool_state.get("fee_fraction", 0.0),
pool_state.get("transfer_fee_bps", 0),
)
graduated = pool_state.get("graduated", False)
# The program may delegate BC.creator to a PFEE-owned PDA after
# the initial creator buy, so the create-time vault on
# token_info goes stale before the sell lands and the sell
@@ -746,6 +749,31 @@ class PlatformAwareSeller(Trader):
f"is_cashback_coin={token_info.is_cashback_coin}"
)
# A graduated curve takes no sells; the coin trades in the AMM pool
# it migrated to, which builds the sell instead.
seller = instruction_builder
if graduated:
market = implementations.graduated_market
try:
if market is None:
raise ValueError( # noqa: TRY301
f"no post-graduation market for {token_info.platform.value}"
)
market_state = await market.get_market_state(token_info)
except ValueError as e:
return TradeResult(
success=False,
platform=token_info.platform,
error_message=f"{token_info.symbol} has graduated: {e}",
failure_reason=TradeFailureReason.SUBMIT_FAILED,
)
logger.info(
f"{token_info.symbol} has graduated; selling into "
f"{market_state['pool']}"
)
seller = market
sell_deductions = (market_state["fee_fraction"], sell_deductions[1])
quote_unit = quote_units_per_token(quote_mint)
quote_label = quote_symbol(quote_mint)
@@ -793,7 +821,7 @@ class PlatformAwareSeller(Trader):
)
# Build sell instructions using platform-specific builder
instructions = await instruction_builder.build_sell_instruction(
instructions = await seller.build_sell_instruction(
token_info,
self.wallet.pubkey,
token_balance, # amount_in (tokens)
@@ -802,7 +830,7 @@ class PlatformAwareSeller(Trader):
)
# Get accounts for priority fee calculation
priority_accounts = instruction_builder.get_required_accounts_for_sell(
priority_accounts = seller.get_required_accounts_for_sell(
token_info, self.wallet.pubkey, address_provider
)
@@ -814,7 +842,7 @@ class PlatformAwareSeller(Trader):
priority_fee=await self.priority_fee_manager.calculate_priority_fee(
priority_accounts
),
compute_unit_limit=instruction_builder.get_sell_compute_unit_limit(
compute_unit_limit=seller.get_sell_compute_unit_limit(
self._get_cu_override("sell", token_info.platform)
),
account_data_size_limit=self._get_cu_override(
+32 -8
View File
@@ -29,6 +29,7 @@ from core.pubkeys import (
from core.wallet import Wallet
from interfaces.core import (
ConfirmationStatus,
CurveGraduatedError,
Platform,
TokenInfo,
TradeFailureReason,
@@ -321,6 +322,8 @@ class UniversalTrader:
self.token_queue: asyncio.Queue = asyncio.Queue()
self.processing: bool = False
self.processed_tokens: set[str] = set()
# Coins whose graduation has been logged, so it is said once.
self._graduated_mints: set[Pubkey] = set()
self.token_timestamps: dict[str, float] = {}
async def _resolve_quote_token_programs(self) -> None:
@@ -899,6 +902,33 @@ class UniversalTrader:
)
return ExitSellVerdict.STOP
async def _read_price(self, token_info: TokenInfo) -> float:
"""The coin's price where it trades now: its curve, or after graduation its AMM pool.
Returns:
Whole quote units per whole token; 0.0 for a pool with nothing in it
Raises:
ValueError: If a graduated coin's pool cannot be priced yet — it
lands a moment after graduation
"""
implementations = self.platform_implementations
try:
return await implementations.curve_manager.calculate_price(
self._get_pool_address(token_info)
)
except CurveGraduatedError:
market = implementations.graduated_market
if market is None:
raise ValueError( # noqa: TRY003
f"{token_info.symbol} has graduated and "
f"{self.platform.value} has no post-graduation market"
) from None
if token_info.mint not in self._graduated_mints:
self._graduated_mints.add(token_info.mint)
logger.info(f"{token_info.symbol} has graduated; pricing off its pool")
return (await market.get_market_state(token_info))["price_per_token"]
async def _current_price_or(self, token_info: TokenInfo, fallback: float) -> float:
"""Read the current price, falling back to the last known one.
@@ -910,10 +940,7 @@ class UniversalTrader:
The freshly read price, or `fallback` if it could not be read
"""
try:
curve_manager = self.platform_implementations.curve_manager
price = await curve_manager.calculate_price(
self._get_pool_address(token_info)
)
price = await self._read_price(token_info)
except Exception as e: # noqa: BLE001
logger.warning(
f"Could not re-read price for {token_info.symbol} ({e}); "
@@ -936,9 +963,6 @@ class UniversalTrader:
f"Starting position monitoring (check interval: {self.price_check_interval}s)"
)
# Platform-agnostic pool address for price monitoring
pool_address = self._get_pool_address(token_info)
curve_manager = self.platform_implementations.curve_manager
exit_sell_attempts = 0
# Every price below is denominated in the coin's quote asset, which is
# not always SOL.
@@ -951,7 +975,7 @@ class UniversalTrader:
while position.is_active:
try:
current_price = await curve_manager.calculate_price(pool_address)
current_price = await self._read_price(token_info)
# A curve with no virtual token reserves prices at 0.0 rather
# than raising, and the seller rejects a non-positive price
# before its own try block, so a stored 0.0 would escape the
File diff suppressed because it is too large Load Diff
@@ -187,6 +187,7 @@ def _pool_before(entry: dict, event: dict, base_decimals: int = 6) -> dict:
"global_config": Pubkey.from_string(entry["global_config"]),
"platform_config": Pubkey.from_string(entry["platform_config"]),
"base_mint": Pubkey.from_string(entry["base_mint"]),
"status": 0,
}
@@ -0,0 +1,447 @@
"""Verify a held coin that graduates is priced and sold in the pool it migrated to.
A bonding curve that completes takes no more trades: pump.fun hands the coin to
PumpSwap, Raydium LaunchLab to a CPMM pool. The bot only knew the curve. A
graduated pump.fun curve has zero reserves, so its decoder raised and every
price read and pre-sell refresh failed; a migrated LaunchLab pool keeps its
final reserves, so it reported a frozen price. Either way the exit sold into a
curve that refuses it, and the position was stranded.
Fixture: `raw_graduated_markets_from_getaccountinfo.json` — for one graduated
pump.fun coin and one graduated StonkFun coin: the curve, the AMM pool, its
token accounts and config, and a real sell into that pool.
Offline machine checks, no network and no funds moved:
A. Both curve managers report `graduated` for the fixture curves, and
`calculate_price` raises CurveGraduatedError instead of returning a price.
B. The AMM pool is derived from the mint alone and matches the fixture's.
C. Each market's sell names the same accounts as the real sell on chain,
apart from the seller's own accounts and the randomly picked fee and
buyback recipients, which come from PumpSwap's GlobalConfig.
D. Each market prices off its pool: PumpSwap from the token-account balances
plus the pool's virtual quote reserves, CPMM from the vault balances less
the fees accrued in them.
E. The seller sells a graduated coin through the market, floored by the
market's fee and the coin's transfer fee; with the pool not readable yet
it sends nothing and reports SUBMIT_FAILED.
F. The trader's price read switches to the market when the curve graduates.
Usage:
uv run tests/regression/verify_sell_after_graduation.py
"""
import asyncio
import base64
import json
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import ( # noqa: E402
CurveGraduatedError,
Platform,
TokenInfo,
TradeFailureReason,
)
from platforms.launchlab.cpmm_market import CPMM_PROGRAM, CpmmMarket # noqa: E402
from platforms.pumpfun.curve_manager import PumpFunCurveManager # noqa: E402
from platforms.pumpfun.pumpswap_market import ( # noqa: E402
_CONFIG_BUYBACK_RECIPIENTS_OFFSET,
_CONFIG_PROTOCOL_RECIPIENTS_OFFSET,
PUMP_AMM_PROGRAM,
PumpSwapMarket,
canonical_pool_address,
)
from platforms.stonkfun import StonkFunCurveManager # noqa: E402
from trading import platform_aware # noqa: E402
from trading.platform_aware import PlatformAwareSeller # noqa: E402
from trading.universal_trader import UniversalTrader # noqa: E402
from utils.idl_manager import get_idl_manager # noqa: E402
FIXTURE = json.loads(
Path(__file__)
.with_name("raw_graduated_markets_from_getaccountinfo.json")
.read_text()
)
PUMP, LAUNCHLAB = FIXTURE["pumpfun"], FIXTURE["launchlab"]
class _FixtureClient:
"""Serves the fixture's accounts by address; anything else does not exist."""
def __init__(self, accounts: dict) -> None:
self.accounts = {
Pubkey.from_string(k): SimpleNamespace(
data=base64.b64decode(v["data"]), owner=Pubkey.from_string(v["owner"])
)
for k, v in accounts.items()
}
async def get_account_info(self, address: Pubkey, commitment: str | None = None): # noqa: ARG002
if address not in self.accounts:
raise ValueError(f"Account {address} not found") # noqa: TRY003
return self.accounts[address]
async def get_multiple_accounts(
self, addresses: list, commitment: str | None = None
): # noqa: ARG002
return [self.accounts.get(a) for a in addresses]
def _pump_token() -> TokenInfo:
return TokenInfo(
name="",
symbol="PUMP",
uri="",
mint=Pubkey.from_string(PUMP["mint"]),
platform=Platform.PUMP_FUN,
bonding_curve=Pubkey.from_string(PUMP["curve"]),
quote_mint=WSOL_MINT,
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
)
def _launchlab_token() -> TokenInfo:
return TokenInfo(
name="",
symbol="STONK",
uri="",
mint=Pubkey.from_string(LAUNCHLAB["mint"]),
platform=Platform.STONK_FUN,
pool_state=Pubkey.from_string(LAUNCHLAB["launchlab_pool"]),
platform_config=Pubkey.from_string(LAUNCHLAB["platform_config"]),
quote_mint=Pubkey.from_string(LAUNCHLAB["quote_mint"]),
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
)
def _chain_sell(fixture: dict, program: Pubkey) -> list[str]:
"""Accounts of the real sell into the fixture pool."""
tx = fixture["sell_transaction"]
loaded = tx["meta"].get("loadedAddresses") or {}
keys = (
tx["transaction"]["message"]["accountKeys"]
+ loaded.get("writable", [])
+ loaded.get("readonly", [])
)
instructions = list(tx["transaction"]["message"]["instructions"]) + [
ix
for group in tx["meta"].get("innerInstructions") or []
for ix in group["instructions"]
]
for ix in instructions:
accounts = [keys[i] for i in ix["accounts"]]
if keys[ix["programIdIndex"]] == str(program) and len(accounts) > 12:
return accounts
raise AssertionError("fixture has no sell into the pool")
def check_a_curves_report_graduation() -> bool:
"""A: graduated, and calculate_price refuses to price the curve."""
parser = get_idl_manager().get_parser(Platform.PUMP_FUN)
results = []
for manager, pool in (
(
PumpFunCurveManager(_FixtureClient(PUMP["accounts"]), parser),
PUMP["curve"],
),
(
StonkFunCurveManager(
_FixtureClient(LAUNCHLAB["accounts"]),
get_idl_manager().get_parser(Platform.STONK_FUN),
),
LAUNCHLAB["launchlab_pool"],
),
):
address = Pubkey.from_string(pool)
state = asyncio.run(manager.get_pool_state(address))
try:
asyncio.run(manager.calculate_price(address))
raised = False
except CurveGraduatedError:
raised = True
results.append(state["graduated"] and raised)
if not all(results):
print(f" pump.fun, LaunchLab: {results}")
return all(results)
def check_b_pools_derive_from_the_mint() -> bool:
"""B: canonical PumpSwap pool and CPMM pool, no search."""
pump_pool = canonical_pool_address(Pubkey.from_string(PUMP["mint"]), WSOL_MINT)
market = CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"]))
cpmm_pool = asyncio.run(market._resolve(_launchlab_token())).address
ok = str(pump_pool) == PUMP["pool"] and str(cpmm_pool) == LAUNCHLAB["cpmm_pool"]
if not ok:
print(f" pumpswap {pump_pool}, cpmm {cpmm_pool}")
return ok
def _config_recipients(offset: int) -> set[str]:
config = base64.b64decode(
next(
v["data"]
for k, v in PUMP["accounts"].items()
if len(base64.b64decode(v["data"])) > 900
)
)
return {
str(Pubkey.from_bytes(config[offset + 32 * i : offset + 32 * (i + 1)]))
for i in range(8)
}
def check_c_sell_accounts_match_chain() -> bool:
"""C: same accounts as the on-chain sells, bar the seller's own and random picks."""
ok = True
for name, market, token, program in (
(
"pumpswap",
PumpSwapMarket(_FixtureClient(PUMP["accounts"])),
_pump_token(),
PUMP_AMM_PROGRAM,
),
(
"cpmm",
CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"])),
_launchlab_token(),
CPMM_PROGRAM,
),
):
fixture = PUMP if name == "pumpswap" else LAUNCHLAB
chain = _chain_sell(fixture, program)
seller = Pubkey.from_string(chain[1] if name == "pumpswap" else chain[0])
instructions = asyncio.run(
market.build_sell_instruction(token, seller, 1, 1, None)
)
ours = [
str(m.pubkey)
for m in next(
ix for ix in instructions if ix.program_id == program
).accounts
]
# The seller's own token accounts: the bot uses its ATA and a throwaway
# WSOL account, the on-chain seller whatever it held.
variable = {5, 6} if name == "pumpswap" else {4, 5}
if name == "pumpswap":
protocol = _config_recipients(_CONFIG_PROTOCOL_RECIPIENTS_OFFSET)
buyback = _config_recipients(_CONFIG_BUYBACK_RECIPIENTS_OFFSET)
# Recipient picks are random: check membership, skip their ATAs.
if ours[9] not in protocol or chain[9] not in protocol:
print(f" {name}: fee recipient not from GlobalConfig")
ok = False
if ours[-2] not in buyback or chain[-2] not in buyback:
print(f" {name}: buyback recipient not from GlobalConfig")
ok = False
variable |= {9, 10, len(chain) - 2, len(chain) - 1}
differs = [
i
for i in range(max(len(ours), len(chain)))
if i not in variable
and (i >= len(ours) or i >= len(chain) or ours[i] != chain[i])
]
if differs or len(ours) != len(chain):
print(
f" {name}: {len(ours)} vs {len(chain)} accounts, differ at {differs}"
)
ok = False
return ok
def _amount(fixture: dict, address: Pubkey) -> int:
data = base64.b64decode(fixture["accounts"][str(address)]["data"])
return struct.unpack_from("<Q", data, 64)[0]
def check_d_markets_price_off_their_pools() -> bool:
"""D: price = reserves ratio as the program counts them."""
pump_market = PumpSwapMarket(_FixtureClient(PUMP["accounts"]))
pump_state = asyncio.run(pump_market.get_market_state(_pump_token()))
pump_pool = pump_market._pools[Pubkey.from_string(PUMP["mint"])]
pool_data = base64.b64decode(PUMP["accounts"][PUMP["pool"]]["data"])
virtual = int.from_bytes(pool_data[245:261], "little", signed=True)
pump_expected = ((_amount(PUMP, pump_pool.quote_account) + virtual) / 1e9) / (
_amount(PUMP, pump_pool.base_account) / 1e6
)
cpmm_market = CpmmMarket(_FixtureClient(LAUNCHLAB["accounts"]))
cpmm_state = asyncio.run(cpmm_market.get_market_state(_launchlab_token()))
pool = cpmm_market._pools[Pubkey.from_string(LAUNCHLAB["mint"])]
data = base64.b64decode(LAUNCHLAB["accounts"][LAUNCHLAB["cpmm_pool"]]["data"])
fees = struct.unpack_from("<4Q", data, 8 + 320 + 5 + 8)
creator = struct.unpack_from("<2Q", data, 8 + 320 + 5 + 56 + 2 + 6)
accrued = [fees[i] + fees[2 + i] + creator[i] for i in (0, 1)]
base_i = 0 if pool.base_is_token_0 else 1
cpmm_expected = (
(_amount(LAUNCHLAB, pool.quote_vault) - accrued[1 - base_i])
/ 10**pool.quote_decimals
) / (
(_amount(LAUNCHLAB, pool.base_vault) - accrued[base_i]) / 10**pool.base_decimals
)
ok = (
abs(pump_state["price_per_token"] - pump_expected) <= pump_expected * 1e-12
and abs(cpmm_state["price_per_token"] - cpmm_expected) <= cpmm_expected * 1e-12
)
if not ok:
print(
f" pumpswap {pump_state} vs {pump_expected}; cpmm {cpmm_state} vs {cpmm_expected}"
)
return ok
class _StubClient:
def __init__(self) -> None:
self.sent: list = []
async def build_and_send_transaction(self, instructions, *_a, **_k) -> str:
self.sent.append(instructions)
return "STUB_SIGNATURE"
async def confirm_transaction_detailed(self, *_a, **_k):
return platform_aware.ConfirmationStatus.REVERTED
class _GraduatedCurve:
async def get_pool_state(self, _pool, commitment=None) -> dict: # noqa: ARG002
return {"graduated": True, "quote_mint": WSOL_MINT, "transfer_fee_bps": 100}
async def calculate_price(self, _pool) -> float:
raise CurveGraduatedError("stub")
class _Market:
def __init__(self, *, ready: bool) -> None:
self.ready = ready
self.minimum_out: int | None = None
async def get_market_state(self, _token) -> dict:
if not self.ready:
raise ValueError("pool not created yet") # noqa: TRY003
return {"price_per_token": 0.00000005, "fee_fraction": 0.0125, "pool": "POOL"}
async def build_sell_instruction(self, _token, _user, _amount, minimum_out, _ap):
self.minimum_out = minimum_out
return ["market-sell"]
def get_required_accounts_for_sell(self, *_a) -> list:
return []
def get_sell_compute_unit_limit(self, _override=None) -> int:
return 150_000
async def _no_fee(_accounts) -> None:
return None
def _sell(market: _Market) -> tuple[object, _StubClient]:
client = _StubClient()
platform_aware.get_platform_implementations = lambda _p, _c: SimpleNamespace(
address_provider=SimpleNamespace(),
instruction_builder=SimpleNamespace(),
curve_manager=_GraduatedCurve(),
graduated_market=market,
)
seller = PlatformAwareSeller(
client,
SimpleNamespace(pubkey=Pubkey.new_unique(), keypair=None),
SimpleNamespace(calculate_priority_fee=_no_fee),
slippage=0.2,
max_retries=1,
)
token = TokenInfo(
name="",
symbol="T",
uri="",
mint=Pubkey.new_unique(),
platform=Platform.STONK_FUN,
pool_state=Pubkey.new_unique(),
quote_mint=WSOL_MINT,
)
result = asyncio.run(seller.execute(token, 1_000_000.0, 0.00000005))
return result, client
def check_e_seller_routes_to_market() -> bool:
"""E: graduated -> market sell with fee- and tax-net floor; not ready -> nothing sent."""
ready = _Market(ready=True)
_, client = _sell(ready)
expected = int(1_000_000 * (1 - 0.01) * 0.00000005 * (1 - 0.0125) * (1 - 0.2) * 1e9)
routed = (
client.sent == [["market-sell"]]
and abs((ready.minimum_out or 0) - expected) <= 1
)
result, client = _sell(_Market(ready=False))
held = (
not client.sent
and not result.success
and result.failure_reason is TradeFailureReason.SUBMIT_FAILED
)
if not (routed and held):
print(
f" routed={routed} floor={ready.minimum_out} expected {expected} held={held}"
)
return routed and held
def check_f_trader_price_follows_graduation() -> bool:
"""F: CurveGraduatedError -> the market's price."""
trader = SimpleNamespace(
platform=Platform.STONK_FUN,
platform_implementations=SimpleNamespace(
curve_manager=_GraduatedCurve(), graduated_market=_Market(ready=True)
),
_graduated_mints=set(),
_get_pool_address=lambda _token: Pubkey.new_unique(),
)
token = TokenInfo(
name="",
symbol="T",
uri="",
mint=Pubkey.new_unique(),
platform=Platform.STONK_FUN,
)
price = asyncio.run(UniversalTrader._read_price(trader, token))
ok = price == 0.00000005
if not ok:
print(f" price {price}")
return ok
def main() -> int:
"""Run every check and report."""
checks = [
("A: curves report graduation", check_a_curves_report_graduation),
("B: AMM pools derive from the mint", check_b_pools_derive_from_the_mint),
("C: market sells match the chain", check_c_sell_accounts_match_chain),
("D: markets price off their pools", check_d_markets_price_off_their_pools),
(
"E: seller routes a graduated coin to the market",
check_e_seller_routes_to_market,
),
(
"F: trader prices off the market after graduation",
check_f_trader_price_follows_graduation,
),
]
passed = 0
for label, check in checks:
ok = check()
print(f"{'PASS' if ok else 'FAIL'} {label}")
passed += ok
print(f"\n{passed}/{len(checks)} checks passed")
return 0 if passed == len(checks) else 1
if __name__ == "__main__":
sys.exit(main())
+162
View File
@@ -0,0 +1,162 @@
"""Price and sell a coin you already hold whose curve has graduated, via the bot's path.
WARNING: this submits a real transaction and sells real funds.
The bot only holds coins it bought at launch, and a curve rarely graduates while
it holds one, so that hand-off cannot be staged with the bot itself. This runs
the two pieces it would use on the coin you name: the trader's price read
(curve -> CurveGraduatedError -> the platform's GraduatedMarket) and
PlatformAwareSeller, which reads the curve, sees `graduated` and sells into the
AMM pool. Buy the coin first with a cookbook AMM buy script.
Usage:
uv run tools/live_graduated_sell.py pump_fun <MINT>
uv run tools/live_graduated_sell.py stonk_fun <MINT> --slippage 0.3
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from solana.rpc.core import DataSliceOpts, MemcmpOpts # noqa: E402
from solders.pubkey import Pubkey # noqa: E402
from core.client import SolanaClient # noqa: E402
from core.priority_fee.manager import PriorityFeeManager # noqa: E402
from core.pubkeys import TOKEN_DECIMALS, resolve_quote_token_program # noqa: E402
from core.wallet import Wallet # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms import get_platform_implementations # noqa: E402
from platforms.launchlab import LAUNCHLAB_PROGRAM # noqa: E402
from trading.platform_aware import PlatformAwareSeller # noqa: E402
from trading.universal_trader import UniversalTrader # noqa: E402
from utils.logger import install_secret_redaction # noqa: E402
load_dotenv(PROJECT_ROOT / ".env")
PRIORITY_FEE = 1_000_000
DEFAULT_SLIPPAGE = 0.3
LAUNCHLAB_POOL_SIZE = 429
LAUNCHLAB_BASE_MINT_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8 + 2 * 32
async def token_info_for(
platform: Platform, mint: Pubkey, client: SolanaClient, impl
) -> TokenInfo:
"""Build the TokenInfo the bot would hold, from the curve's own state."""
mint_owner = (await client.get_account_info(mint)).owner
if platform == Platform.PUMP_FUN:
curve = impl.address_provider.derive_pool_address(mint)
state = await impl.curve_manager.get_pool_state(curve)
return TokenInfo(
name="",
symbol=str(mint)[:6],
uri="",
mint=mint,
platform=platform,
bonding_curve=curve,
creator=Pubkey.from_string(str(state["creator"])),
quote_mint=state["quote_mint"],
token_program_id=mint_owner,
)
found = await (await client.get_client()).get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
LAUNCHLAB_POOL_SIZE,
MemcmpOpts(offset=LAUNCHLAB_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
pool = found.value[0].pubkey
state = await impl.curve_manager.get_pool_state(pool)
return TokenInfo(
name="",
symbol=str(mint)[:6],
uri="",
mint=mint,
platform=platform,
pool_state=pool,
base_vault=state["base_vault"],
quote_vault=state["quote_vault"],
global_config=state["global_config"],
platform_config=state["platform_config"],
creator=state["creator"],
quote_mint=state["quote_mint"],
token_program_id=mint_owner,
)
async def run(platform: Platform, mint: Pubkey, slippage: float) -> int:
"""Read the price the bot would read, then sell the whole balance."""
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
try:
impl = get_platform_implementations(platform, client)
token_info = await token_info_for(platform, mint, client, impl)
await resolve_quote_token_program(
token_info.quote_mint, client.get_account_info
)
trader = SimpleNamespace(
platform=platform,
platform_implementations=impl,
_graduated_mints=set(),
_get_pool_address=lambda t: t.bonding_curve or t.pool_state,
)
price = await UniversalTrader._read_price(trader, token_info)
print(f"Price via the trader: {price:.12f} quote per token")
ata = wallet.get_associated_token_address(mint, token_info.token_program_id)
balance = await client.get_token_account_balance(ata)
print(f"Holding: {balance / 10**TOKEN_DECIMALS:,.6f} tokens")
seller = PlatformAwareSeller(
client,
wallet,
PriorityFeeManager(
client=client,
enable_dynamic_fee=False,
enable_fixed_fee=True,
fixed_fee=PRIORITY_FEE,
extra_fee=0.0,
hard_cap=PRIORITY_FEE,
),
slippage=slippage,
max_retries=2,
)
result = await seller.execute(token_info, balance / 10**TOKEN_DECIMALS, price)
print(
f"Sell: success={result.success} tx={result.tx_signature} error={result.error_message}"
)
return 0 if result.success else 1
finally:
await client.close()
def main() -> None:
"""Parse the command line and run."""
parser = argparse.ArgumentParser(
description="Sell a held, graduated coin via the bot's path"
)
parser.add_argument("platform", choices=[p.value for p in Platform])
parser.add_argument("mint", help="The coin's mint address")
parser.add_argument("--slippage", type=float, default=DEFAULT_SLIPPAGE)
args = parser.parse_args()
install_secret_redaction()
sys.exit(
asyncio.run(
run(Platform(args.platform), Pubkey.from_string(args.mint), args.slippage)
)
)
if __name__ == "__main__":
main()