feat(platforms,cookbook): add StonkFun on a shared Raydium LaunchLab base (#235)

letsbonk.fun and StonkFun now share one LaunchLab implementation that prices
from real reserves, trades any quote mint, spends exactly the buy amount, and
accounts for the curve fee and Token-2022 transfer fees. Cleanup harvests
withheld fees before closing an account.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-10-04 22:29:07 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 75d9087d05
commit d8a0ff7fe5
51 changed files with 9656 additions and 2314 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
# Cookbook
One script per thing you might want to do on pump.fun, PumpSwap and letsbonk.fun.
One script per thing you might want to do on pump.fun, PumpSwap, letsbonk.fun and StonkFun.
Each is standalone: run it with `uv run <path>`, no bot config, no framework. They
read `.env` directly, and most take the mint or curve address as the first argument.
@@ -150,6 +150,21 @@ spend, *exact out* fixes what you receive.
| 💸 [`letsbonk/letsbonk_sell_token_exact_out.py`](letsbonk/letsbonk_sell_token_exact_out.py) | Sell for a fixed amount received |
| [`letsbonk/letsbonk_idl_parser.py`](letsbonk/letsbonk_idl_parser.py) | Loads the LaunchLab IDL for the four above |
## StonkFun
StonkFun runs no program of its own: every coin is a Raydium LaunchLab pool under one of
StonkFun's two platform configs, paired with whatever quote asset its launcher picked —
SOL, a tokenized stock, another coin. Amounts are in that asset. *Reward* coins carry a
1% or 3% Token-2022 transfer fee on every transfer; the scripts account for it.
| Script | What it does |
|---|---|
| [`stonkfun/listen/stonkfun_listen_tokens_geyser.py`](stonkfun/listen/stonkfun_listen_tokens_geyser.py) | New coins over Geyser gRPC, decoded from the launch instruction |
| [`stonkfun/listen/stonkfun_listen_tokens_blocksubscribe.py`](stonkfun/listen/stonkfun_listen_tokens_blocksubscribe.py) | The same over `blockSubscribe` |
| [`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 |
## legacy
Instructions pump.fun has moved on from. They still land on chain and older coins
@@ -0,0 +1,212 @@
"""Listen for new StonkFun coins over blockSubscribe.
Usage:
uv run cookbook/stonkfun/listen/stonkfun_listen_tokens_blocksubscribe.py
One subscription per StonkFun platform config — `mentionsAccountOrProgram`
takes a single address — so only blocks touching StonkFun pools arrive, not
every LaunchLab launchpad. Each transaction is decoded from its own accounts
and arguments, including inner instructions and accounts behind an address
lookup table. Slower than Geyser: a block arrives whole, at `confirmed`.
WebSocket API Reference:
https://solana.com/docs/rpc/websocket/blocksubscribe
"""
import asyncio
import base64
import json
import os
import struct
import base58
import websockets
from dotenv import load_dotenv
from solders.pubkey import Pubkey
from solders.transaction import VersionedTransaction
load_dotenv()
WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# Block frames run far past websockets' 1 MiB default, which closes the
# connection with 1009 instead of delivering them.
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
STONKFUN_PLATFORM_CONFIGS = {
Pubkey.from_string("4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"): "standard",
Pubkey.from_string("6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"): "reward",
}
# Anchor discriminator: the first 8 bytes of the instruction name's hash.
INITIALIZE_WITH_TOKEN_2022 = bytes([37, 190, 126, 222, 44, 154, 171, 17])
# Slots remembered to drop a block both subscriptions delivered.
SEEN_SLOTS_LIMIT = 1000
# Account positions in initialize_with_token_2022, from the IDL.
PAYER, CREATOR, PLATFORM_CONFIG = 0, 1, 3
POOL_STATE, BASE_MINT, QUOTE_MINT = 5, 6, 7
def decode_launch(data: bytes, accounts: list[int], keys: list[Pubkey]) -> dict | None:
"""Decode an initialize_with_token_2022 instruction, or None if it is not one."""
if not data.startswith(INITIALIZE_WITH_TOKEN_2022) or len(accounts) <= QUOTE_MINT:
return None
if max(accounts) >= len(keys):
return None
mode = STONKFUN_PLATFORM_CONFIGS.get(keys[accounts[PLATFORM_CONFIG]])
if mode is None:
return None
offset = 8 + 1 # discriminator, decimals
strings = []
for _ in range(3): # name, symbol, uri
(length,) = struct.unpack_from("<I", data, offset)
strings.append(data[offset + 4 : offset + 4 + length].decode(errors="replace"))
offset += 4 + length
curve_type = data[offset]
offset += 1 + (25 if curve_type == 0 else 17) # the curve's fields
offset += 24 + 1 # vesting, migrated-pool creator fee side
transfer_fee_bps = (
struct.unpack_from("<H", data, offset + 1)[0] if data[offset] == 1 else 0
)
return {
"mode": mode,
"name": strings[0],
"symbol": strings[1],
"mint": keys[accounts[BASE_MINT]],
"pool": keys[accounts[POOL_STATE]],
"quote_mint": keys[accounts[QUOTE_MINT]],
"creator": keys[accounts[CREATOR]],
"transfer_fee_bps": transfer_fee_bps,
}
def instructions_and_keys(
tx: dict,
) -> tuple[list[tuple[int, list[int], bytes]], list[Pubkey]]:
"""Every instruction in a base64 block transaction, and the keys they index.
Lookup-table accounts follow the static keys, writable then read-only. Inner
instructions come from the meta, with base58 data.
"""
message = VersionedTransaction.from_bytes(
base64.b64decode(tx["transaction"][0])
).message
meta = tx.get("meta") or {}
loaded = meta.get("loadedAddresses") or {}
keys = list(message.account_keys) + [
Pubkey.from_string(key)
for key in loaded.get("writable", []) + loaded.get("readonly", [])
]
instructions = [
(ix.program_id_index, list(ix.accounts), bytes(ix.data))
for ix in message.instructions
]
for group in meta.get("innerInstructions") or []:
instructions += [
(ix["programIdIndex"], ix["accounts"], base58.b58decode(ix["data"]))
for ix in group["instructions"]
]
return instructions, keys
def handle_transaction(tx: dict) -> None:
"""Print any StonkFun launch in one block transaction."""
meta = tx.get("meta") or {}
if meta.get("err") is not None:
return
# Cheap gate before decoding the envelope: a launch logs its instruction.
if not any(
"InitializeWithToken2022" in line for line in meta.get("logMessages") or []
):
return
instructions, keys = instructions_and_keys(tx)
for program_index, accounts, data in instructions:
if program_index >= len(keys) or keys[program_index] != LAUNCHLAB_PROGRAM:
continue
launch = decode_launch(data, accounts, keys)
if launch:
print("\n" + "=" * 80)
print(f"NEW STONKFUN COIN ({launch['mode']})")
print("=" * 80)
print(f"Name: {launch['name']} ({launch['symbol']})")
print(f"Mint: {launch['mint']}")
print(f"Pool: {launch['pool']}")
print(f"Quote asset: {launch['quote_mint']}")
print(f"Creator: {launch['creator']}")
print(f"Transfer tax: {launch['transfer_fee_bps'] / 100:.2f}%")
print("=" * 80)
async def listen() -> None:
"""Subscribe to blocks touching either platform config and decode launches."""
async with websockets.connect(
WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
for request_id, config in enumerate(STONKFUN_PLATFORM_CONFIGS, start=1):
await websocket.send(
json.dumps(
{
"jsonrpc": "2.0",
"id": request_id,
"method": "blockSubscribe",
"params": [
{"mentionsAccountOrProgram": str(config)},
{
"commitment": "confirmed",
"encoding": "base64",
"showRewards": False,
"transactionDetails": "full",
# Whole-frame setting: a lower value nulls the
# entire block, not just its v1 transactions.
"maxSupportedTransactionVersion": 1,
},
],
}
)
)
print("Subscribed to blocks touching StonkFun's platform configs")
# Both subscriptions can deliver the same block when it touches both
# configs; remember recent slots so each prints once.
seen_slots: set[int] = set()
while True:
try:
message = json.loads(await websocket.recv())
except websockets.ConnectionClosed:
print("WebSocket connection closed.")
return
if message.get("method") != "blockNotification":
continue
value = message["params"]["result"]["value"]
block = value.get("block")
# `block` is null for a skipped or unavailable slot.
if not block or value["slot"] in seen_slots:
continue
seen_slots.add(value["slot"])
if len(seen_slots) > SEEN_SLOTS_LIMIT:
seen_slots.clear()
for tx in block.get("transactions") or []:
try:
handle_transaction(tx)
except Exception as e: # noqa: BLE001
print(f"Could not decode a transaction: {e}")
async def main() -> None:
"""Reconnect for as long as the script runs."""
while True:
try:
await listen()
except (websockets.WebSocketException, OSError) as e:
print(f"Connection error: {e!s}")
print("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,234 @@
"""Listen for new StonkFun coins over Geyser gRPC.
Usage:
uv run cookbook/stonkfun/listen/stonkfun_listen_tokens_geyser.py
StonkFun has no program of its own: a launch is a Raydium LaunchLab
`initialize_with_token_2022` that names one of StonkFun's two platform configs.
Subscribing to those two accounts instead of to the whole LaunchLab program
keeps every other launchpad on LaunchLab out of the stream. Trades touch the
configs too, so most of what arrives is trades, and the decoder skips them.
Everything printed comes from the instruction itself — no extra RPC call:
the accounts give the mint, pool, quote asset and creator, and the arguments
give the name, the curve and the transfer fee.
Geyser gRPC Reference:
https://docs.triton.one/rpc-pool/grpc-subscriptions
Authentication: Supports both Basic and X-Token authentication methods.
Configure via GEYSER_ENDPOINT, GEYSER_API_TOKEN and GEYSER_AUTH_TYPE.
"""
import asyncio
import os
import struct
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import base58
import grpc
from dotenv import load_dotenv
from solders.pubkey import Pubkey
# The geyser stubs are generated once, into src/geyser/generated. Reuse them rather
# than keeping a second copy here that drifts out of sync with proto/.
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from src.geyser.generated import geyser_pb2, geyser_pb2_grpc
load_dotenv()
GEYSER_ENDPOINT = os.getenv("GEYSER_ENDPOINT")
GEYSER_API_TOKEN = os.getenv("GEYSER_API_TOKEN")
AUTH_TYPE = os.getenv("GEYSER_AUTH_TYPE", "x-token").lower()
BAD_AUTH_TYPE_MSG = "GEYSER_AUTH_TYPE must be 'x-token' or 'basic'"
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
STONKFUN_PLATFORM_CONFIGS = {
Pubkey.from_string("4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"): "standard",
Pubkey.from_string("6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"): "reward",
}
# Anchor discriminator: the first 8 bytes of the instruction name's hash.
INITIALIZE_WITH_TOKEN_2022 = bytes([37, 190, 126, 222, 44, 154, 171, 17])
# Account positions in initialize_with_token_2022, from the IDL. The payer and
# the creator are separate slots: the pool's creator, and the fee vault keyed
# to it, come from index 1, not from whoever paid.
PAYER, CREATOR, GLOBAL_CONFIG, PLATFORM_CONFIG = 0, 1, 2, 3
POOL_STATE, BASE_MINT, QUOTE_MINT = 5, 6, 7
BASE_VAULT, QUOTE_VAULT, QUOTE_TOKEN_PROGRAM = 8, 9, 11
CURVE_TYPES = {0: "constant product", 1: "fixed price", 2: "linear"}
def resolve_account_keys(tx: geyser_pb2.SubscribeUpdateTransactionInfo) -> list[bytes]:
"""All account keys an instruction can index: static, then lookup-table ones.
Launches sent as v0 transactions can put accounts behind an address lookup
table; geyser reports those in the meta, writable first, then read-only.
"""
keys = list(tx.transaction.message.account_keys)
meta = getattr(tx, "meta", None)
if meta is not None:
keys.extend(meta.loaded_writable_addresses)
keys.extend(meta.loaded_readonly_addresses)
return keys
def decode_launch(data: bytes, accounts: bytes, keys: list[bytes]) -> dict | None:
"""Decode an initialize_with_token_2022 instruction, or None if it is not one."""
if (
not data.startswith(INITIALIZE_WITH_TOKEN_2022)
or len(accounts) <= QUOTE_TOKEN_PROGRAM
):
return None
if max(accounts) >= len(keys):
return None
def account(position: int) -> Pubkey:
return Pubkey.from_bytes(keys[accounts[position]])
mode = STONKFUN_PLATFORM_CONFIGS.get(account(PLATFORM_CONFIG))
if mode is None:
return None
offset = 8
def read(fmt: str) -> Any:
nonlocal offset
values = struct.unpack_from(fmt, data, offset)
offset += struct.calcsize(fmt)
return values if len(values) > 1 else values[0]
def read_string() -> str:
nonlocal offset
length = read("<I")
value = data[offset : offset + length].decode(errors="replace")
offset += length
return value
decimals = read("<B")
name, symbol, uri = read_string(), read_string(), read_string()
curve_type = read("<B")
if curve_type == 0:
supply, total_base_sell, raise_target, _migrate = read("<QQQB")
else:
supply, raise_target, _migrate = read("<QQB")
total_base_sell = None
read("<QQQ") # vesting: locked amount, cliff, unlock period
read("<B") # which side the migrated pool's creator fee is taken on
transfer_fee_bps = read("<H") if read("<B") == 1 else 0
return {
"mode": mode,
"name": name,
"symbol": symbol,
"uri": uri,
"mint": account(BASE_MINT),
"pool": account(POOL_STATE),
"quote_mint": account(QUOTE_MINT),
"quote_token_program": account(QUOTE_TOKEN_PROGRAM),
"creator": account(CREATOR),
"payer": account(PAYER),
"global_config": account(GLOBAL_CONFIG),
"decimals": decimals,
"curve": CURVE_TYPES.get(curve_type, str(curve_type)),
"supply": supply,
"total_base_sell": total_base_sell,
"raise_target": raise_target,
"transfer_fee_bps": transfer_fee_bps,
}
def instructions_of(tx: geyser_pb2.SubscribeUpdateTransactionInfo) -> Iterator:
"""Top-level instructions, then the inner ones a router may have issued."""
yield from tx.transaction.message.instructions
meta = getattr(tx, "meta", None)
if meta is not None:
for group in meta.inner_instructions:
yield from group.instructions
def print_launch(launch: dict, signature: str) -> None:
"""Print one launch."""
unit = 10 ** launch["decimals"]
print("\n" + "=" * 80)
print(f"NEW STONKFUN COIN ({launch['mode']})")
print("=" * 80)
print(f"Name: {launch['name']} ({launch['symbol']})")
print(f"Mint: {launch['mint']}")
print(f"Pool: {launch['pool']}")
print(f"Quote asset: {launch['quote_mint']}")
print(f"Creator: {launch['creator']}")
if launch["payer"] != launch["creator"]:
print(f"Paid by: {launch['payer']}")
print(f"Curve: {launch['curve']}, {launch['supply'] / unit:,.0f} supply")
if launch["total_base_sell"] is not None:
print(f"Sold on curve: {launch['total_base_sell'] / unit:,.0f}")
print(f"Raise target: {launch['raise_target']} raw quote units")
print(f"Transfer tax: {launch['transfer_fee_bps'] / 100:.2f}%")
print(f"URI: {launch['uri']}")
print(f"Signature: {signature}")
print("=" * 80)
async def create_geyser_connection() -> geyser_pb2_grpc.GeyserStub:
"""Open a Geyser stub using the configured auth type.
Raises:
ValueError: If GEYSER_AUTH_TYPE names a scheme the endpoint does not take
"""
if AUTH_TYPE == "x-token":
auth = grpc.metadata_call_credentials(
lambda _, callback: callback((("x-token", GEYSER_API_TOKEN),), None)
)
elif AUTH_TYPE == "basic":
auth = grpc.metadata_call_credentials(
lambda _, callback: callback(
(("authorization", f"Basic {GEYSER_API_TOKEN}"),), None
)
)
else:
raise ValueError(BAD_AUTH_TYPE_MSG)
creds = grpc.composite_channel_credentials(grpc.ssl_channel_credentials(), auth)
channel = grpc.aio.secure_channel(GEYSER_ENDPOINT, creds)
return geyser_pb2_grpc.GeyserStub(channel)
def create_subscription_request() -> geyser_pb2.SubscribeRequest:
"""Subscribe to successful transactions touching either StonkFun platform config."""
request = geyser_pb2.SubscribeRequest()
stonkfun = request.transactions["stonkfun"]
stonkfun.account_include.extend(str(config) for config in STONKFUN_PLATFORM_CONFIGS)
stonkfun.failed = False
request.commitment = geyser_pb2.CommitmentLevel.PROCESSED
return request
async def monitor_stonkfun() -> None:
"""Print every StonkFun launch as it happens."""
print(f"Watching StonkFun launches using {AUTH_TYPE.upper()} authentication")
stub = await create_geyser_connection()
async for update in stub.Subscribe(iter([create_subscription_request()])):
if not update.HasField("transaction"):
continue
tx = update.transaction.transaction
keys = resolve_account_keys(tx)
for ix in instructions_of(tx):
if ix.program_id_index >= len(keys):
continue
if Pubkey.from_bytes(keys[ix.program_id_index]) != LAUNCHLAB_PROGRAM:
continue
launch = decode_launch(bytes(ix.data), bytes(ix.accounts), keys)
if launch:
print_launch(launch, base58.b58encode(bytes(tx.signature)).decode())
if __name__ == "__main__":
asyncio.run(monitor_stonkfun())
@@ -0,0 +1,216 @@
"""Read a StonkFun coin's pool: price, quote asset, fees, transfer tax and progress.
Usage:
uv run cookbook/stonkfun/read/stonkfun_read_pool.py <MINT>
StonkFun runs no program of its own. Every coin is a Raydium LaunchLab pool
created under one of StonkFun's two platform configs: *standard*, or *reward*,
where the coin is a Token-2022 mint with a 1% or 3% transfer fee that is paid
out to holders. The quote asset is whatever the launcher picked — SOL, a
tokenized stock, a stablecoin, another coin — so everything here is in the
pool's own quote units.
The price is `(virtual_quote + real_quote) / (virtual_base - real_base)`. The
virtual reserves are fixed at launch; the real ones carry every trade since.
Reading the virtual reserves alone gives the launch price forever.
"""
import argparse
import asyncio
import os
import struct
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
from solders.pubkey import Pubkey
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
STONKFUN_PLATFORM_CONFIGS = {
Pubkey.from_string("4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"): "standard",
Pubkey.from_string("6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"): "reward",
}
POOL_STATE_SIZE = 429
# PoolState: 8 discriminator, epoch u64, five u8 flags, ten u64 amounts, a
# five-u64 vesting schedule, then the pubkeys.
POOL_PUBKEYS_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8
POOL_BASE_MINT_OFFSET = POOL_PUBKEYS_OFFSET + 2 * 32
POOL_STATUS = {0: "trading on the curve", 1: "waiting for migration", 2: "migrated"}
# Fee rates are parts per million of the quote side.
FEE_RATE_DENOMINATOR = 1_000_000
# GlobalConfig: discriminator, epoch, curve_type u8, index u16, migrate_fee u64.
GLOBAL_TRADE_FEE_RATE_OFFSET = 8 + 8 + 1 + 2 + 8
# PlatformConfig: discriminator, epoch, two wallets, three u64 scales.
PLATFORM_FEE_RATE_OFFSET = 8 + 8 + 32 + 32 + 3 * 8
# then name[64], web[256], img[256], cpswap_config.
PLATFORM_CREATOR_FEE_RATE_OFFSET = PLATFORM_FEE_RATE_OFFSET + 8 + 64 + 256 + 256 + 32
# Token-2022 mint extensions start after the 82-byte base mint, padding to
# 165 bytes and a one-byte account type.
TOKEN_2022_EXTENSIONS_OFFSET = 166
TRANSFER_FEE_CONFIG_EXTENSION = 1
def decode_pool(data: bytes) -> dict:
"""Decode the PoolState fields a trader needs."""
u64s = struct.unpack_from("<10Q", data, 8 + 8 + 5)
pubkeys = [
Pubkey.from_bytes(
data[POOL_PUBKEYS_OFFSET + 32 * i : POOL_PUBKEYS_OFFSET + 32 * (i + 1)]
)
for i in range(7)
]
return {
"status": data[17],
"base_decimals": data[18],
"quote_decimals": data[19],
"supply": u64s[0],
"total_base_sell": u64s[1],
"virtual_base": u64s[2],
"virtual_quote": u64s[3],
"real_base": u64s[4],
"real_quote": u64s[5],
"total_quote_fund_raising": u64s[6],
"global_config": pubkeys[0],
"platform_config": pubkeys[1],
"base_mint": pubkeys[2],
"quote_mint": pubkeys[3],
"base_vault": pubkeys[4],
"quote_vault": pubkeys[5],
"creator": pubkeys[6],
}
def transfer_fee(mint_data: bytes, epoch: int) -> tuple[int, int]:
"""The transfer fee in force for `epoch`, as (basis points, max fee raw).
(0, 0) when the mint has no TransferFeeConfig extension. A rate change is
scheduled two epochs ahead, so the newer entry only applies from its epoch.
"""
offset = TOKEN_2022_EXTENSIONS_OFFSET
while offset + 4 <= len(mint_data):
kind, length = struct.unpack_from("<HH", mint_data, offset)
body = mint_data[offset + 4 : offset + 4 + length]
if kind == TRANSFER_FEE_CONFIG_EXTENSION:
# Two authorities and the withheld amount, then older and newer
# fees, each (epoch u64, maximum_fee u64, basis_points u16).
older = struct.unpack_from("<QQH", body, 72)
newer = struct.unpack_from("<QQH", body, 90)
active = newer if epoch >= newer[0] else older
return active[2], active[1]
if kind == 0 and length == 0:
break
offset += 4 + length
return 0, 0
async def find_pool(client: AsyncClient, mint: Pubkey) -> Pubkey:
"""Find a coin's pool without knowing its quote asset.
The pool address is derived from both mints, so a coin quoted in an asset
you did not guess derives to an empty account. Filtering the program's
accounts on the base mint finds it whatever the quote.
Raises:
ValueError: If no LaunchLab pool has this base mint
"""
response = await client.get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
POOL_STATE_SIZE,
MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
if not response.value:
raise ValueError(f"No LaunchLab pool found for {mint}")
return response.value[0].pubkey
async def read_pool(mint: Pubkey) -> None:
"""Print one StonkFun coin's pool state."""
async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client:
pool_address = await find_pool(client, mint)
pool = decode_pool((await client.get_account_info(pool_address)).value.data)
mode = STONKFUN_PLATFORM_CONFIGS.get(pool["platform_config"])
if mode is None:
print(f"{mint} is a LaunchLab coin, but not a StonkFun one")
print(f"(platform config {pool['platform_config']}).")
return
accounts = (
await client.get_multiple_accounts(
[pool["global_config"], pool["platform_config"], mint]
)
).value
global_data, platform_data, mint_data = (a.data for a in accounts)
trade_fee_rate = struct.unpack_from(
"<Q", global_data, GLOBAL_TRADE_FEE_RATE_OFFSET
)[0]
platform_fee_rate = struct.unpack_from(
"<Q", platform_data, PLATFORM_FEE_RATE_OFFSET
)[0]
creator_fee_rate = struct.unpack_from(
"<Q", platform_data, PLATFORM_CREATOR_FEE_RATE_OFFSET
)[0]
epoch = (await client.get_epoch_info()).value.epoch
tax_bps, _ = transfer_fee(mint_data, epoch)
base_unit = 10 ** pool["base_decimals"]
quote_unit = 10 ** pool["quote_decimals"]
quote_reserve = pool["virtual_quote"] + pool["real_quote"]
base_reserve = pool["virtual_base"] - pool["real_base"]
price = (quote_reserve / quote_unit) / (base_reserve / base_unit)
launch_price = (pool["virtual_quote"] / quote_unit) / (
pool["virtual_base"] / base_unit
)
total_fee_rate = trade_fee_rate + platform_fee_rate + creator_fee_rate
print(f"Mint: {mint}")
print(f"Pool: {pool_address}")
print(f"Mode: {mode}")
print(f"Status: {POOL_STATUS.get(pool['status'], pool['status'])}")
print(f"Creator: {pool['creator']}")
print(
f"Quote asset: {pool['quote_mint']} ({pool['quote_decimals']} decimals)"
)
print(f"Price: {price:.12f} quote per token")
print(f"Launch price: {launch_price:.12f} quote per token")
print(
f"Curve fee: {total_fee_rate / FEE_RATE_DENOMINATOR:.2%} of the quote side "
f"(Raydium {trade_fee_rate / FEE_RATE_DENOMINATOR:.2%}, "
f"platform {platform_fee_rate / FEE_RATE_DENOMINATOR:.2%}, "
f"creator {creator_fee_rate / FEE_RATE_DENOMINATOR:.2%})"
)
print(f"Transfer tax: {tax_bps / 100:.2f}% on every transfer of the coin")
print(
f"Raised: {pool['real_quote'] / quote_unit:.6f} of "
f"{pool['total_quote_fund_raising'] / quote_unit:.6f} quote "
f"({pool['real_quote'] / pool['total_quote_fund_raising']:.2%} to graduation)"
)
print(
f"Sold: {pool['real_base'] / base_unit:,.0f} of "
f"{pool['total_base_sell'] / base_unit:,.0f} tokens on the curve"
)
def main() -> None:
"""Parse the command line and read the pool."""
parser = argparse.ArgumentParser(description="Read a StonkFun coin's pool state")
parser.add_argument("mint", help="The coin's mint address")
args = parser.parse_args()
asyncio.run(read_pool(Pubkey.from_string(args.mint)))
if __name__ == "__main__":
main()
@@ -0,0 +1,400 @@
"""Spend an exact amount of a StonkFun coin's quote asset on it, using buy_exact_in.
WARNING: this submits a real transaction and spends real funds.
Usage:
uv run cookbook/stonkfun/trade/stonkfun_buy_token.py <MINT> <AMOUNT>
uv run cookbook/stonkfun/trade/stonkfun_buy_token.py <MINT> 0.001 --dry-run
StonkFun coins are Raydium LaunchLab pools, and each one is paired with the
quote asset its launcher picked. AMOUNT is in that asset: 0.001 means 0.001 SOL
on a SOL-paired coin and 0.001 SPCXX on a SpaceX-paired one. For SOL the script
wraps the amount into a temporary account and closes it afterwards. For anything
else you must already hold the asset.
`buy_exact_in` spends exactly AMOUNT. The curve fee comes out of it first, and on
a *reward* coin the Token-2022 transfer fee then comes out of the tokens on their
way to you. The expected amount below accounts for both, so `--slippage` only
has to cover the price moving.
"""
import argparse
import asyncio
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 = 200_000
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
STONKFUN_PLATFORM_CONFIGS = {
Pubkey.from_string("4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"),
Pubkey.from_string("6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"),
}
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
BUY_EXACT_IN_DISCRIMINATOR = bytes([250, 234, 13, 123, 213, 156, 19, 236])
# A front end may charge its own share fee on top; this script charges none.
SHARE_FEE_RATE = 0
# Rent-exempt minimum for a 165-byte token account.
TOKEN_ACCOUNT_SIZE = 165
TOKEN_ACCOUNT_RENT = 2_039_280
POOL_STATE_SIZE = 429
POOL_PUBKEYS_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8
POOL_BASE_MINT_OFFSET = POOL_PUBKEYS_OFFSET + 2 * 32
FEE_RATE_DENOMINATOR = 1_000_000
GLOBAL_TRADE_FEE_RATE_OFFSET = 8 + 8 + 1 + 2 + 8
PLATFORM_FEE_RATE_OFFSET = 8 + 8 + 32 + 32 + 3 * 8
PLATFORM_CREATOR_FEE_RATE_OFFSET = PLATFORM_FEE_RATE_OFFSET + 8 + 64 + 256 + 256 + 32
TOKEN_2022_EXTENSIONS_OFFSET = 166
TRANSFER_FEE_CONFIG_EXTENSION = 1
def decode_pool(data: bytes) -> dict:
"""Decode the PoolState fields a trade needs."""
u64s = struct.unpack_from("<10Q", data, 8 + 8 + 5)
pubkeys = [
Pubkey.from_bytes(
data[POOL_PUBKEYS_OFFSET + 32 * i : POOL_PUBKEYS_OFFSET + 32 * (i + 1)]
)
for i in range(7)
]
return {
"status": data[17],
"base_decimals": data[18],
"quote_decimals": data[19],
"virtual_base": u64s[2],
"virtual_quote": u64s[3],
"real_base": u64s[4],
"real_quote": u64s[5],
"global_config": pubkeys[0],
"platform_config": pubkeys[1],
"base_mint": pubkeys[2],
"quote_mint": pubkeys[3],
"base_vault": pubkeys[4],
"quote_vault": pubkeys[5],
"creator": pubkeys[6],
}
def transfer_fee(mint_data: bytes, epoch: int) -> tuple[int, int]:
"""The transfer fee in force for `epoch`, as (basis points, max fee raw)."""
offset = TOKEN_2022_EXTENSIONS_OFFSET
while offset + 4 <= len(mint_data):
kind, length = struct.unpack_from("<HH", mint_data, offset)
body = mint_data[offset + 4 : offset + 4 + length]
if kind == TRANSFER_FEE_CONFIG_EXTENSION:
older = struct.unpack_from("<QQH", body, 72)
newer = struct.unpack_from("<QQH", body, 90)
active = newer if epoch >= newer[0] else older
return active[2], active[1]
if kind == 0 and length == 0:
break
offset += 4 + length
return 0, 0
def expected_tokens_out(pool: dict, amount_in: int, fee_rate: int, tax: tuple) -> int:
"""Raw tokens that arrive in your account for `amount_in` raw quote.
The curve fee is taken from the input, rounded up. The rest trades against
the constant-product curve, then the transfer fee is withheld on delivery.
"""
fee = -(-amount_in * fee_rate // FEE_RATE_DENOMINATOR)
net_in = amount_in - fee
base_reserve = pool["virtual_base"] - pool["real_base"]
quote_reserve = pool["virtual_quote"] + pool["real_quote"]
out = base_reserve * net_in // (quote_reserve + net_in)
bps, max_fee = tax
withheld = min(-(-out * bps // 10_000), max_fee) if bps else 0
return out - withheld
def pda(seeds: list[bytes]) -> Pubkey:
"""A LaunchLab program-derived address."""
return Pubkey.find_program_address(seeds, LAUNCHLAB_PROGRAM)[0]
async def find_pool(client: AsyncClient, mint: Pubkey) -> Pubkey:
"""Find a coin's pool by its base mint, whatever it is quoted in."""
response = await client.get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
POOL_STATE_SIZE,
MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
if not response.value:
raise ValueError(f"No LaunchLab pool found for {mint}")
return response.value[0].pubkey
def build_buy_exact_in(
pool_address: Pubkey,
pool: dict,
payer: Pubkey,
user_base_account: Pubkey,
user_quote_account: Pubkey,
quote_token_program: Pubkey,
amount_in: int,
minimum_amount_out: int,
) -> Instruction:
"""The 15 accounts the IDL names, then three the program reads positionally.
The fee vaults are keyed by quote mint, so a coin paired with anything but
SOL needs them derived with its own quote, not with wrapped SOL.
"""
quote_mint = pool["quote_mint"]
accounts = [
AccountMeta(payer, is_signer=True, is_writable=True),
AccountMeta(pda([b"vault_auth_seed"]), is_signer=False, is_writable=False),
AccountMeta(pool["global_config"], is_signer=False, is_writable=False),
AccountMeta(pool["platform_config"], is_signer=False, is_writable=False),
AccountMeta(pool_address, is_signer=False, is_writable=True),
AccountMeta(user_base_account, is_signer=False, is_writable=True),
AccountMeta(user_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_mint"], is_signer=False, is_writable=False),
AccountMeta(quote_mint, is_signer=False, is_writable=False),
AccountMeta(TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(quote_token_program, is_signer=False, is_writable=False),
AccountMeta(pda([b"__event_authority"]), is_signer=False, is_writable=False),
AccountMeta(LAUNCHLAB_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pda([bytes(pool["platform_config"]), bytes(quote_mint)]),
is_signer=False,
is_writable=True,
),
AccountMeta(
pda([bytes(pool["creator"]), bytes(quote_mint)]),
is_signer=False,
is_writable=True,
),
]
data = BUY_EXACT_IN_DISCRIMINATOR + struct.pack(
"<QQQ", amount_in, minimum_amount_out, SHARE_FEE_RATE
)
return Instruction(LAUNCHLAB_PROGRAM, data, accounts)
async def buy(mint: Pubkey, amount: float, slippage: float, *, dry_run: bool) -> None:
"""Spend exactly `amount` of the coin's quote asset on it."""
payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY))
user = payer.pubkey()
async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client:
pool_address = await find_pool(client, mint)
pool = decode_pool((await client.get_account_info(pool_address)).value.data)
if pool["platform_config"] not in STONKFUN_PLATFORM_CONFIGS:
raise ValueError(f"{mint} is a LaunchLab coin, but not a StonkFun one")
if pool["status"] != 0:
raise ValueError(f"{mint} has left the curve (status {pool['status']})")
quote_mint = pool["quote_mint"]
global_info, platform_info, mint_info, quote_info = (
await client.get_multiple_accounts(
[pool["global_config"], pool["platform_config"], mint, quote_mint]
)
).value
fee_rate = (
struct.unpack_from("<Q", global_info.data, GLOBAL_TRADE_FEE_RATE_OFFSET)[0]
+ struct.unpack_from("<Q", platform_info.data, PLATFORM_FEE_RATE_OFFSET)[0]
+ struct.unpack_from(
"<Q", platform_info.data, PLATFORM_CREATOR_FEE_RATE_OFFSET
)[0]
)
epoch = (await client.get_epoch_info()).value.epoch
tax = transfer_fee(mint_info.data, epoch)
# xStocks and some other quotes are Token-2022 mints; the program needs
# the quote's own token program, so read it rather than assume.
quote_token_program = quote_info.owner
quote_unit = 10 ** pool["quote_decimals"]
base_unit = 10 ** pool["base_decimals"]
amount_in = int(amount * quote_unit)
expected = expected_tokens_out(pool, amount_in, fee_rate, tax)
minimum_out = int(expected * (1 - slippage))
print(f"Mint: {mint}")
print(f"Pool: {pool_address}")
print(f"Quote asset: {quote_mint}")
print(f"Spending: {amount} ({amount_in} raw)")
print(f"Curve fee: {fee_rate / FEE_RATE_DENOMINATOR:.2%}")
print(f"Transfer tax: {tax[0] / 100:.2f}%")
print(f"Expecting: {expected / base_unit:,.6f} tokens after fee and tax")
print(f"Accepting: >= {minimum_out / base_unit:,.6f} tokens")
user_base_account = get_associated_token_address(user, mint, TOKEN_2022_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=TOKEN_2022_PROGRAM
),
]
is_sol = quote_mint == WSOL_MINT
if is_sol:
# A throwaway account at a fresh seed, funded with the spend and
# closed in the same transaction. It leaves any WSOL you already
# hold alone.
seed = secrets.token_hex(16)
user_quote_account = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM)
instructions += [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=user_quote_account,
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_account,
mint=WSOL_MINT,
owner=user,
)
),
]
else:
user_quote_account = get_associated_token_address(
user, quote_mint, quote_token_program
)
held = 0
if (await client.get_account_info(user_quote_account)).value is not None:
balance = await client.get_token_account_balance(user_quote_account)
held = int(balance.value.amount)
if held < amount_in:
raise ValueError(
f"Need {amount_in} raw units of {quote_mint}, hold {held}"
)
instructions.append(
build_buy_exact_in(
pool_address,
pool,
user,
user_base_account,
user_quote_account,
quote_token_program,
amount_in,
minimum_out,
)
)
if is_sol:
instructions.append(
close_account(
CloseAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote_account,
dest=user,
owner=user,
)
)
)
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="Spend an exact amount of a StonkFun coin's quote asset on it"
)
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,444 @@
"""Sell a StonkFun coin back into its pool with sell_exact_in.
WARNING: this submits a real transaction and spends real funds.
Usage:
uv run cookbook/stonkfun/trade/stonkfun_sell_token.py <MINT>
uv run cookbook/stonkfun/trade/stonkfun_sell_token.py <MINT> --close
uv run cookbook/stonkfun/trade/stonkfun_sell_token.py <MINT> --amount 1000 --dry-run
Sells your whole balance unless `--amount` says otherwise, and pays out in the
pool's quote asset: SOL for a SOL-paired coin, received into a temporary account
and unwrapped in the same transaction; anything else lands in your account for
that asset.
On a *reward* coin the Token-2022 transfer fee is withheld from the tokens on
their way into the pool, so the pool trades on fewer than you sent, and the
curve fee then comes out of the quote side. The expected payout accounts for
both.
The same fee was withheld inside *your* account when you bought, and an account
holding withheld fees cannot be closed. `--close` first harvests them into the
mint — an instruction anyone may call — then closes the account and returns its
rent. Without it, a sold-out reward-coin account keeps its rent locked.
"""
import argparse
import asyncio
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 = 200_000
LAUNCHLAB_PROGRAM = Pubkey.from_string("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj")
STONKFUN_PLATFORM_CONFIGS = {
Pubkey.from_string("4E876qZTE9FJMrBzgVtBrSrzz2TLivB5Y5QXPjB4gZL7"),
Pubkey.from_string("6BwHHDg3u1854jC8PDLXvR4spTcLNaoBxLJNGC4nTESt"),
}
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
SELL_EXACT_IN_DISCRIMINATOR = bytes([149, 39, 222, 155, 211, 124, 152, 26])
SHARE_FEE_RATE = 0
# Token-2022: TransferFeeExtension, then HarvestWithheldTokensToMint.
HARVEST_WITHHELD_TO_MINT = bytes([26, 4])
TOKEN_ACCOUNT_SIZE = 165
TOKEN_ACCOUNT_RENT = 2_039_280
POOL_STATE_SIZE = 429
POOL_PUBKEYS_OFFSET = 8 + 8 + 5 + 10 * 8 + 5 * 8
POOL_BASE_MINT_OFFSET = POOL_PUBKEYS_OFFSET + 2 * 32
FEE_RATE_DENOMINATOR = 1_000_000
GLOBAL_TRADE_FEE_RATE_OFFSET = 8 + 8 + 1 + 2 + 8
PLATFORM_FEE_RATE_OFFSET = 8 + 8 + 32 + 32 + 3 * 8
PLATFORM_CREATOR_FEE_RATE_OFFSET = PLATFORM_FEE_RATE_OFFSET + 8 + 64 + 256 + 256 + 32
TOKEN_2022_EXTENSIONS_OFFSET = 166
TRANSFER_FEE_CONFIG_EXTENSION = 1
def decode_pool(data: bytes) -> dict:
"""Decode the PoolState fields a trade needs."""
u64s = struct.unpack_from("<10Q", data, 8 + 8 + 5)
pubkeys = [
Pubkey.from_bytes(
data[POOL_PUBKEYS_OFFSET + 32 * i : POOL_PUBKEYS_OFFSET + 32 * (i + 1)]
)
for i in range(7)
]
return {
"status": data[17],
"base_decimals": data[18],
"quote_decimals": data[19],
"virtual_base": u64s[2],
"virtual_quote": u64s[3],
"real_base": u64s[4],
"real_quote": u64s[5],
"global_config": pubkeys[0],
"platform_config": pubkeys[1],
"base_mint": pubkeys[2],
"quote_mint": pubkeys[3],
"base_vault": pubkeys[4],
"quote_vault": pubkeys[5],
"creator": pubkeys[6],
}
def transfer_fee(mint_data: bytes, epoch: int) -> tuple[int, int] | None:
"""The transfer fee in force for `epoch`, as (basis points, max fee raw).
None when the mint has no TransferFeeConfig extension at all, which is what
decides whether there is anything to harvest.
"""
offset = TOKEN_2022_EXTENSIONS_OFFSET
while offset + 4 <= len(mint_data):
kind, length = struct.unpack_from("<HH", mint_data, offset)
body = mint_data[offset + 4 : offset + 4 + length]
if kind == TRANSFER_FEE_CONFIG_EXTENSION:
older = struct.unpack_from("<QQH", body, 72)
newer = struct.unpack_from("<QQH", body, 90)
active = newer if epoch >= newer[0] else older
return active[2], active[1]
if kind == 0 and length == 0:
break
offset += 4 + length
return None
def expected_quote_out(pool: dict, amount_in: int, fee_rate: int, tax: tuple) -> int:
"""Raw quote paid out for selling `amount_in` raw tokens.
The transfer fee is withheld on the way into the pool, so the curve trades
on what arrives. The curve fee is then taken from the quote side, rounded up.
"""
bps, max_fee = tax
withheld = min(-(-amount_in * bps // 10_000), max_fee) if bps else 0
arrived = amount_in - withheld
base_reserve = pool["virtual_base"] - pool["real_base"]
quote_reserve = pool["virtual_quote"] + pool["real_quote"]
gross = quote_reserve * arrived // (base_reserve + arrived)
return gross - -(-gross * fee_rate // FEE_RATE_DENOMINATOR)
def pda(seeds: list[bytes]) -> Pubkey:
"""A LaunchLab program-derived address."""
return Pubkey.find_program_address(seeds, LAUNCHLAB_PROGRAM)[0]
async def find_pool(client: AsyncClient, mint: Pubkey) -> Pubkey:
"""Find a coin's pool by its base mint, whatever it is quoted in."""
response = await client.get_program_accounts(
LAUNCHLAB_PROGRAM,
encoding="base64",
data_slice=DataSliceOpts(offset=0, length=0),
filters=[
POOL_STATE_SIZE,
MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(mint)),
],
)
if not response.value:
raise ValueError(f"No LaunchLab pool found for {mint}")
return response.value[0].pubkey
def build_sell_exact_in(
pool_address: Pubkey,
pool: dict,
payer: Pubkey,
user_base_account: Pubkey,
user_quote_account: Pubkey,
quote_token_program: Pubkey,
amount_in: int,
minimum_amount_out: int,
) -> Instruction:
"""Same 18 accounts as the buy; only the discriminator and the direction differ."""
quote_mint = pool["quote_mint"]
accounts = [
AccountMeta(payer, is_signer=True, is_writable=True),
AccountMeta(pda([b"vault_auth_seed"]), is_signer=False, is_writable=False),
AccountMeta(pool["global_config"], is_signer=False, is_writable=False),
AccountMeta(pool["platform_config"], is_signer=False, is_writable=False),
AccountMeta(pool_address, is_signer=False, is_writable=True),
AccountMeta(user_base_account, is_signer=False, is_writable=True),
AccountMeta(user_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_mint"], is_signer=False, is_writable=False),
AccountMeta(quote_mint, is_signer=False, is_writable=False),
AccountMeta(TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(quote_token_program, is_signer=False, is_writable=False),
AccountMeta(pda([b"__event_authority"]), is_signer=False, is_writable=False),
AccountMeta(LAUNCHLAB_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pda([bytes(pool["platform_config"]), bytes(quote_mint)]),
is_signer=False,
is_writable=True,
),
AccountMeta(
pda([bytes(pool["creator"]), bytes(quote_mint)]),
is_signer=False,
is_writable=True,
),
]
data = SELL_EXACT_IN_DISCRIMINATOR + struct.pack(
"<QQQ", amount_in, minimum_amount_out, SHARE_FEE_RATE
)
return Instruction(LAUNCHLAB_PROGRAM, data, accounts)
def build_harvest_to_mint(mint: Pubkey, token_account: Pubkey) -> Instruction:
"""Move the fees withheld in `token_account` into the mint. Needs no signer."""
return Instruction(
TOKEN_2022_PROGRAM,
HARVEST_WITHHELD_TO_MINT,
[
AccountMeta(mint, is_signer=False, is_writable=True),
AccountMeta(token_account, is_signer=False, is_writable=True),
],
)
async def sell(
mint: Pubkey,
amount: float | None,
slippage: float,
*,
close: bool,
dry_run: bool,
) -> None:
"""Sell `amount` tokens, or the whole balance when it is None."""
payer = Keypair.from_bytes(base58.b58decode(PRIVATE_KEY))
user = payer.pubkey()
async with AsyncClient(RPC_ENDPOINT, commitment=Confirmed) as client:
pool_address = await find_pool(client, mint)
pool = decode_pool((await client.get_account_info(pool_address)).value.data)
if pool["platform_config"] not in STONKFUN_PLATFORM_CONFIGS:
raise ValueError(f"{mint} is a LaunchLab coin, but not a StonkFun one")
if pool["status"] != 0:
raise ValueError(f"{mint} has left the curve (status {pool['status']})")
user_base_account = get_associated_token_address(user, mint, TOKEN_2022_PROGRAM)
if (await client.get_account_info(user_base_account)).value is None:
raise ValueError(f"You hold no {mint}")
held = int(
(await client.get_token_account_balance(user_base_account)).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}")
if close and amount_in != held:
raise ValueError("--close needs the whole balance sold; drop --amount")
quote_mint = pool["quote_mint"]
global_info, platform_info, mint_info, quote_info = (
await client.get_multiple_accounts(
[pool["global_config"], pool["platform_config"], mint, quote_mint]
)
).value
fee_rate = (
struct.unpack_from("<Q", global_info.data, GLOBAL_TRADE_FEE_RATE_OFFSET)[0]
+ struct.unpack_from("<Q", platform_info.data, PLATFORM_FEE_RATE_OFFSET)[0]
+ struct.unpack_from(
"<Q", platform_info.data, PLATFORM_CREATOR_FEE_RATE_OFFSET
)[0]
)
epoch = (await client.get_epoch_info()).value.epoch
tax = transfer_fee(mint_info.data, epoch)
quote_token_program = quote_info.owner
quote_unit = 10 ** pool["quote_decimals"]
expected = expected_quote_out(pool, amount_in, fee_rate, tax or (0, 0))
minimum_out = max(1, int(expected * (1 - slippage)))
print(f"Mint: {mint}")
print(f"Pool: {pool_address}")
print(f"Quote asset: {quote_mint}")
print(f"Selling: {amount_in / base_unit:,.6f} tokens")
print(f"Curve fee: {fee_rate / FEE_RATE_DENOMINATOR:.2%}")
print(f"Transfer tax: {(tax or (0, 0))[0] / 100:.2f}%")
print(f"Expecting: {expected / quote_unit:.10f} quote after tax and fee")
print(f"Accepting: >= {minimum_out / quote_unit:.10f} quote")
instructions = [
set_compute_unit_limit(COMPUTE_UNIT_LIMIT),
set_compute_unit_price(PRIORITY_FEE_MICROLAMPORTS),
]
is_sol = quote_mint == WSOL_MINT
if is_sol:
seed = secrets.token_hex(16)
user_quote_account = Pubkey.create_with_seed(user, seed, TOKEN_PROGRAM)
instructions += [
create_account_with_seed(
CreateAccountWithSeedParams(
from_pubkey=user,
to_pubkey=user_quote_account,
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_account,
mint=WSOL_MINT,
owner=user,
)
),
]
else:
user_quote_account = get_associated_token_address(
user, quote_mint, quote_token_program
)
instructions.append(
create_idempotent_associated_token_account(
user, user, quote_mint, token_program_id=quote_token_program
)
)
instructions.append(
build_sell_exact_in(
pool_address,
pool,
user,
user_base_account,
user_quote_account,
quote_token_program,
amount_in,
minimum_out,
)
)
if is_sol:
instructions.append(
close_account(
CloseAccountParams(
program_id=TOKEN_PROGRAM,
account=user_quote_account,
dest=user,
owner=user,
)
)
)
if close:
# Harvesting needs the mint to have a transfer fee at all; a
# standard coin has nothing withheld and closes directly.
if tax is not None:
instructions.append(build_harvest_to_mint(mint, user_base_account))
instructions.append(
close_account(
CloseAccountParams(
program_id=TOKEN_2022_PROGRAM,
account=user_base_account,
dest=user,
owner=user,
)
)
)
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 StonkFun coin into its 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(
"--close",
action="store_true",
help="Harvest withheld fees and close the coin's account after selling all of it",
)
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,
close=args.close,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()