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