mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
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:
co-authored by
Claude Opus 5.5
parent
457aeeb6d8
commit
b5d0fd7761
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user