Files
pumpfun-bonkfun-bot_github/learning-examples/letsbonk-buy-sell/manual_sell_exact_out.py
T
836d873d27 fix: report on-chain trade outcomes correctly, and repair every broken learning example (#177)
* fix(tx-status): read meta.err before reporting a trade succeeded

confirm_transaction answers "did this signature land in a block?", never
"did it succeed". A landed transaction can have reverted, and RPC reports
that only in meta.err. Reading whether a call threw instead of reading
meta.err produced false results in both directions.

False success — a reverted trade reported as confirmed:

- ten examples (pump.fun, pumpswap, letsbonk) printed "Transaction
  confirmed" without checking meta.err
- src/cleanup/manager.py discarded confirm_transaction's boolean and
  logged "Closed successfully" unconditionally, so a reverted close
  reported rent as reclaimed while the account stayed open
- learning-examples/cleanup_accounts.py did the same

False failure — a good trade reported as unconfirmed:

- _get_transaction_result omitted maxSupportedTransactionVersion, so the
  RPC answered -32015 for every versioned (v0) transaction. meta.err was
  unreadable and a successful trade read back as failed. The bot sends
  legacy transactions, which is the only reason this was survivable.
- confirm_transaction raised TypeError on a base58 str (solana-py wants a
  Signature) while _get_transaction_result raised on a Signature (not
  JSON serializable). Both were swallowed by a broad except into "not
  confirmed". The annotations pointed the wrong way too:
  build_and_send_transaction returns Signature, not str.

Changes:

- add learning-examples/tx_status.py — assert_transaction_succeeded and
  confirm_and_assert, replacing the copy duplicated in mint_and_buy{,_v2}
- wire it into the ten examples that confirmed without checking
- read the boolean in both cleanup paths
- normalize str/Signature at the client boundary; correct the annotations
- send maxSupportedTransactionVersion: 0 on getTransaction
- split verify_transaction_succeeded out of confirm_transaction so the
  meta.err check can run against a transaction that landed earlier

Two reporting bugs found while testing the above:

- live_v2_round_trip read balances at solana-py's default (finalized)
  commitment while confirming trades at "confirmed", so the end read saw
  pre-trade state and it printed "net change: +0.000000000 SOL" after a
  real round trip. Verified: on a busy account finalized trails confirmed
  by ~263k lamports.
- cleanup_accounts produced no output at all, success or failure, because
  get_logger attaches no handler and only the bot installs one. httpx is
  pinned to WARNING alongside it — the RPC endpoint carries an API key.

Adds learning-examples/verify_tx_status_checks.py: offline stub checks, a
scan that fails if an example confirms without checking meta.err, an AST
check that nothing in src/ discards the boolean, a guard that
getTransaction opts into v0, and --live, which replays issue #175's three
signatures against mainnet and requires both layers to reject them on
meta.err (Custom: 6062) rather than on a failed fetch. The two src/ guards
were mutation-tested: reintroducing each bug makes them fail.

The BuybackFeeRecipientMissing (6062) half of #175 was already fixed by
the buy_v2/sell_v2 migration in 02343b7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(client): retry RPC timeouts instead of letting them escape post_rpc

aiohttp signals a request timeout with asyncio.TimeoutError, which is not
an aiohttp.ClientError. post_rpc caught only the latter, so every RPC
timeout propagated to the caller unretried — and str() on it is empty, so
whatever logged it printed a blank reason.

Found while running learning-examples/live_listener_matrix.py: three of
the four listeners died mid-run with "CRASHED: " and no message. The
endpoint was answering getHealth in ~100ms while getAccountInfo hung past
60s, and every caller that touches it (sol_balance, ata_is_closed,
AccountCleanupManager.cleanup_ata) went down with it. With the retry in
place the same run degrades to a logged failure and completes.

Also makes the verifier report a raising check as a failure rather than
aborting the run — several checks assert that a call does NOT raise, so
the raise is the finding and the remaining checks still need to report.

Covered by "RPC timeouts are retried, not raised" in
verify_tx_status_checks.py, mutation-tested against the one-exception
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(examples): raise websocket frame limit, load .env, drop dead code

Running every learning example turned up three separate reasons a shipped
script could not work at all.

blockSubscribe examples were completely non-functional. websockets
defaults max_size to 1 MiB and Solana's blockSubscribe frames run well
past that, so the connection died with a 1009 close on the first real
block and the retry loop spun. listen_blocksubscribe.py logged 158,613
"message too big" errors in 35s and decoded zero tokens; compare_listeners
produced 10.1M error lines and its block column never reported anything.
Pass max_size=WEBSOCKET_MAX_MESSAGE_BYTES (32 MiB, the value the bot's own
listeners already use) at all 12 example connect sites — logsSubscribe and
programSubscribe included, since they have the same latent ceiling.

After: listen_blocksubscribe decodes tokens with 0 errors, and
compare_listeners reports provider_1_block alongside geyser and logs.

Seven examples read SOLANA_* from the environment but never called
load_dotenv(), so they only ran with variables already exported —
manual_buy, manual_buy_cu_optimized, manual_buy_geyser, manual_sell,
fetch_price, blockSubscribe_extract_transactions and
sample_cashback_pumpswap. manual_buy died on
"None isn't a valid URI: scheme isn't ws or wss" against a normal .env
checkout.

Dead code and artifacts:

- unused datetime import in listen_pumpportal
- initial_real_token_reserves computed and never read in mint_and_buy and
  mint_and_buy_v2
- gitignore blockSubscribe-transactions/, which a shipped example writes
  into 1200+ files deep and which nothing ignored
- stop tracking trades/trades.log; .gitignore has listed it all along, but
  a tracked file ignores .gitignore

ruff over src/ and learning-examples/ goes 2396 -> 2393 findings: the three
removals, nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix: read balances at confirmed, derive letsbonk platform_config per pool

Running every example against mainnet surfaced four more bugs, three of
them the same root cause as the commitment bug already in this branch:
solana-py defaults to "finalized", but trades confirm at "confirmed", and
finalization lags far enough behind that anything read in between is
pre-trade state.

get_token_account_balance defaulted to finalized. Cleanup reads it to
decide whether to burn before closing, so right after a sell it saw the
pre-sell amount and built a burn for tokens the account no longer held —
the burn + close reverted with InsufficientFunds and the rent stayed
locked. Observed live: "Burning 35766666 tokens" on an account the sell
had already emptied, then Custom(1). Defaults to confirmed now, which is
also what confirm_transaction uses.

manual_sell_pumpswap read the user's base balance at finalized, which
failed two ways in one session: "could not find account" when the ATA had
been created by a buy moments earlier, and a stale non-zero balance whose
transfer then reverted with insufficient funds. Pool vault reads move to
confirmed too, so quotes are not priced off stale reserves.

The letsbonk examples hardcoded platform_config. LaunchLab pools do not
share one — partner launches carry their own — so every buy/sell against
such a pool failed with ConstraintAddress (2012):

  AnchorError caused by account: platform_config
  Left:  5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA
  Right: FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1

PoolState carries platform_config at field 18 and these scripts already
parse the pool, so they now take the pool's own value and keep the
constant only as a fallback. src/platforms/letsbonk already documented
this; only the examples were stale. All four scripts now simulate and land
(buy_exact_in, sell_exact_in, buy_exact_out, sell_exact_out).

They also discarded simulation logs on failure, printing an error number
with no indication of which account or constraint broke. They print the
program logs now — that is how the above was diagnosed.

Runnability and credentials:

- fetch_price, cleanup_accounts and the four letsbonk scripts hardcoded a
  placeholder ("...", "YOUR_TOKEN_MINT_ADDRESS_HERE") and could not run at
  all without editing the source. They take argv[1] now, matching
  manual_sell and the pumpswap scripts. cleanup_accounts takes "2022" as
  argv[2] for Token-2022 mints, which every pump.fun coin is.
- six scripts printed the RPC/WSS endpoint, which carries an API key, into
  stdout. They print only the host now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(cleanup): never burn wrapped SOL, and label what actually happened

learning-examples/cleanup_accounts.py burned any non-zero balance before
closing. The token program rejects a burn of native (wrapped) SOL with
NativeNotSupported (error 10), so the burn + close transaction reverted and
a WSOL account could never be cleaned up — the pumpswap examples leave one
behind on every sell. Closing a WSOL account already returns both the
wrapped lamports and the rent, so there is nothing to burn first.

src/cleanup/manager.py already had this guard; only the example was
missing it. Observed live: "Burning 2206381 tokens" then Custom(10), with
2.2m lamports stranded until the guard went in.

The success line also claimed "Burned and closed" for the unwrap path,
which burns nothing. It now reports Unwrapped/Burned/Closed to match the
instructions actually built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* chore: drop captured-output artifacts and a spent one-off investigation script

learning-examples/decoded_buy_tx_from_getTransaction.json and
decoded_create_tx_from_getTransaction.json are not JSON — they are captured
stdout from the decode scripts, several objects concatenated, so json.load
raises "Extra data" on both. Nothing reads them and nothing can. The
raw_*.json fixtures stay: all three are valid input and were re-checked
through decode_from_getTransaction / decode_from_blockSubscribe /
decode_from_getAccountInfo.

learning-examples/pumpswap/sample_cashback_pumpswap.py was added by #168 to
find the position of the extra account that cashback PumpSwap pools
require. That question is answered — the layouts are in
platforms/pumpfun/instruction_builder.py and machine-checked against the
IDL by verify_v2_account_layout.py. It is undocumented, unlike every other
pumpswap example in the README, and nothing imports it.

Not touched: logs/ (run history), .cursor/.kiro/.windsurf (deliberate
mirrors of the same rules for other editors), and the raw_*.json fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

* fix(tx-status): fail closed on missing metadata, stop retrying landed reverts

Addresses the must-fix findings from review. Two of them were wrong in code
this branch added.

assert_transaction_succeeded folded meta=None into err=None and returned
success. Missing execution metadata means the outcome is unknown, not that
it succeeded — which is the exact mistake this module exists to prevent. It
raises now.

It also always read at "confirmed" while confirm_and_assert accepted a
commitment argument, so confirming at "finalized" and then reading status at
"confirmed" could report success before the finalization the caller asked
for. The commitment is threaded through.

The manual buy/sell retry loops built one message and blockhash before the
loop, so a landed revert was retried by resubmitting byte-identical signed
bytes — three attempts with backoff that could never succeed. Reverts now
raise TransactionRevertedError (a RuntimeError subclass, so existing
handlers keep working) and the loops treat it as terminal. Rebuilding and
re-signing per attempt is the fuller fix but a bigger change to these
scripts than this branch should carry.

cleanup_accounts guessed the mint's token program, defaulting to legacy SPL
unless argv[2] was "2022". The ATA address differs between programs, so a
wrong guess derives an address that does not exist and the script reports
"already closed" for an account it never looked at. It reads the owner off
the mint account instead, which is authoritative, and rejects anything not
owned by a token program. Verified live: pump.fun coins resolve to
Token-2022, letsbonk and USDC to legacy. argv[2] is gone — nothing to guess.

urlsplit(...).netloc keeps any user:pass@ userinfo, so the endpoint
redaction added earlier still printed credentials for providers that put the
key there. Uses .hostname in all five sites.

Four checks added to verify_tx_status_checks.py, each mutation-tested:
missing metadata, revert-is-terminal, commitment propagation, and a scan
that fails if any example goes back to netloc.

Skipped, both minor and pre-existing repo-wide rather than introduced here:
validating that RPC/WSS env vars are non-None before connecting (every
example has this shape; fixing two of them would just make it inconsistent),
and the cryptic base58 error when a letsbonk script runs with no argument,
which matches the "..." placeholder convention the pumpswap and manual_sell
examples already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:44:35 +02:00

777 lines
27 KiB
Python

"""
Manual Sell Exact Out Example for Raydium LaunchLab
This script demonstrates how to sell tokens using the sell_exact_out instruction
from the Raydium LaunchLab program. It follows the IDL structure.
Key features:
- Uses sell_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 sell_exact_in example
- User configurable SOL amount to receive and slippage
- Uses idempotent ATA creation
"""
import asyncio
import os
import struct
import sys
from urllib.parse import urlsplit
import base58
from dotenv import load_dotenv
from idl_parser import load_idl_parser
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
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 Message
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.dirname(os.path.dirname(os.path.abspath(__file__))))
import tx_status # noqa: E402
# 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()
# Token mint: pass as argv[1], or hardcode here.
TOKEN_MINT_ADDRESS = Pubkey.from_string(
sys.argv[1] if len(sys.argv) > 1 else "YOUR_TOKEN_MINT_ADDRESS_HERE"
)
# 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)
# User configurable parameters
SOL_AMOUNT_TO_RECEIVE = float(
os.environ.get("SOL_AMOUNT", "0.0001")
) # Amount of SOL to receive
SLIPPAGE_TOLERANCE = float(os.environ.get("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 sell_exact_out (from IDL)
SELL_EXACT_OUT_DISCRIMINATOR = bytes([95, 200, 71, 34, 8, 9, 11, 166])
# 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 required input using constant product formula.
This is for selling base tokens to get an exact amount of quote tokens (WSOL).
Args:
pool_state_data: Decoded pool state data containing reserves
amount_out: Amount of quote tokens (WSOL) desired to receive
slippage_tolerance: Slippage tolerance as a decimal (0.25 = 25%)
Returns:
Maximum amount of base tokens to sell
"""
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 selling base tokens to get exact quote: amount_in = (amount_out * virtual_base) / (virtual_quote - amount_out)
# This is the inverse of the sell formula
# Calculate required input using constant product formula
numerator = amount_out * virtual_base
denominator = virtual_quote - amount_out
if denominator <= 0:
print("Error: Amount out is too large for current pool state")
return None
expected_input = numerator // denominator
# Apply slippage tolerance (allow selling more tokens than expected)
maximum_with_slippage = int(expected_input * (1 + slippage_tolerance))
print(
f"Amount out: {amount_out:,} lamports ({amount_out / LAMPORTS_PER_SOL:.6f} SOL)"
)
print(f"Expected input: {expected_input:,} tokens")
print(
f"Maximum with {slippage_tolerance * 100}% slippage: {maximum_with_slippage:,} tokens"
)
return maximum_with_slippage
except Exception as e:
print(f"Error calculating maximum amount in from pool state: {e}")
return None
async def sell_exact_out(
client: AsyncClient,
base_token_mint: Pubkey,
amount_out_sol: float,
slippage_tolerance: float,
) -> str | None:
"""
Execute a sell_exact_out transaction on Raydium LaunchLab.
This function implements the exact transaction flow similar to sell_exact_in:
1. SetComputeUnitPrice
2. SetComputeUnitLimit
3. Create WSOL account with seed
4. Initialize WSOL account
5. Execute sell_exact_out instruction
6. Close WSOL account
7. Optional: Transfer remaining SOL (as seen in the example)
Args:
client: Solana RPC client
base_token_mint: Address of the token to sell
amount_out_sol: Exact amount of SOL 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
amount_out = int(amount_out_sol * LAMPORTS_PER_SOL)
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:,} lamports ({amount_out_sol} SOL)")
print(f"Maximum amount in: {maximum_amount_in:,} tokens")
# Get user's base token account (where tokens will be debited from)
user_base_token = get_associated_token_address(PAYER.pubkey(), base_token_mint)
# Step 1: Create WSOL account with seed (where WSOL will be received)
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 (minimal amount for account creation)
account_creation_lamports = 2_039_280 # Standard account creation cost
user_quote_token, create_wsol_ix, init_wsol_ix = create_wsol_account_with_seed(
PAYER.pubkey(), wsol_seed, account_creation_lamports
)
print(f"User base token account: {user_base_token}")
print(f"User quote token account: {user_quote_token}")
# Step 2: Build the sell_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 (tokens being sold)
AccountMeta(
pubkey=user_quote_token, is_signer=False, is_writable=True
), # user_quote_token (WSOL received)
AccountMeta(
pubkey=base_vault, is_signer=False, is_writable=True
), # base_vault (receives tokens)
AccountMeta(
pubkey=quote_vault, is_signer=False, is_writable=True
), # quote_vault (sends WSOL)
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 = (
SELL_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
)
sell_exact_out_ix = Instruction(
program_id=RAYDIUM_LAUNCHLAB_PROGRAM_ID,
data=instruction_data,
accounts=accounts,
)
# Step 3: Create close WSOL account instruction
close_wsol_ix = create_close_account_instruction(
user_quote_token, PAYER.pubkey(), PAYER.pubkey()
)
# Step 4: Build complete transaction
instructions = [
set_compute_unit_price(COMPUTE_UNIT_PRICE),
set_compute_unit_limit(COMPUTE_UNIT_LIMIT),
# Instruction #3: Create WSOL account with seed
create_wsol_ix,
# Instruction #4: Initialize WSOL account
init_wsol_ix,
# Instruction #5: Execute sell_exact_out
sell_exact_out_ix,
# Instruction #6: Close WSOL account
close_wsol_ix,
]
blockhash_resp = await client.get_latest_blockhash()
recent_blockhash = blockhash_resp.value.blockhash
message = Message.new_with_blockhash(
instructions, PAYER.pubkey(), 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=TxOpts(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 sell_exact_out: {e}")
import traceback
traceback.print_exc()
return None
async def main():
"""
Main function to execute the sell_exact_out example.
Takes configuration from environment variables or uses defaults.
"""
try:
print(f"Starting sell_exact_out for token: {TOKEN_MINT_ADDRESS}")
print(f"Amount to receive: {SOL_AMOUNT_TO_RECEIVE} SOL")
print(f"Slippage tolerance: {SLIPPAGE_TOLERANCE * 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")
# Check if user has the base token account and sufficient balance
user_base_token = get_associated_token_address(
PAYER.pubkey(), TOKEN_MINT_ADDRESS
)
try:
token_account_info = await client.get_token_account_balance(
user_base_token
)
if token_account_info.value:
token_balance = int(token_account_info.value.amount)
print(f"Token balance: {token_balance:,} tokens")
# We don't know exactly how many tokens will be needed until we calculate from pool state
# So we'll just warn if balance is very low
if token_balance < 1000:
print(f"Warning: Low token balance ({token_balance:,} tokens)")
else:
print("Token account not found or has no balance!")
return
except Exception as e:
print(f"Error checking token balance: {e}")
print("Continuing anyway...")
tx_signature = await sell_exact_out(
client, TOKEN_MINT_ADDRESS, SOL_AMOUNT_TO_RECEIVE, SLIPPAGE_TOLERANCE
)
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)
if __name__ == "__main__":
asyncio.run(main())