mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* chore(deps): upgrade solders to 0.29 and solana to 0.40 in cookbook and tools solana 0.40 moves TxOpts (now TxOptsModel), MemcmpOpts and TokenAccountOpts from solana.rpc.types to solana.rpc.core, and spl.token's *Params dataclasses to spl.token.models as pydantic models — validated and keyword-only. Three call sites only failed against mainnet: MemcmpOpts.bytes now takes a base58 str, SyncNativeParams was built positionally, and confirm_transaction was passed a bare "confirmed". Also moves every cookbook and tools send path off the legacy Transaction onto VersionedTransaction + MessageV0, and teaches the getTransaction decoder the json and base64 encodings alongside jsonParsed — the base64 v1 fixture needs solders 0.29 to deserialize at all. src/ is untouched and still on the old imports; the offline suite is 3/19 until that lands. Verified on mainnet: a 0.003 SOL buy_v2/sell_v2 round trip confirmed and the wallet returned to SOL only, all six listeners and every read-only script run live, five trade paths simulated with error=None. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): move the bot onto solders 0.29 and solana 0.40 Same three API moves as the cookbook commit, now in src: TxOpts is TxOptsModel in solana.rpc.core, and spl.token's BurnParams/CloseAccountParams come from spl.token.models. build_and_send_transaction builds a VersionedTransaction from MessageV0 with no lookup tables, so the bot no longer sends a legacy transaction anywhere. verify_transaction_v1 proved the log route by stripping the logs and expecting nothing back, which only held while solders could not read a v1 envelope. On 0.29 it can, so the check is inverted: make the envelope unreadable, keep the logs, and the create must still arrive. A second check pins the envelope fallback, so a decode regression is reported rather than silently absorbed by the log route. Offline: 19/19. Live on mainnet, funds returned: - tools/simulate_bot_buy_path: zero-RPC buy path, err=None, 107,015 CU - tools/simulate_v2_trades: buy_v2 95,573 CU, buy+sell 174,190 — both inside the existing 180k/120k limits, so no retune - tools/live_v2_round_trip: buy_v2 + sell_v2 both landed - tools/live_listener_matrix: all four listeners detected; logs and pumpportal ran full round trips; geyser and blocks both drew a coin on an unconfigured quote mint and correctly refused to size a buy. Every ATA closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bring the rest of the dependencies to current Every remaining dependency is now at its latest release, with the floors raised to the versions actually tested rather than the oldest that resolves: grpcio 1.73.1 -> 1.84.0 grpcio-tools 1.73.1 -> 1.84.0 protobuf 6.33.5 -> 7.36.2 uvloop 0.21.0 -> 0.22.1 pyyaml 6.0.2 -> 6.0.3 python-dotenv 1.2.2 -> 1.2.3 construct 2.10.70 (floor) websockets 17.1 (floor) ruff 0.12.4 -> 0.16.8 aiohttp and base58 were already current. winloop is left at its old floor: it is Windows-only and cannot be installed, let alone tested, here. protobuf 7 arrives with grpcio-tools 1.84 and is the only major. The committed geyser stubs are gencode 6.31.1 and still pass ValidateProtobufRuntimeVersion against it; they are not regenerated, because the committed files import each other absolutely and protoc does not emit that, so the output would need rewriting. Noted in CLAUDE.md with the verification date. ruff 0.16 needs no config change. The repo-wide baseline moves 1680 -> 1713 because the rules moved, not the code; CLAUDE.md carries the new figure so it is not misread as a regression. Verified per dependency, not just in aggregate: all four bot configs load under the new pyyaml/dotenv; the logs, blocks and pumpportal listeners run under the uvloop policy bot_runner installs; both geyser paths (token listener and the account-filtered graduation watcher) run under grpcio 1.84 / protobuf 7. Offline 19/19, including verify_listener_cancellation for websockets. One more live buy_v2/sell_v2 round trip on the final set, both landed, ATA closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(utils): redact RPC credentials from every log record The provider endpoints carry their API key in the URL and the HTTP clients log a full request URL at INFO, so anything that raises the root logger to INFO prints the key. tools/cleanup_accounts.py did, four times, in a terminal. It was already silencing httpx and httpcore by name. That stopped working the moment solana-py 0.40 switched to httpx2: the rename took the guard with it and nothing said so. Naming the clients is now a convenience on top of the real control — install_secret_redaction masks the value itself, at log-record creation, whichever logger emits it. It redacts the *rendered* message rather than record.msg plus the string arguments. The first version of this did the latter and still leaked: httpx2 passes its URL as a URL object, so the type check skipped the only argument carrying the key. Confirmed by forcing httpx2 back to INFO and watching the key print anyway. The host is kept, so a log line still says which provider answered. Installed from get_logger, from both logging.basicConfig sites, and from setup_file_logging before it attaches the handler — logs/ outlives a terminal. verify_no_rpc_credentials_logged covers all of it, including the non-str argument. Checked against the defeated version first: it fails there and passes here. Suite is 20/20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): make a real bot run the verification, not the simulations The ground rule said never to run a bot with real funds, which points at the tools/ harnesses instead. Those call the buyer, seller and listeners directly: they skip bot_runner, config loading, the trader loop, the exit strategy and cleanup, so passing there says nothing about the paths only a full run reaches. Verification is a real run, with explicit approval for that session, covering every mode the change touches and ending with the wallet holding SOL only. Also records why a run can look clean when nothing happened: pump_bot runs every bots/*.yaml, three of the four committed configs ship enabled: false, and with all of them disabled it prints nothing and exits 0. The log file is the tell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(utils): keep the redaction comment provider-neutral The parameter list is what matters, not which provider uses which spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
783 lines
27 KiB
Python
783 lines
27 KiB
Python
"""Manual Buy Exact Out Example for Raydium LaunchLab
|
|
|
|
Usage:
|
|
uv run cookbook/letsbonk/letsbonk_buy_token_exact_out.py <MINT> [TOKENS_TO_RECEIVE] [--slippage 0.25]
|
|
|
|
This script demonstrates how to buy tokens using the buy_exact_out instruction
|
|
from the Raydium LaunchLab program. It follows the IDL structure.
|
|
|
|
Key features:
|
|
- Uses buy_exact_out instruction
|
|
- Implements proper account ordering as per IDL
|
|
- Includes slippage protection with maximum_amount_in
|
|
- Handles WSOL wrapping/unwrapping automatically
|
|
- Follows the exact transaction structure from the buy_exact_in example
|
|
- User configurable token amount and slippage
|
|
- Uses idempotent ATA creation
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import struct
|
|
import sys
|
|
from urllib.parse import urlsplit
|
|
|
|
import base58
|
|
from dotenv import load_dotenv
|
|
from letsbonk_idl_parser import load_idl_parser
|
|
from solana.rpc.async_api import AsyncClient
|
|
from solana.rpc.commitment import Confirmed
|
|
from solana.rpc.core import 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
|
|
|
|
sys.path.append(
|
|
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "solana")
|
|
)
|
|
|
|
import solana_transaction_status as tx_status
|
|
|
|
# Initialize IDL parser for Raydium LaunchLab with verbose mode for debugging
|
|
IDL_PARSER = load_idl_parser("idl/raydium_launchlab_idl.json", verbose=True)
|
|
|
|
load_dotenv()
|
|
|
|
# Configuration constants
|
|
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
|
|
PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
|
|
PAYER = Keypair.from_bytes(PRIVATE_KEY)
|
|
|
|
# Defaults for the command line below, not fixed settings.
|
|
DEFAULT_AMOUNT = 1_000_000
|
|
DEFAULT_SLIPPAGE = 0.25
|
|
|
|
# Transaction parameters
|
|
SHARE_FEE_RATE = 0
|
|
|
|
# Program IDs and addresses from Raydium LaunchLab
|
|
RAYDIUM_LAUNCHLAB_PROGRAM_ID = Pubkey.from_string(
|
|
"LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj"
|
|
)
|
|
GLOBAL_CONFIG = Pubkey.from_string("6s1xP3hpbAfFoNtUNF8mfHsjr2Bd97JxFJRWLbL6aHuX")
|
|
# Fallback only. platform_config is NOT the same for every LaunchLab pool:
|
|
# partner launches use their own, and passing the wrong one fails the buy/sell
|
|
# with ConstraintAddress (2012). The live value is read from the pool state
|
|
# below; this constant is only used if the pool omits it.
|
|
LETSBONK_PLATFORM_CONFIG = Pubkey.from_string(
|
|
"5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA"
|
|
)
|
|
|
|
# Token program and system addresses
|
|
TOKEN_PROGRAM_ID = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
|
|
SYSTEM_PROGRAM_ID = Pubkey.from_string("11111111111111111111111111111111")
|
|
WSOL_MINT = Pubkey.from_string("So11111111111111111111111111111111111111112")
|
|
COMPUTE_BUDGET_PROGRAM_ID = Pubkey.from_string(
|
|
"ComputeBudget111111111111111111111111111111"
|
|
)
|
|
ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string(
|
|
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
|
|
)
|
|
SYSTEM_RENT_PROGRAM_ID = Pubkey.from_string(
|
|
"SysvarRent111111111111111111111111111111111"
|
|
)
|
|
|
|
# Instruction discriminator for buy_exact_out (from IDL)
|
|
BUY_EXACT_OUT_DISCRIMINATOR = bytes([24, 211, 116, 40, 105, 3, 153, 56])
|
|
|
|
# Compute budget settings
|
|
COMPUTE_UNIT_LIMIT = 150_000
|
|
COMPUTE_UNIT_PRICE = 1_000
|
|
|
|
LAMPORTS_PER_SOL = 1_000_000_000
|
|
|
|
|
|
def derive_authority_pda() -> Pubkey:
|
|
"""
|
|
Derive the authority PDA for the Raydium LaunchLab program.
|
|
|
|
This PDA acts as the authority for pool vault operations and is generated
|
|
using the AUTH_SEED as specified in the IDL.
|
|
|
|
Returns:
|
|
Pubkey: The derived authority PDA
|
|
"""
|
|
AUTH_SEED = b"vault_auth_seed"
|
|
authority_pda, _ = Pubkey.find_program_address(
|
|
[AUTH_SEED], RAYDIUM_LAUNCHLAB_PROGRAM_ID
|
|
)
|
|
return authority_pda
|
|
|
|
|
|
def derive_event_authority_pda() -> Pubkey:
|
|
"""
|
|
Derive the event authority PDA for the Raydium LaunchLab program.
|
|
|
|
This PDA is used for emitting program events during swaps.
|
|
|
|
Returns:
|
|
Pubkey: The derived event authority PDA
|
|
"""
|
|
EVENT_AUTHORITY_SEED = b"__event_authority"
|
|
event_authority_pda, _ = Pubkey.find_program_address(
|
|
[EVENT_AUTHORITY_SEED], RAYDIUM_LAUNCHLAB_PROGRAM_ID
|
|
)
|
|
return event_authority_pda
|
|
|
|
|
|
def derive_pool_state_for_token(base_token_mint: Pubkey) -> Pubkey | None:
|
|
"""
|
|
Derive the pool state account for a given base token mint.
|
|
|
|
Args:
|
|
base_token_mint: The token mint address to search for
|
|
|
|
Returns:
|
|
Pubkey of the pool state account, or None if not found
|
|
"""
|
|
seeds = [b"pool", bytes(base_token_mint), bytes(WSOL_MINT)]
|
|
pool_state_pda, _ = Pubkey.find_program_address(seeds, RAYDIUM_LAUNCHLAB_PROGRAM_ID)
|
|
return pool_state_pda
|
|
|
|
|
|
def derive_creator_fee_vault(creator: Pubkey, quote_mint: Pubkey) -> Pubkey:
|
|
"""
|
|
Derive the creator fee vault PDA.
|
|
|
|
This vault accumulates creator fees from trades.
|
|
|
|
Args:
|
|
creator: The pool creator's pubkey
|
|
quote_mint: The quote token mint (WSOL)
|
|
|
|
Returns:
|
|
Pubkey of the creator fee vault
|
|
"""
|
|
seeds = [bytes(creator), bytes(quote_mint)]
|
|
creator_fee_vault_pda, _ = Pubkey.find_program_address(
|
|
seeds, RAYDIUM_LAUNCHLAB_PROGRAM_ID
|
|
)
|
|
return creator_fee_vault_pda
|
|
|
|
|
|
def derive_platform_fee_vault(platform_config: Pubkey, quote_mint: Pubkey) -> Pubkey:
|
|
"""
|
|
Derive the platform fee vault PDA.
|
|
|
|
This vault accumulates platform fees from trades.
|
|
|
|
Args:
|
|
platform_config: The platform config account
|
|
quote_mint: The quote token mint (WSOL)
|
|
|
|
Returns:
|
|
Pubkey of the platform fee vault
|
|
"""
|
|
seeds = [bytes(platform_config), bytes(quote_mint)]
|
|
platform_fee_vault_pda, _ = Pubkey.find_program_address(
|
|
seeds, RAYDIUM_LAUNCHLAB_PROGRAM_ID
|
|
)
|
|
return platform_fee_vault_pda
|
|
|
|
|
|
def decode_pool_state(account_data: bytes) -> dict | None:
|
|
"""
|
|
Decode pool state account data using the IDL parser.
|
|
|
|
Args:
|
|
account_data: Raw account data from the pool state account
|
|
|
|
Returns:
|
|
Dictionary containing decoded pool state data, or None if decoding fails
|
|
"""
|
|
try:
|
|
result = IDL_PARSER.decode_account_data(
|
|
account_data, "PoolState", skip_discriminator=True
|
|
)
|
|
if result:
|
|
return result
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Error decoding pool state: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
|
|
async def get_pool_state_data(client: AsyncClient, pool_state: Pubkey) -> dict | None:
|
|
"""
|
|
Get and decode the pool state account data.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
pool_state: The pool state account address
|
|
|
|
Returns:
|
|
Dictionary containing decoded pool state data, or None if error
|
|
"""
|
|
try:
|
|
account_info = await client.get_account_info(pool_state)
|
|
if not account_info.value:
|
|
print("Pool state account not found")
|
|
return None
|
|
|
|
return decode_pool_state(account_info.value.data)
|
|
|
|
except Exception as e:
|
|
print(f"Error getting pool state data: {e}")
|
|
return None
|
|
|
|
|
|
def get_associated_token_address(owner: Pubkey, mint: Pubkey) -> Pubkey:
|
|
"""
|
|
Calculate the associated token account address for a given owner and mint.
|
|
|
|
This manually implements the ATA derivation without requiring the spl-token package.
|
|
|
|
Args:
|
|
owner: The wallet that owns the token account
|
|
mint: The token mint address
|
|
|
|
Returns:
|
|
Pubkey of the associated token account
|
|
"""
|
|
ata_address, _ = Pubkey.find_program_address(
|
|
[bytes(owner), bytes(TOKEN_PROGRAM_ID), bytes(mint)],
|
|
ASSOCIATED_TOKEN_PROGRAM_ID,
|
|
)
|
|
return ata_address
|
|
|
|
|
|
def create_associated_token_account_idempotent_instruction(
|
|
payer: Pubkey, owner: Pubkey, mint: Pubkey
|
|
) -> Instruction:
|
|
"""
|
|
Create an idempotent instruction to create an Associated Token Account.
|
|
|
|
This uses the CreateIdempotent instruction which doesn't fail if the ATA already exists.
|
|
|
|
Args:
|
|
payer: The account that will pay for the creation
|
|
owner: The owner of the new token account
|
|
mint: The token mint
|
|
|
|
Returns:
|
|
Instruction for creating the ATA idempotently
|
|
"""
|
|
ata_address = get_associated_token_address(owner, mint)
|
|
|
|
accounts = [
|
|
AccountMeta(pubkey=payer, is_signer=True, is_writable=True), # Funding account
|
|
AccountMeta(
|
|
pubkey=ata_address, is_signer=False, is_writable=True
|
|
), # Associated token account
|
|
AccountMeta(pubkey=owner, is_signer=False, is_writable=False), # Wallet address
|
|
AccountMeta(pubkey=mint, is_signer=False, is_writable=False), # Token mint
|
|
AccountMeta(
|
|
pubkey=SYSTEM_PROGRAM_ID, is_signer=False, is_writable=False
|
|
), # System program
|
|
AccountMeta(
|
|
pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False
|
|
), # Token program
|
|
]
|
|
|
|
data = bytes([1])
|
|
|
|
return Instruction(
|
|
program_id=ASSOCIATED_TOKEN_PROGRAM_ID, data=data, accounts=accounts
|
|
)
|
|
|
|
|
|
def create_initialize_account_instruction(
|
|
account: Pubkey, mint: Pubkey, owner: Pubkey
|
|
) -> Instruction:
|
|
"""
|
|
Create an InitializeAccount instruction for the Token Program.
|
|
|
|
Args:
|
|
account: The account to initialize
|
|
mint: The token mint
|
|
owner: The account owner
|
|
|
|
Returns:
|
|
Instruction for initializing the account
|
|
"""
|
|
accounts = [
|
|
AccountMeta(pubkey=account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=mint, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=owner, is_signer=False, is_writable=False),
|
|
AccountMeta(pubkey=SYSTEM_RENT_PROGRAM_ID, is_signer=False, is_writable=False),
|
|
]
|
|
|
|
# InitializeAccount instruction discriminator (instruction 1 in Token Program)
|
|
data = bytes([1])
|
|
|
|
return Instruction(program_id=TOKEN_PROGRAM_ID, data=data, accounts=accounts)
|
|
|
|
|
|
def create_close_account_instruction(
|
|
account: Pubkey, destination: Pubkey, owner: Pubkey
|
|
) -> Instruction:
|
|
"""
|
|
Create a CloseAccount instruction for the Token Program.
|
|
|
|
Args:
|
|
account: The account to close
|
|
destination: Where to send the remaining lamports
|
|
owner: The account owner (must sign)
|
|
|
|
Returns:
|
|
Instruction for closing the account
|
|
"""
|
|
accounts = [
|
|
AccountMeta(pubkey=account, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=destination, is_signer=False, is_writable=True),
|
|
AccountMeta(pubkey=owner, is_signer=True, is_writable=False),
|
|
]
|
|
|
|
data = bytes([9])
|
|
|
|
return Instruction(program_id=TOKEN_PROGRAM_ID, data=data, accounts=accounts)
|
|
|
|
|
|
def create_wsol_account_with_seed(
|
|
payer: Pubkey, seed: str, lamports: int
|
|
) -> tuple[Pubkey, Instruction, Instruction]:
|
|
"""
|
|
Create a WSOL account using createAccountWithSeed and initialize it.
|
|
|
|
This replicates the exact pattern from the Solscan example where a new account
|
|
is created with a seed and then initialized as a token account.
|
|
|
|
Args:
|
|
payer: The account that will pay for and own the new account
|
|
seed: String seed for deterministic account generation
|
|
lamports: Amount of lamports to transfer to the new account
|
|
|
|
Returns:
|
|
Tuple of (new_account_pubkey, create_instruction, initialize_instruction)
|
|
"""
|
|
new_account = Pubkey.create_with_seed(payer, seed, TOKEN_PROGRAM_ID)
|
|
|
|
create_ix = create_account_with_seed(
|
|
CreateAccountWithSeedParams(
|
|
from_pubkey=payer,
|
|
to_pubkey=new_account,
|
|
base=payer,
|
|
seed=seed,
|
|
lamports=lamports,
|
|
space=165, # Size of a token account
|
|
owner=TOKEN_PROGRAM_ID,
|
|
)
|
|
)
|
|
|
|
initialize_ix = create_initialize_account_instruction(new_account, WSOL_MINT, payer)
|
|
|
|
return new_account, create_ix, initialize_ix
|
|
|
|
|
|
def get_user_base_token_account(payer: Pubkey, base_mint: Pubkey) -> Pubkey:
|
|
"""
|
|
Get the user's associated token account for the base token.
|
|
|
|
In a real implementation, this should check if the account exists and create it if needed.
|
|
For this example, we'll derive the standard ATA address.
|
|
|
|
Args:
|
|
payer: The user's wallet address
|
|
base_mint: The base token mint address
|
|
|
|
Returns:
|
|
Pubkey of the user's base token account
|
|
"""
|
|
return get_associated_token_address(payer, base_mint)
|
|
|
|
|
|
def calculate_maximum_amount_in_from_pool_state(
|
|
pool_state_data: dict, amount_out: int, slippage_tolerance: float
|
|
) -> int:
|
|
"""
|
|
Calculate the maximum amount in based on pool state data and slippage tolerance.
|
|
|
|
Uses the actual pool reserves to calculate expected input using constant product formula.
|
|
For buy_exact_out, we know the output amount and need to calculate the required input.
|
|
|
|
Args:
|
|
pool_state_data: Decoded pool state data containing reserves
|
|
amount_out: Amount of base tokens to receive (exact output)
|
|
slippage_tolerance: Slippage tolerance as a decimal (0.25 = 25%)
|
|
|
|
Returns:
|
|
Maximum amount of quote tokens to spend
|
|
"""
|
|
try:
|
|
# Extract pool reserves from decoded state
|
|
virtual_base = pool_state_data["virtual_base"]
|
|
virtual_quote = pool_state_data["virtual_quote"]
|
|
real_base = pool_state_data["real_base"]
|
|
real_quote = pool_state_data["real_quote"]
|
|
|
|
print("Pool State:")
|
|
print(f" Virtual Base: {virtual_base:,}")
|
|
print(f" Virtual Quote: {virtual_quote:,}")
|
|
print(f" Real Base: {real_base:,}")
|
|
print(f" Real Quote: {real_quote:,}")
|
|
|
|
# Use virtual reserves for bonding curve calculation
|
|
# For exact output, we need to solve: amount_out = (amount_in * virtual_base) / (virtual_quote + amount_in)
|
|
# Rearranging: amount_in = (amount_out * virtual_quote) / (virtual_base - amount_out)
|
|
|
|
if virtual_base <= amount_out:
|
|
raise ValueError(
|
|
f"Amount out ({amount_out}) cannot be >= virtual base reserves ({virtual_base})"
|
|
)
|
|
|
|
# Calculate required input using rearranged constant product formula
|
|
numerator = amount_out * virtual_quote
|
|
denominator = virtual_base - amount_out
|
|
expected_input = numerator // denominator
|
|
|
|
# Apply slippage tolerance (add buffer for price movement)
|
|
maximum_with_slippage = int(expected_input * (1 + slippage_tolerance))
|
|
|
|
print(f"Amount out: {amount_out:,} tokens")
|
|
print(
|
|
f"Expected input: {expected_input:,} lamports ({expected_input / LAMPORTS_PER_SOL:.6f} SOL)"
|
|
)
|
|
print(
|
|
f"Maximum with {slippage_tolerance * 100}% slippage: {maximum_with_slippage:,} lamports ({maximum_with_slippage / LAMPORTS_PER_SOL:.6f} SOL)"
|
|
)
|
|
|
|
return maximum_with_slippage
|
|
|
|
except Exception as e:
|
|
print(f"Error calculating maximum amount in from pool state: {e}")
|
|
return None
|
|
|
|
|
|
async def buy_exact_out(
|
|
client: AsyncClient,
|
|
base_token_mint: Pubkey,
|
|
amount_out: int,
|
|
slippage_tolerance: float,
|
|
) -> str | None:
|
|
"""
|
|
Execute a buy_exact_out transaction on Raydium LaunchLab.
|
|
|
|
This function implements the exact transaction flow similar to buy_exact_in:
|
|
1. SetComputeUnitPrice
|
|
2. SetComputeUnitLimit
|
|
3. Create Associated Token Account for base token (idempotent)
|
|
4. Create WSOL account with seed
|
|
5. Initialize WSOL account
|
|
6. Execute buy_exact_out instruction
|
|
7. Close WSOL account
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
base_token_mint: Address of the token to buy
|
|
amount_out: Exact amount of tokens to receive
|
|
slippage_tolerance: Slippage tolerance as decimal
|
|
|
|
Returns:
|
|
Transaction signature if successful, None otherwise
|
|
"""
|
|
try:
|
|
print(f"Finding pool state for token: {base_token_mint}")
|
|
pool_state = derive_pool_state_for_token(base_token_mint)
|
|
if not pool_state:
|
|
print("Pool state not found for this token")
|
|
return None
|
|
|
|
# Get and decode pool state data using IDL parser
|
|
pool_state_data = await get_pool_state_data(client, pool_state)
|
|
if not pool_state_data:
|
|
print("Failed to decode pool state data")
|
|
return None
|
|
|
|
# Extract vault addresses and creator from decoded pool state (convert from base58 strings to Pubkey objects)
|
|
base_vault = Pubkey.from_string(pool_state_data["base_vault"])
|
|
quote_vault = Pubkey.from_string(pool_state_data["quote_vault"])
|
|
creator = Pubkey.from_string(pool_state_data["creator"])
|
|
|
|
print(f"Found pool state: {pool_state}")
|
|
print(f"Base vault: {base_vault}")
|
|
print(f"Quote vault: {quote_vault}")
|
|
print(f"Creator: {creator}")
|
|
print(f"Pool status: {pool_state_data['status']}")
|
|
|
|
# Derive necessary PDAs
|
|
authority = derive_authority_pda()
|
|
event_authority = derive_event_authority_pda()
|
|
# platform_config varies per pool — take the pool's own value.
|
|
platform_config = (
|
|
Pubkey.from_string(pool_state_data["platform_config"])
|
|
if pool_state_data.get("platform_config")
|
|
else LETSBONK_PLATFORM_CONFIG
|
|
)
|
|
creator_fee_vault = derive_creator_fee_vault(creator, WSOL_MINT)
|
|
platform_fee_vault = derive_platform_fee_vault(
|
|
platform_config, WSOL_MINT
|
|
)
|
|
|
|
print(f"Creator fee vault: {creator_fee_vault}")
|
|
print(f"Platform fee vault: {platform_fee_vault}")
|
|
|
|
# Calculate amounts using pool state data
|
|
maximum_amount_in = calculate_maximum_amount_in_from_pool_state(
|
|
pool_state_data, amount_out, slippage_tolerance
|
|
)
|
|
|
|
if maximum_amount_in is None:
|
|
print("Failed to calculate maximum amount in")
|
|
return None
|
|
|
|
print(f"Amount out: {amount_out} tokens")
|
|
print(
|
|
f"Maximum amount in: {maximum_amount_in} lamports ({maximum_amount_in / LAMPORTS_PER_SOL:.6f} SOL)"
|
|
)
|
|
|
|
# Step 1: Create Associated Token Account for base token (idempotent)
|
|
user_base_token = get_associated_token_address(PAYER.pubkey(), base_token_mint)
|
|
create_ata_ix = create_associated_token_account_idempotent_instruction(
|
|
PAYER.pubkey(), PAYER.pubkey(), base_token_mint
|
|
)
|
|
|
|
# Step 2: Create WSOL account with seed
|
|
import hashlib
|
|
import time
|
|
|
|
# Generate a unique seed based on timestamp and user pubkey
|
|
seed_data = f"{int(time.time())}{PAYER.pubkey()!s}"
|
|
wsol_seed = hashlib.sha256(seed_data.encode()).hexdigest()[:32]
|
|
|
|
# Calculate required lamports (maximum_amount_in + small buffer for account creation)
|
|
account_creation_lamports = 2_039_280 # Standard account creation cost
|
|
total_lamports = maximum_amount_in + account_creation_lamports
|
|
|
|
user_quote_token, create_wsol_ix, init_wsol_ix = create_wsol_account_with_seed(
|
|
PAYER.pubkey(), wsol_seed, total_lamports
|
|
)
|
|
|
|
print(f"User base token account: {user_base_token}")
|
|
print(f"User quote token account: {user_quote_token}")
|
|
|
|
# Step 3: Build the buy_exact_out instruction
|
|
accounts = [
|
|
AccountMeta(
|
|
pubkey=PAYER.pubkey(), is_signer=True, is_writable=False
|
|
), # payer
|
|
AccountMeta(
|
|
pubkey=authority, is_signer=False, is_writable=False
|
|
), # authority
|
|
AccountMeta(
|
|
pubkey=GLOBAL_CONFIG, is_signer=False, is_writable=False
|
|
), # global_config
|
|
AccountMeta(
|
|
pubkey=platform_config, is_signer=False, is_writable=False
|
|
), # platform_config
|
|
AccountMeta(
|
|
pubkey=pool_state, is_signer=False, is_writable=True
|
|
), # pool_state
|
|
AccountMeta(
|
|
pubkey=user_base_token, is_signer=False, is_writable=True
|
|
), # user_base_token
|
|
AccountMeta(
|
|
pubkey=user_quote_token, is_signer=False, is_writable=True
|
|
), # user_quote_token
|
|
AccountMeta(
|
|
pubkey=base_vault, is_signer=False, is_writable=True
|
|
), # base_vault
|
|
AccountMeta(
|
|
pubkey=quote_vault, is_signer=False, is_writable=True
|
|
), # quote_vault
|
|
AccountMeta(
|
|
pubkey=base_token_mint, is_signer=False, is_writable=False
|
|
), # base_token_mint
|
|
AccountMeta(
|
|
pubkey=WSOL_MINT, is_signer=False, is_writable=False
|
|
), # quote_token_mint
|
|
AccountMeta(
|
|
pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False
|
|
), # base_token_program
|
|
AccountMeta(
|
|
pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False
|
|
), # quote_token_program
|
|
AccountMeta(
|
|
pubkey=event_authority, is_signer=False, is_writable=False
|
|
), # event_authority
|
|
AccountMeta(
|
|
pubkey=RAYDIUM_LAUNCHLAB_PROGRAM_ID, is_signer=False, is_writable=False
|
|
), # program
|
|
]
|
|
|
|
# Add remaining accounts (not explicitly listed in IDL but required by the program)
|
|
# These accounts are used for fee collection during swaps
|
|
accounts.append(
|
|
AccountMeta(pubkey=SYSTEM_PROGRAM_ID, is_signer=False, is_writable=False)
|
|
) # #16: System Program
|
|
accounts.append(
|
|
AccountMeta(pubkey=platform_fee_vault, is_signer=False, is_writable=True)
|
|
) # #17: Platform fee vault
|
|
accounts.append(
|
|
AccountMeta(pubkey=creator_fee_vault, is_signer=False, is_writable=True)
|
|
) # #18: Creator fee vault
|
|
|
|
# Instruction data: discriminator + amount_out + maximum_amount_in + share_fee_rate
|
|
instruction_data = (
|
|
BUY_EXACT_OUT_DISCRIMINATOR
|
|
+ struct.pack("<Q", amount_out) # amount_out (u64)
|
|
+ struct.pack("<Q", maximum_amount_in) # maximum_amount_in (u64)
|
|
+ struct.pack("<Q", SHARE_FEE_RATE) # share_fee_rate (u64): 0
|
|
)
|
|
|
|
buy_exact_out_ix = Instruction(
|
|
program_id=RAYDIUM_LAUNCHLAB_PROGRAM_ID,
|
|
data=instruction_data,
|
|
accounts=accounts,
|
|
)
|
|
|
|
# Step 4: Create close WSOL account instruction
|
|
close_wsol_ix = create_close_account_instruction(
|
|
user_quote_token, PAYER.pubkey(), PAYER.pubkey()
|
|
)
|
|
|
|
# Step 5: Build complete transaction
|
|
instructions = [
|
|
set_compute_unit_price(COMPUTE_UNIT_PRICE),
|
|
set_compute_unit_limit(COMPUTE_UNIT_LIMIT),
|
|
# Instruction #3: Create Associated Token Account for base token (idempotent)
|
|
create_ata_ix,
|
|
# Instruction #4: Create WSOL account with seed
|
|
create_wsol_ix,
|
|
# Instruction #5: Initialize WSOL account
|
|
init_wsol_ix,
|
|
# Instruction #6: Execute buy_exact_out
|
|
buy_exact_out_ix,
|
|
# Instruction #7: Close WSOL account
|
|
close_wsol_ix,
|
|
]
|
|
|
|
blockhash_resp = await client.get_latest_blockhash()
|
|
recent_blockhash = blockhash_resp.value.blockhash
|
|
|
|
message = MessageV0.try_compile(
|
|
PAYER.pubkey(), instructions, [], recent_blockhash
|
|
)
|
|
|
|
transaction = VersionedTransaction(message, [PAYER])
|
|
|
|
print("Simulating transaction...")
|
|
simulation = await client.simulate_transaction(transaction)
|
|
|
|
if simulation.value.err:
|
|
print(f"Simulation failed: {simulation.value.err}")
|
|
# The error code alone does not say which account or
|
|
# constraint failed; the program logs do.
|
|
for line in simulation.value.logs or []:
|
|
print(f" {line}")
|
|
return None
|
|
|
|
print(
|
|
f"Simulation successful. Compute units consumed: {simulation.value.units_consumed}"
|
|
)
|
|
|
|
print("Sending transaction...")
|
|
result = await client.send_transaction(
|
|
transaction,
|
|
opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed),
|
|
)
|
|
|
|
tx_signature = result.value
|
|
print(f"Transaction sent: https://solscan.io/tx/{tx_signature}")
|
|
|
|
print("Waiting for confirmation...")
|
|
await tx_status.confirm_and_assert(client, tx_signature)
|
|
print("Transaction confirmed!")
|
|
|
|
return tx_signature
|
|
|
|
except Exception as e:
|
|
print(f"Error executing buy_exact_out: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
|
|
async def run(token_mint: Pubkey, amount: int, slippage: float) -> None:
|
|
"""Execute the buy_exact_out example.
|
|
|
|
Args:
|
|
token_mint: The coin to trade
|
|
amount: Whole tokens to receive
|
|
slippage: Slippage tolerance
|
|
"""
|
|
try:
|
|
print(f"Starting buy_exact_out for token: {token_mint}")
|
|
print(f"Amount to receive: {amount:,} tokens")
|
|
print(f"Slippage tolerance: {slippage * 100}%")
|
|
# Endpoint carries an API key. hostname, not netloc: netloc keeps any
|
|
# user:pass@ userinfo, which would leak the credential anyway.
|
|
print(f"Using RPC endpoint: {urlsplit(RPC_ENDPOINT).hostname or '<unset>'}")
|
|
print()
|
|
|
|
async with AsyncClient(RPC_ENDPOINT) as client:
|
|
balance_resp = await client.get_balance(PAYER.pubkey())
|
|
balance_sol = balance_resp.value / LAMPORTS_PER_SOL
|
|
print(f"Wallet balance: {balance_sol:.6f} SOL")
|
|
|
|
tx_signature = await buy_exact_out(
|
|
client, token_mint, amount, slippage
|
|
)
|
|
|
|
if tx_signature:
|
|
print(f"\n✅ Success! Transaction: {tx_signature}")
|
|
print(f"🔗 View on Solscan: https://solscan.io/tx/{tx_signature}")
|
|
else:
|
|
print("\n❌ Transaction failed!")
|
|
|
|
except ValueError as e:
|
|
print(f"Invalid token mint address: {e}")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
|
|
def main() -> None:
|
|
"""Parse the command line and run the trade."""
|
|
parser = argparse.ArgumentParser(description="Buy a fixed number of letsbonk.fun tokens")
|
|
parser.add_argument("mint", help="The coin's mint address")
|
|
parser.add_argument(
|
|
"amount",
|
|
nargs="?",
|
|
type=int,
|
|
default=DEFAULT_AMOUNT,
|
|
help=f"Whole tokens to receive (default {DEFAULT_AMOUNT})",
|
|
)
|
|
parser.add_argument(
|
|
"--slippage",
|
|
type=float,
|
|
default=DEFAULT_SLIPPAGE,
|
|
help=f"Slippage tolerance (default {DEFAULT_SLIPPAGE})",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
asyncio.run(run(Pubkey.from_string(args.mint), args.amount, args.slippage))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|