mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
A graduated curve takes no trades. Curve managers now report it and refuse a price, and the trader prices and sells through the platform's post-graduation market: the canonical PumpSwap pool for pump.fun, the Raydium CPMM pool for LaunchLab. Both pools are derived from the mint. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
514 lines
19 KiB
Python
514 lines
19 KiB
Python
"""Verify LaunchLab trades price, size and build the way the program settles them.
|
|
|
|
Every StonkFun and letsbonk.fun coin is a Raydium LaunchLab pool. The letsbonk
|
|
code this replaced priced off the pool's virtual reserves alone — fixed at
|
|
launch — so every pool read as its launch price forever; passed a slippage-
|
|
padded amount to `buy_exact_in`, which spends all of it; ignored the curve fee
|
|
and the Token-2022 transfer fee; and hardcoded wrapped SOL as the quote.
|
|
|
|
Fixture: `raw_stonkfun_trades_from_gettransaction.json`, real StonkFun trades —
|
|
a SOL-quoted standard sell, xStock-quoted reward trades (a buy of a 1%
|
|
transfer-fee coin, a sell of a 3% one), a STONK-quoted standard buy — each with the GlobalConfig,
|
|
PlatformConfig and base mint accounts it traded against. The program's own
|
|
TradeEvent in each transaction records the reserves before the trade.
|
|
|
|
Offline machine checks, no network and no funds moved:
|
|
|
|
A. The curve manager reads the fee rate and transfer fee from the fixture
|
|
accounts, and the curve math then reproduces each trade to the unit: a
|
|
buy delivers exactly the buyer's token balance change, a sell pays exactly
|
|
the event's amount out, and the pool receives exactly the sent amount less
|
|
the transfer fee.
|
|
B. The price comes from virtual plus real reserves, and differs from the
|
|
launch price on every fixture.
|
|
C. The builder's swap instruction names the same 18 accounts, in order, as
|
|
the trade on chain — fee vaults keyed by the pool's own quote mint — and
|
|
its writable flags match the IDL.
|
|
D. The buyer hands an exact-in builder the configured amount unpadded, and
|
|
floors tokens net of curve fee and transfer fee; a builder that is not
|
|
exact-in still gets the padded ceiling.
|
|
E. The seller's floor is net of transfer fee and curve fee.
|
|
F. Single-token mode passes over a coin its filters refuse — an unconfigured
|
|
quote asset, a transfer fee above `max_transfer_fee_bps` — and waits for
|
|
one it can buy, instead of exiting on the first launch it sees.
|
|
|
|
Usage:
|
|
uv run tests/regression/verify_launchlab_trades.py
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import base58
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
|
|
|
from solders.pubkey import Pubkey # noqa: E402
|
|
|
|
from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402
|
|
from interfaces.core import Platform, TokenInfo # noqa: E402
|
|
from platforms.launchlab import LAUNCHLAB_PROGRAM # noqa: E402
|
|
from platforms.launchlab.curve_math import ( # noqa: E402
|
|
buy_amount_out,
|
|
sell_amount_out,
|
|
withheld_fee,
|
|
)
|
|
from platforms.launchlab.instruction_builder import _SWAP_ACCOUNTS # noqa: E402
|
|
from platforms.stonkfun import ( # noqa: E402
|
|
StonkFunAddressProvider,
|
|
StonkFunCurveManager,
|
|
StonkFunInstructionBuilder,
|
|
)
|
|
from trading import platform_aware # noqa: E402
|
|
from trading.platform_aware import PlatformAwareBuyer, PlatformAwareSeller # noqa: E402
|
|
from trading.universal_trader import UniversalTrader # noqa: E402
|
|
from utils.idl_manager import get_idl_manager # noqa: E402
|
|
|
|
FIXTURE = Path(__file__).with_name("raw_stonkfun_trades_from_gettransaction.json")
|
|
IDL = json.loads((PROJECT_ROOT / "idl" / "raydium_launchlab_idl.json").read_text())
|
|
|
|
# Anchor's emit_cpi wraps an event in a self-invocation with this tag.
|
|
EVENT_CPI_TAG = bytes.fromhex("e445a52e51cb9a1d")
|
|
TRADE_EVENT = bytes(
|
|
next(e for e in IDL["events"] if e["name"] == "TradeEvent")["discriminator"]
|
|
)
|
|
BUY_EXACT_IN = bytes(
|
|
next(i for i in IDL["instructions"] if i["name"] == "buy_exact_in")["discriminator"]
|
|
)
|
|
|
|
PARSER = get_idl_manager().get_parser(Platform.STONK_FUN)
|
|
PROVIDER = StonkFunAddressProvider()
|
|
TRADER = Pubkey.from_string("11111111111111111111111111111112")
|
|
|
|
|
|
class _FixtureClient:
|
|
"""Serves the fixture's config and mint accounts by address."""
|
|
|
|
def __init__(self, accounts: dict[Pubkey, bytes]) -> None:
|
|
self.accounts = accounts
|
|
|
|
async def get_multiple_accounts(
|
|
self,
|
|
pubkeys: list[Pubkey],
|
|
commitment: str | None = None, # noqa: ARG002
|
|
) -> list[SimpleNamespace]:
|
|
return [SimpleNamespace(data=self.accounts[k], owner=None) for k in pubkeys]
|
|
|
|
|
|
def _keys(tx: dict) -> list[str]:
|
|
loaded = tx["meta"].get("loadedAddresses") or {}
|
|
return (
|
|
tx["transaction"]["message"]["accountKeys"]
|
|
+ loaded.get("writable", [])
|
|
+ loaded.get("readonly", [])
|
|
)
|
|
|
|
|
|
def _instructions(tx: dict):
|
|
yield from tx["transaction"]["message"]["instructions"]
|
|
for group in tx["meta"].get("innerInstructions") or []:
|
|
yield from group["instructions"]
|
|
|
|
|
|
def _trade(entry: dict) -> dict:
|
|
"""The swap instruction, its TradeEvent, and the signer's balance changes."""
|
|
tx = entry["transaction"]
|
|
keys = _keys(tx)
|
|
swap, event = None, None
|
|
for ix in _instructions(tx):
|
|
if keys[ix["programIdIndex"]] != str(LAUNCHLAB_PROGRAM):
|
|
continue
|
|
data = base58.b58decode(ix["data"])
|
|
if data[:8] == EVENT_CPI_TAG and data[8:16] == TRADE_EVENT:
|
|
body = data[16:]
|
|
values = struct.unpack_from("<13Q", body, 32)
|
|
event = dict(
|
|
zip(
|
|
(
|
|
"total_base_sell",
|
|
"virtual_base",
|
|
"virtual_quote",
|
|
"real_base_before",
|
|
"real_quote_before",
|
|
"real_base_after",
|
|
"real_quote_after",
|
|
"amount_in",
|
|
"amount_out",
|
|
"protocol_fee",
|
|
"platform_fee",
|
|
"creator_fee",
|
|
"share_fee",
|
|
),
|
|
values,
|
|
strict=True,
|
|
)
|
|
)
|
|
elif swap is None and len(ix["accounts"]) == len(_SWAP_ACCOUNTS):
|
|
swap = {"data": data, "accounts": [keys[i] for i in ix["accounts"]]}
|
|
|
|
signer = tx["transaction"]["message"]["accountKeys"][0]
|
|
|
|
def balance(section: str, mint: str) -> int:
|
|
return sum(
|
|
int(b["uiTokenAmount"]["amount"])
|
|
for b in tx["meta"][section]
|
|
if b.get("owner") == signer and b["mint"] == mint
|
|
)
|
|
|
|
base_delta = balance("postTokenBalances", entry["base_mint"]) - balance(
|
|
"preTokenBalances", entry["base_mint"]
|
|
)
|
|
return {"swap": swap, "event": event, "base_delta": base_delta}
|
|
|
|
|
|
def _pool_before(entry: dict, event: dict, base_decimals: int = 6) -> dict:
|
|
quote_decimals = (
|
|
next(
|
|
b["uiTokenAmount"]["decimals"]
|
|
for b in entry["transaction"]["meta"]["postTokenBalances"]
|
|
if b["mint"] == entry["quote_mint"]
|
|
)
|
|
if entry["quote_mint"] != str(WSOL_MINT)
|
|
else 9
|
|
)
|
|
return {
|
|
"virtual_base": event["virtual_base"],
|
|
"virtual_quote": event["virtual_quote"],
|
|
"real_base": event["real_base_before"],
|
|
"real_quote": event["real_quote_before"],
|
|
"base_decimals": base_decimals,
|
|
"quote_decimals": quote_decimals,
|
|
"global_config": Pubkey.from_string(entry["global_config"]),
|
|
"platform_config": Pubkey.from_string(entry["platform_config"]),
|
|
"base_mint": Pubkey.from_string(entry["base_mint"]),
|
|
"status": 0,
|
|
}
|
|
|
|
|
|
async def _priced_pool(entry: dict, event: dict) -> dict:
|
|
"""Run the fixture pool through the curve manager's fee and price step."""
|
|
accounts = {
|
|
Pubkey.from_string(entry[name]): base64.b64decode(
|
|
entry["accounts_base64"][name]
|
|
)
|
|
for name in ("global_config", "platform_config", "base_mint")
|
|
}
|
|
manager = StonkFunCurveManager(_FixtureClient(accounts), PARSER)
|
|
return await manager._with_fees(_pool_before(entry, event), None)
|
|
|
|
|
|
def check_a_math_reproduces_trades() -> bool:
|
|
"""A: fee rate and transfer fee read from accounts; amounts exact to the unit."""
|
|
ok = True
|
|
for entry in json.loads(FIXTURE.read_text()):
|
|
trade = _trade(entry)
|
|
event = trade["event"]
|
|
pool = asyncio.run(_priced_pool(entry, event))
|
|
tax = (pool["transfer_fee_bps"], pool["transfer_fee_max"])
|
|
is_buy = trade["swap"]["data"][:8] == BUY_EXACT_IN
|
|
if is_buy:
|
|
predicted = buy_amount_out(pool, event["amount_in"], pool["fee_rate"], tax)
|
|
good = (
|
|
predicted
|
|
== trade["base_delta"]
|
|
== event["amount_out"] - withheld_fee(event["amount_out"], *tax)
|
|
)
|
|
detail = f"predicted {predicted}, delivered {trade['base_delta']}"
|
|
else:
|
|
sent = -trade["base_delta"]
|
|
predicted = sell_amount_out(pool, sent, pool["fee_rate"], tax)
|
|
good = predicted == event["amount_out"] and event[
|
|
"amount_in"
|
|
] == sent - withheld_fee(sent, *tax)
|
|
detail = f"predicted {predicted}, paid {event['amount_out']}"
|
|
print(
|
|
f" {entry['label']}: fee {pool['fee_rate']} ppm, tax "
|
|
f"{pool['transfer_fee_bps']} bps, {detail}"
|
|
)
|
|
ok &= good
|
|
return ok
|
|
|
|
|
|
def check_b_price_uses_real_reserves() -> bool:
|
|
"""B: price from virtual + real reserves, not the launch price."""
|
|
ok = True
|
|
for entry in json.loads(FIXTURE.read_text()):
|
|
event = _trade(entry)["event"]
|
|
pool = asyncio.run(_priced_pool(entry, event))
|
|
scale = 10 ** pool["base_decimals"] / 10 ** pool["quote_decimals"]
|
|
expected = (
|
|
(event["virtual_quote"] + event["real_quote_before"])
|
|
/ (event["virtual_base"] - event["real_base_before"])
|
|
* scale
|
|
)
|
|
launch = event["virtual_quote"] / event["virtual_base"] * scale
|
|
good = (
|
|
abs(pool["price_per_token"] - expected) <= expected * 1e-12
|
|
and abs(pool["price_per_token"] - launch) > launch * 1e-6
|
|
)
|
|
if not good:
|
|
print(
|
|
f" {entry['label']}: price {pool['price_per_token']} vs {expected}"
|
|
)
|
|
ok &= good
|
|
return ok
|
|
|
|
|
|
def check_c_builder_matches_chain() -> bool:
|
|
"""C: the builder's 18 accounts equal the on-chain trade's, flags per the IDL."""
|
|
ok = True
|
|
builder = StonkFunInstructionBuilder(PARSER)
|
|
for entry in json.loads(FIXTURE.read_text()):
|
|
swap = _trade(entry)["swap"]
|
|
chain = swap["accounts"]
|
|
user = Pubkey.from_string(chain[0])
|
|
token_info = TokenInfo(
|
|
name="",
|
|
symbol="",
|
|
uri="",
|
|
mint=Pubkey.from_string(entry["base_mint"]),
|
|
platform=Platform.STONK_FUN,
|
|
pool_state=Pubkey.from_string(entry["pool"]),
|
|
global_config=Pubkey.from_string(entry["global_config"]),
|
|
platform_config=Pubkey.from_string(entry["platform_config"]),
|
|
creator=Pubkey.from_string(entry["creator"]),
|
|
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
|
|
quote_mint=Pubkey.from_string(entry["quote_mint"]),
|
|
quote_token_program_id=Pubkey.from_string(chain[12]),
|
|
)
|
|
is_buy = swap["data"][:8] == BUY_EXACT_IN
|
|
build = (
|
|
builder.build_buy_instruction if is_buy else builder.build_sell_instruction
|
|
)
|
|
instructions = asyncio.run(build(token_info, user, 1, 1, PROVIDER))
|
|
ours = next(ix for ix in instructions if ix.program_id == LAUNCHLAB_PROGRAM)
|
|
built = [str(meta.pubkey) for meta in ours.accounts]
|
|
# user_quote_token is the trader's own choice of account: a throwaway
|
|
# WSOL account for SOL, usually the ATA otherwise.
|
|
mismatched = [
|
|
name
|
|
for i, (name, _) in enumerate(_SWAP_ACCOUNTS)
|
|
if built[i] != chain[i] and name != "user_quote_token"
|
|
]
|
|
if mismatched:
|
|
print(f" {entry['label']}: differs at {mismatched}")
|
|
ok &= not mismatched
|
|
|
|
idl_swap = next(i for i in IDL["instructions"] if i["name"] == "buy_exact_in")
|
|
idl_writable = {a["name"]: bool(a.get("writable")) for a in idl_swap["accounts"]}
|
|
flag_mismatch = [
|
|
name
|
|
for name, writable in _SWAP_ACCOUNTS
|
|
if name in idl_writable and name != "payer" and writable != idl_writable[name]
|
|
]
|
|
if flag_mismatch:
|
|
print(f" writable flags differ from the IDL at {flag_mismatch}")
|
|
return ok and not flag_mismatch
|
|
|
|
|
|
class _StubClient:
|
|
async def build_and_send_transaction(
|
|
self, *_args: object, **_kwargs: object
|
|
) -> str:
|
|
return "STUB_SIGNATURE"
|
|
|
|
async def confirm_transaction(self, *_args: object, **_kwargs: object) -> bool:
|
|
return False
|
|
|
|
async def confirm_transaction_detailed(self, *_args: object, **_kwargs: object):
|
|
return platform_aware.ConfirmationStatus.REVERTED
|
|
|
|
|
|
def _stub_implementations(pool_state: dict, captured: dict, *, exact_in: bool):
|
|
async def build(_token_info, _user, amount_in, minimum_out, _provider):
|
|
captured["amount_in"], captured["minimum_out"] = amount_in, minimum_out
|
|
return ["stub-instruction"]
|
|
|
|
class Curve:
|
|
async def get_pool_state(self, _pool, commitment=None): # noqa: ARG002
|
|
return pool_state
|
|
|
|
return SimpleNamespace(
|
|
address_provider=PROVIDER,
|
|
instruction_builder=SimpleNamespace(
|
|
spends_exact_amount_in=exact_in,
|
|
build_buy_instruction=build,
|
|
build_sell_instruction=build,
|
|
get_required_accounts_for_buy=lambda *_a, **_k: [],
|
|
get_required_accounts_for_sell=lambda *_a, **_k: [],
|
|
get_buy_compute_unit_limit=lambda _o: 150_000,
|
|
get_sell_compute_unit_limit=lambda _o: 150_000,
|
|
),
|
|
curve_manager=Curve(),
|
|
)
|
|
|
|
|
|
async def _no_fee(_accounts: list) -> None:
|
|
return None
|
|
|
|
|
|
POOL_STATE = {
|
|
"price_per_token": 0.00000003,
|
|
"fee_fraction": 0.0125,
|
|
"transfer_fee_bps": 100,
|
|
"quote_mint": WSOL_MINT,
|
|
"creator": str(TRADER),
|
|
}
|
|
TOKEN = TokenInfo(
|
|
name="T",
|
|
symbol="T",
|
|
uri="",
|
|
mint=TRADER,
|
|
platform=Platform.STONK_FUN,
|
|
pool_state=TRADER,
|
|
quote_mint=WSOL_MINT,
|
|
creator=TRADER,
|
|
)
|
|
|
|
|
|
def _buy(*, exact_in: bool) -> dict:
|
|
captured: dict = {}
|
|
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
|
|
POOL_STATE, captured, exact_in=exact_in
|
|
)
|
|
buyer = PlatformAwareBuyer(
|
|
_StubClient(),
|
|
SimpleNamespace(pubkey=TRADER, keypair=None),
|
|
SimpleNamespace(calculate_priority_fee=_no_fee),
|
|
amount=0.01,
|
|
slippage=0.2,
|
|
max_retries=1,
|
|
)
|
|
asyncio.run(buyer.execute(TOKEN))
|
|
return captured
|
|
|
|
|
|
def check_d_buyer_sizing() -> bool:
|
|
"""D: exact-in gets the unpadded amount; the token floor is net of fee and tax."""
|
|
exact = _buy(exact_in=True)
|
|
padded = _buy(exact_in=False)
|
|
expected_tokens = 0.01 / POOL_STATE["price_per_token"] * (1 - 0.0125) * (1 - 0.01)
|
|
expected_floor = int(expected_tokens * (1 - 0.2) * 10**6)
|
|
ok = (
|
|
exact["amount_in"] == 10_000_000
|
|
and padded["amount_in"] == int(0.01 * 10**9 * 1.2)
|
|
and abs(exact["minimum_out"] - expected_floor) <= 1
|
|
)
|
|
if not ok:
|
|
print(f" exact={exact} padded={padded} expected floor {expected_floor}")
|
|
return ok
|
|
|
|
|
|
def check_e_seller_floor() -> bool:
|
|
"""E: the sell floor is net of transfer fee and curve fee."""
|
|
captured: dict = {}
|
|
platform_aware.get_platform_implementations = lambda _p, _c: _stub_implementations(
|
|
POOL_STATE, captured, exact_in=True
|
|
)
|
|
seller = PlatformAwareSeller(
|
|
_StubClient(),
|
|
SimpleNamespace(pubkey=TRADER, keypair=None),
|
|
SimpleNamespace(calculate_priority_fee=_no_fee),
|
|
slippage=0.2,
|
|
max_retries=1,
|
|
)
|
|
tokens, price = 1_000_000.0, 0.00000003
|
|
asyncio.run(seller.execute(TOKEN, tokens, price))
|
|
expected = int(tokens * (1 - 0.01) * price * (1 - 0.0125) * (1 - 0.2) * 10**9)
|
|
ok = abs(captured.get("minimum_out", -10) - expected) <= 1
|
|
if not ok:
|
|
print(f" floor {captured.get('minimum_out')} expected {expected}")
|
|
return ok
|
|
|
|
|
|
def check_f_single_token_mode_waits_past_refused_coins() -> bool:
|
|
"""F: the first launch is an xStock coin, the second taxed 3%, the third buyable."""
|
|
spcxx = Pubkey.from_string("Xs3oZwbHvqis4NYcf4YKWmEia2eC84wSiVrcYcTqpH8")
|
|
launches = [
|
|
TokenInfo(
|
|
name="",
|
|
symbol="XSTOCK",
|
|
uri="",
|
|
mint=Pubkey.new_unique(),
|
|
platform=Platform.STONK_FUN,
|
|
quote_mint=spcxx,
|
|
),
|
|
TokenInfo(
|
|
name="",
|
|
symbol="TAXED",
|
|
uri="",
|
|
mint=Pubkey.new_unique(),
|
|
platform=Platform.STONK_FUN,
|
|
quote_mint=WSOL_MINT,
|
|
transfer_fee_bps=300,
|
|
),
|
|
TokenInfo(
|
|
name="",
|
|
symbol="BUYABLE",
|
|
uri="",
|
|
mint=Pubkey.new_unique(),
|
|
platform=Platform.STONK_FUN,
|
|
quote_mint=WSOL_MINT,
|
|
transfer_fee_bps=100,
|
|
),
|
|
]
|
|
|
|
class Listener:
|
|
async def listen_for_tokens(self, callback, *_args):
|
|
# Launches arrive apart, so the waiter can wake on the first one
|
|
# before the next overwrites it.
|
|
for launch in launches:
|
|
await callback(launch)
|
|
await asyncio.sleep(0.05)
|
|
await asyncio.sleep(3600)
|
|
|
|
trader = SimpleNamespace(
|
|
token_listener=Listener(),
|
|
match_string=None,
|
|
bro_address=None,
|
|
token_wait_timeout=5,
|
|
processed_tokens=set(),
|
|
token_timestamps={},
|
|
allowed_quote_mints=None,
|
|
quote_amounts={WSOL_MINT: 0.001},
|
|
max_transfer_fee_bps=100,
|
|
)
|
|
trader._skip_reason = lambda token: UniversalTrader._skip_reason(trader, token)
|
|
found = asyncio.run(UniversalTrader._wait_for_token(trader))
|
|
ok = found is not None and found.symbol == "BUYABLE"
|
|
if not ok:
|
|
print(f" picked {found and found.symbol}")
|
|
return ok
|
|
|
|
|
|
def main() -> int:
|
|
"""Run every check and report."""
|
|
checks = [
|
|
("A: curve math reproduces real trades", check_a_math_reproduces_trades),
|
|
("B: price from virtual + real reserves", check_b_price_uses_real_reserves),
|
|
("C: builder accounts match the chain", check_c_builder_matches_chain),
|
|
("D: buyer sizing for exact-in", check_d_buyer_sizing),
|
|
("E: seller floor net of fees", check_e_seller_floor),
|
|
(
|
|
"F: single-token mode waits past refused coins",
|
|
check_f_single_token_mode_waits_past_refused_coins,
|
|
),
|
|
]
|
|
passed = 0
|
|
for label, check in checks:
|
|
ok = check()
|
|
print(f"{'PASS' if ok else 'FAIL'} {label}")
|
|
passed += ok
|
|
print(f"\n{passed}/{len(checks)} checks passed")
|
|
return 0 if passed == len(checks) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|