diff --git a/.gitignore b/.gitignore index f5cce9b..70110c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ trades/* +# written by learning-examples/blockSubscribe_extract_transactions.py +blockSubscribe-transactions/ .vscode .pylintrc diff --git a/CLAUDE.md b/CLAUDE.md index ca7bf24..11e5271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,37 @@ Run all three after any pump.fun program upgrade. The simulations report `unitsConsumed`; use it to retune `get_buy_compute_unit_limit` / `get_sell_compute_unit_limit` in `platforms/pumpfun/instruction_builder.py`. +### Verifying transaction-status handling +```bash +# Offline: stub checks plus a scan that every example verifies meta.err +uv run learning-examples/verify_tx_status_checks.py + +# Adds a mainnet replay of the reverted signatures from issue #175 +uv run learning-examples/verify_tx_status_checks.py --live +``` + +`confirm_transaction` answers "did this land in a block?", never "did it +succeed". A landed transaction can have reverted, and RPC reports that only in +`meta.err`. Reporting success without reading it is issue #175: buys reverting +with `BuybackFeeRecipientMissing` (6062) printed as confirmed buys. + +- Examples use `learning-examples/tx_status.py` — `confirm_and_assert` in place + of a bare `confirm_transaction`, or `assert_transaction_succeeded` after one. + The verifier above fails the build if a new example skips it. +- The bot uses `SolanaClient.confirm_transaction`, which folds `meta.err` into + its return value. **Read the boolean** — discarding it is the same bug. +- `_get_transaction_result` must send `maxSupportedTransactionVersion: 0` or the + RPC rejects every versioned (v0) transaction with `-32015`, and a good trade + reads back as unconfirmed. +- `build_and_send_transaction` returns a solders `Signature`, not a `str`. A + `Signature` is not JSON serializable and does not support slicing; a `str` is + rejected by solana-py's `confirm_transaction`. Normalize at the boundary. +- `post_rpc` must catch `asyncio.TimeoutError` alongside `aiohttp.ClientError`. + aiohttp raises the former when the request timeout fires and it is **not** a + `ClientError`, so leaving it out lets every RPC timeout escape unretried — + and `str()` on it is empty, so the caller logs a blank reason. A slow + `getAccountInfo` is enough to take down a whole listener run this way. + ### Code Quality ```bash # Format code diff --git a/learning-examples/blockSubscribe_extract_transactions.py b/learning-examples/blockSubscribe_extract_transactions.py index e30a51f..152202f 100644 --- a/learning-examples/blockSubscribe_extract_transactions.py +++ b/learning-examples/blockSubscribe_extract_transactions.py @@ -4,11 +4,19 @@ import json import os import websockets +from dotenv import load_dotenv from solders.pubkey import Pubkey PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P") +load_dotenv() + WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + async def save_transaction(tx_data, tx_signature): os.makedirs("blockSubscribe-transactions", exist_ok=True) @@ -20,7 +28,9 @@ async def save_transaction(tx_data, tx_signature): async def listen_for_transactions(): - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/cleanup_accounts.py b/learning-examples/cleanup_accounts.py index 2189457..2d4e583 100644 --- a/learning-examples/cleanup_accounts.py +++ b/learning-examples/cleanup_accounts.py @@ -1,5 +1,7 @@ import asyncio +import logging import os +import sys from dotenv import load_dotenv from solders.pubkey import Pubkey @@ -11,21 +13,62 @@ from core.wallet import Wallet from utils.logger import get_logger load_dotenv() +# get_logger attaches no handler — the bot installs one at startup, but a +# standalone example has to do it itself or every line below goes nowhere. This +# script ran completely silently, success or failure, without it. +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +# httpx logs each request at INFO, and the RPC endpoint carries an API key in +# its path — keep it out of the console. +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) logger = get_logger(__name__) RPC_ENDPOINT = os.getenv("SOLANA_NODE_RPC_ENDPOINT") PRIVATE_KEY = os.getenv("SOLANA_PRIVATE_KEY") # Update this address to MINT address of a token you want to close -MINT_ADDRESS = Pubkey.from_string("9WHpYbqG6LJvfCYfMjvGbyo1wHXgroCrixPb33s2pump") +# Mint of the token account to close: pass as argv[1], or hardcode here. +MINT_ADDRESS = Pubkey.from_string( + sys.argv[1] if len(sys.argv) > 1 else "9WHpYbqG6LJvfCYfMjvGbyo1wHXgroCrixPb33s2pump" +) -# Token program for the mint - use TOKEN_PROGRAM for legacy SPL tokens, TOKEN_2022_PROGRAM for Token-2022 -# This must match the actual token's program to derive the correct ATA address -TOKEN_PROGRAM = SystemAddresses.TOKEN_PROGRAM +# The mint's token program is read from the mint account itself (see +# resolve_token_program). Guessing it derives the wrong ATA address, and the +# script then reports "already closed" for an account that was never looked at. + + +async def resolve_token_program(client: SolanaClient, mint: Pubkey) -> Pubkey: + """Return the token program that owns this mint. + + A mint account is owned by whichever token program created it, so the mint + itself is the authoritative source. Every pump.fun coin is Token-2022 while + letsbonk coins and USDC are legacy SPL, and the ATA address differs between + them — deriving with the wrong one silently points at an address that does + not exist. + + Args: + client: Solana RPC client + mint: Mint address + + Returns: + TOKEN_PROGRAM or TOKEN_2022_PROGRAM + + Raises: + ValueError: If the mint is missing or owned by something else + """ + info = await client.get_account_info(mint) + owner = info.owner + if owner not in (SystemAddresses.TOKEN_PROGRAM, SystemAddresses.TOKEN_2022_PROGRAM): + raise ValueError(f"Mint {mint} is not owned by a token program (owner {owner})") + return owner async def close_account_if_exists( - client: SolanaClient, wallet: Wallet, account: Pubkey, mint: Pubkey + client: SolanaClient, + wallet: Wallet, + account: Pubkey, + mint: Pubkey, + token_program: Pubkey, ): """Safely close a token account if it exists and reclaim rent.""" try: @@ -40,7 +83,17 @@ async def close_account_if_exists( # Burn + close are combined into a single transaction to avoid race conditions instructions = [] balance = await client.get_token_account_balance(account) - if balance > 0: + if balance > 0 and mint == SystemAddresses.WSOL_MINT: + # Wrapped SOL cannot be burned — the token program rejects it with + # NativeNotSupported (error 10) and the whole transaction reverts, so + # the account can never be closed. Closing a WSOL account already + # returns both the wrapped lamports and the rent to the owner, so + # there is nothing to burn first. Matches AccountCleanupManager. + logger.info( + f"Unwrapping {balance} lamports of wrapped SOL from {account} " + f"by closing it (burn skipped)" + ) + elif balance > 0: logger.info(f"Burning {balance} tokens from account {account}...") burn_ix = burn( BurnParams( @@ -48,7 +101,7 @@ async def close_account_if_exists( mint=mint, owner=wallet.pubkey, amount=balance, - program_id=TOKEN_PROGRAM, + program_id=token_program, ) ) instructions.append(burn_ix) @@ -59,7 +112,7 @@ async def close_account_if_exists( account=account, dest=wallet.pubkey, owner=wallet.pubkey, - program_id=TOKEN_PROGRAM, + program_id=token_program, ) instructions.append(close_account(close_params)) @@ -68,9 +121,20 @@ async def close_account_if_exists( wallet.keypair, skip_preflight=True, ) - await client.confirm_transaction(tx_sig) - action = "Burned and closed" if balance > 0 else "Closed" - logger.info(f"{action} successfully: {account}") + # confirm_transaction returns False when the transaction landed but + # reverted — reporting success on that would hide a failed cleanup. + # The label reflects what was actually built: wrapped SOL is unwrapped by + # the close, never burned, so it must not claim a burn. + if balance > 0 and mint == SystemAddresses.WSOL_MINT: + action = "Unwrapped and closed" + elif balance > 0: + action = "Burned and closed" + else: + action = "Closed" + if await client.confirm_transaction(tx_sig): + logger.info(f"{action} successfully: {account}") + else: + logger.error(f"Failed to {action.lower()} account {account}: {tx_sig}") except Exception as e: logger.error(f"Error while processing account {account}: {e}") @@ -81,9 +145,12 @@ async def main(): client = SolanaClient(RPC_ENDPOINT) wallet = Wallet(PRIVATE_KEY) + token_program = await resolve_token_program(client, MINT_ADDRESS) + logger.info(f"Mint {MINT_ADDRESS} uses token program {token_program}") + # Get user's ATA for the token - ata = wallet.get_associated_token_address(MINT_ADDRESS, TOKEN_PROGRAM) - await close_account_if_exists(client, wallet, ata, MINT_ADDRESS) + ata = wallet.get_associated_token_address(MINT_ADDRESS, token_program) + await close_account_if_exists(client, wallet, ata, MINT_ADDRESS, token_program) except Exception as e: logger.error(f"Unexpected error: {e}") diff --git a/learning-examples/copytrading/listen_wallet_transactions.py b/learning-examples/copytrading/listen_wallet_transactions.py index 7c2bad2..6f9a2c9 100644 --- a/learning-examples/copytrading/listen_wallet_transactions.py +++ b/learning-examples/copytrading/listen_wallet_transactions.py @@ -12,6 +12,7 @@ import os import struct import sys from datetime import datetime +from urllib.parse import urlsplit import base58 import websockets @@ -21,6 +22,11 @@ load_dotenv() # Configuration WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 WALLET_TO_TRACK = sys.argv[1] if len(sys.argv) > 1 else "..." # Pass wallet as argv[1] or hardcode # Pump.fun program constants @@ -393,13 +399,17 @@ async def process_websocket_message(websocket): async def listen_for_transactions(): """Main function to listen for wallet transactions.""" print(f"Starting to monitor wallet: {WALLET_TO_TRACK}") - print(f"Connecting to: {WSS_ENDPOINT}") + # Endpoint carries an API key. hostname, not netloc: netloc keeps any + # user:pass@ userinfo, which would leak the credential anyway. + print(f"Connecting to: {urlsplit(WSS_ENDPOINT).hostname or ''}") print("Looking for pump.fun bonding curve buy/sell transactions only...") print("=" * 80) while True: try: - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: await subscribe_to_wallet_logs(websocket) ping_task = asyncio.create_task(keep_connection_alive(websocket)) diff --git a/learning-examples/decoded_buy_tx_from_getTransaction.json b/learning-examples/decoded_buy_tx_from_getTransaction.json deleted file mode 100644 index cb33c5a..0000000 --- a/learning-examples/decoded_buy_tx_from_getTransaction.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "message": { - "accountKeys": [ - { - "pubkey": "2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef", - "signer": true, - "source": "transaction", - "writable": true - }, - { - "pubkey": "6fogeBTRjgm9Kb9dVtqpjDf6bGvjGgdScmTu5nCVPJPn", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "9nj8QEp6mQDsr2G6oGtq8DJakPuKLeUqnWMf4JzgcSCd", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "DxMF77MqYYYr4NshXWrUdiGzfzwpNhKG7H73B94ETX8S", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "JBfdxsh3DEXfpeTUrQe94hpKQ8yxBqVRqRiSRvemUA4i", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "11111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "ComputeBudget111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "SysvarRent111111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "HfJVjBdkhAD2ynVM8PdTSii4ECZdsxNTCx5wpEqUpump", - "signer": false, - "source": "transaction", - "writable": false - } - ], - "addressTableLookups": [], - "instructions": [ - { - "accounts": [], - "data": "3JwEwun4YUPH", - "programId": "ComputeBudget111111111111111111111111111111", - "stackHeight": null - }, - { - "accounts": [], - "data": "LEJDE7", - "programId": "ComputeBudget111111111111111111111111111111", - "stackHeight": null - }, - { - "accounts": [ - "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", - "HfJVjBdkhAD2ynVM8PdTSii4ECZdsxNTCx5wpEqUpump", - "6fogeBTRjgm9Kb9dVtqpjDf6bGvjGgdScmTu5nCVPJPn", - "9nj8QEp6mQDsr2G6oGtq8DJakPuKLeUqnWMf4JzgcSCd", - "DxMF77MqYYYr4NshXWrUdiGzfzwpNhKG7H73B94ETX8S", - "2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef", - "11111111111111111111111111111111", - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "SysvarRent111111111111111111111111111111111", - "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" - ], - "data": "AJTQ2h9DXrBqzdpEhcLVYocNoqujioBxb", - "programId": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "stackHeight": null - }, - { - "accounts": [ - "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", - "HfJVjBdkhAD2ynVM8PdTSii4ECZdsxNTCx5wpEqUpump", - "6fogeBTRjgm9Kb9dVtqpjDf6bGvjGgdScmTu5nCVPJPn", - "9nj8QEp6mQDsr2G6oGtq8DJakPuKLeUqnWMf4JzgcSCd", - "DxMF77MqYYYr4NshXWrUdiGzfzwpNhKG7H73B94ETX8S", - "2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef", - "11111111111111111111111111111111", - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" - ], - "data": "5jRcjdixRUDSvSh4QXANSHU9rk2He2WMm", - "programId": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "stackHeight": null - }, - { - "parsed": { - "info": { - "destination": "JBfdxsh3DEXfpeTUrQe94hpKQ8yxBqVRqRiSRvemUA4i", - "lamports": 962563, - "source": "2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef" - }, - "type": "transfer" - }, - "program": "system", - "programId": "11111111111111111111111111111111", - "stackHeight": null - } - ], - "recentBlockhash": "cqZ8vtKbuAVtwCRHrATJn7X3kohsJH2Qn9zdhEr8sE5" - }, - "signatures": [ - "33LHUzKfhBvU68kYib22oFx2XdQKQZ2sfvTmmdCFEr6YidyP8S3WwAurjz6Suewvr2WUByV79K3stNCZDe7wq3De" - ] -} -Instruction for program: ComputeBudget111111111111111111111111111111 -Data: 3JwEwun4YUPH - -Instruction for program: ComputeBudget111111111111111111111111111111 -Data: LEJDE7 - -Instruction: buy -Decoded data: {'amount': 605426095720} - -Accounts: - global: 4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf - feeRecipient: CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM - mint: HfJVjBdkhAD2ynVM8PdTSii4ECZdsxNTCx5wpEqUpump - bondingCurve: 6fogeBTRjgm9Kb9dVtqpjDf6bGvjGgdScmTu5nCVPJPn - associatedBondingCurve: 9nj8QEp6mQDsr2G6oGtq8DJakPuKLeUqnWMf4JzgcSCd - associatedUser: DxMF77MqYYYr4NshXWrUdiGzfzwpNhKG7H73B94ETX8S - user: 2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef - systemProgram: 11111111111111111111111111111111 - tokenProgram: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - rent: SysvarRent111111111111111111111111111111111 - eventAuthority: Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1 - program: 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P -Instruction: buy -Decoded data: {'amount': 605426095720} - -Accounts: - global: 4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf - feeRecipient: CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM - mint: HfJVjBdkhAD2ynVM8PdTSii4ECZdsxNTCx5wpEqUpump - bondingCurve: 6fogeBTRjgm9Kb9dVtqpjDf6bGvjGgdScmTu5nCVPJPn - associatedBondingCurve: 9nj8QEp6mQDsr2G6oGtq8DJakPuKLeUqnWMf4JzgcSCd - associatedUser: DxMF77MqYYYr4NshXWrUdiGzfzwpNhKG7H73B94ETX8S - user: 2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef - systemProgram: 11111111111111111111111111111111 - tokenProgram: ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL - rent: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - eventAuthority: Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1 - program: 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P -Parsed instruction: system - transfer -Info: { - "destination": "JBfdxsh3DEXfpeTUrQe94hpKQ8yxBqVRqRiSRvemUA4i", - "lamports": 962563, - "source": "2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef" -} - -Transaction Information: -Blockhash: cqZ8vtKbuAVtwCRHrATJn7X3kohsJH2Qn9zdhEr8sE5 -Fee payer: 2vr538qDgHCPYmr2mjt5LSjQ3kBYjtw3SDSveUKBVkef -Signature: 33LHUzKfhBvU68kYib22oFx2XdQKQZ2sfvTmmdCFEr6YidyP8S3WwAurjz6Suewvr2WUByV79K3stNCZDe7wq3De diff --git a/learning-examples/decoded_create_tx_from_getTransaction.json b/learning-examples/decoded_create_tx_from_getTransaction.json deleted file mode 100644 index 24781f3..0000000 --- a/learning-examples/decoded_create_tx_from_getTransaction.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "message": { - "accountKeys": [ - { - "pubkey": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5", - "signer": true, - "source": "transaction", - "writable": true - }, - { - "pubkey": "ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp", - "signer": true, - "source": "transaction", - "writable": true - }, - { - "pubkey": "HWEoBxYs7ssKuudEjzjmpfJVX7Dvi7wescFsVx2L5yoY", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "FFzxakVNzpirwMFtLyD22UZ6UM3KLF2EAGC2RxNPaYoH", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "6eTEMemDi58KJE1rPEagqMsgn34xUWTkzdZTKM8EVbYF", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "H6LkmMUuAiJhAcH9ejScvMVXFhUSzxwYPiczL7zW3aAj", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "BfUATFeQBKLdaTGdCT9bKkxXrYTYaRkcjtQ4iFwMGVq1", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", - "signer": false, - "source": "transaction", - "writable": true - }, - { - "pubkey": "ComputeBudget111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "11111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "SysvarRent111111111111111111111111111111111", - "signer": false, - "source": "transaction", - "writable": false - }, - { - "pubkey": "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "signer": false, - "source": "transaction", - "writable": false - } - ], - "addressTableLookups": [], - "instructions": [ - { - "accounts": [], - "data": "HnkkG7", - "programId": "ComputeBudget111111111111111111111111111111", - "stackHeight": null - }, - { - "parsed": { - "info": { - "destination": "HWEoBxYs7ssKuudEjzjmpfJVX7Dvi7wescFsVx2L5yoY", - "lamports": 4000000, - "source": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5" - }, - "type": "transfer" - }, - "program": "system", - "programId": "11111111111111111111111111111111", - "stackHeight": null - }, - { - "accounts": [], - "data": "3ZfX8LdfViHV", - "programId": "ComputeBudget111111111111111111111111111111", - "stackHeight": null - }, - { - "accounts": [ - "ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp", - "TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM", - "FFzxakVNzpirwMFtLyD22UZ6UM3KLF2EAGC2RxNPaYoH", - "6eTEMemDi58KJE1rPEagqMsgn34xUWTkzdZTKM8EVbYF", - "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", - "H6LkmMUuAiJhAcH9ejScvMVXFhUSzxwYPiczL7zW3aAj", - "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5", - "11111111111111111111111111111111", - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", - "SysvarRent111111111111111111111111111111111", - "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" - ], - "data": "U5L9Srg16xJSo5n5mGEnUQaep1FgNe24zh1oYXVbcVpSyAPk5QWVT8RNVbaJeebkjgPqHUHnWwPyPFmJ21HDBYXgTd8HTU7QfbiY7cn26rn4zxi724rGkbWKwnJHghce74jdF8dLeGwvYnU", - "programId": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "stackHeight": null - }, - { - "parsed": { - "info": { - "account": "BfUATFeQBKLdaTGdCT9bKkxXrYTYaRkcjtQ4iFwMGVq1", - "mint": "ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp", - "source": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5", - "systemProgram": "11111111111111111111111111111111", - "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "wallet": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5" - }, - "type": "create" - }, - "program": "spl-associated-token-account", - "programId": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", - "stackHeight": null - }, - { - "accounts": [ - "4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf", - "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", - "ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp", - "FFzxakVNzpirwMFtLyD22UZ6UM3KLF2EAGC2RxNPaYoH", - "6eTEMemDi58KJE1rPEagqMsgn34xUWTkzdZTKM8EVbYF", - "BfUATFeQBKLdaTGdCT9bKkxXrYTYaRkcjtQ4iFwMGVq1", - "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5", - "11111111111111111111111111111111", - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "SysvarRent111111111111111111111111111111111", - "Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1", - "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P" - ], - "data": "AJTQ2h9DXrBiKPvzMVAo11jUarcFAxcz3", - "programId": "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", - "stackHeight": null - } - ], - "recentBlockhash": "4LkonCgzoF4HF4ZmrczNCntkk5QJzoBfwEG67o99S6yR" - }, - "signatures": [ - "52ar89rghM8EwKZkxFnBMC4LaMReqtVdpxqXxGWBCMYV1DnYdLrdsqJo8Hbn9KjVpckAokqGNHzTSVfK5xuLepdC", - "SpE85aiJConP43xzaqrri6TRmEbKZdiSJoXeTixJPuMkYUSQZbyjxrb3NbatshfoggYacnyd1YWJf98iPV5YYqm" - ] -} -Instruction for program: ComputeBudget111111111111111111111111111111 -Data: HnkkG7 - -Parsed instruction: system - transfer -Info: { - "destination": "HWEoBxYs7ssKuudEjzjmpfJVX7Dvi7wescFsVx2L5yoY", - "lamports": 4000000, - "source": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5" -} -Instruction for program: ComputeBudget111111111111111111111111111111 -Data: 3ZfX8LdfViHV - -Instruction: create -Decoded data: {'name': 'excited', 'symbol': 'excited', 'uri': 'https://cf-ipfs.com/ipfs/QmcDiP9wAZ8QeijrNrtwLeMGk46vKAAe8yvfWtumBZbURq'} - -Accounts: - mint: ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp - mintAuthority: TSLvdd1pWpHVjahSpsvCXUbgwsL3JAcvokwaKt1eokM - bondingCurve: FFzxakVNzpirwMFtLyD22UZ6UM3KLF2EAGC2RxNPaYoH - associatedBondingCurve: 6eTEMemDi58KJE1rPEagqMsgn34xUWTkzdZTKM8EVbYF - global: 4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf - mplTokenMetadata: metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s - metadata: H6LkmMUuAiJhAcH9ejScvMVXFhUSzxwYPiczL7zW3aAj - user: Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5 - systemProgram: 11111111111111111111111111111111 - tokenProgram: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - associatedTokenProgram: ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL - rent: SysvarRent111111111111111111111111111111111 - eventAuthority: Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1 - program: 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P -Parsed instruction: spl-associated-token-account - create -Info: { - "account": "BfUATFeQBKLdaTGdCT9bKkxXrYTYaRkcjtQ4iFwMGVq1", - "mint": "ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp", - "source": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5", - "systemProgram": "11111111111111111111111111111111", - "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - "wallet": "Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5" -} -Instruction: buy -Decoded data: {'amount': 37951768488745} - -Accounts: - global: 4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf - feeRecipient: CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM - mint: ER2N5eaDoC68kNhj7LyaScimzq7deaqxiw88rewvxaKp - bondingCurve: FFzxakVNzpirwMFtLyD22UZ6UM3KLF2EAGC2RxNPaYoH - associatedBondingCurve: 6eTEMemDi58KJE1rPEagqMsgn34xUWTkzdZTKM8EVbYF - associatedUser: BfUATFeQBKLdaTGdCT9bKkxXrYTYaRkcjtQ4iFwMGVq1 - user: Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5 - systemProgram: 11111111111111111111111111111111 - tokenProgram: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - rent: SysvarRent111111111111111111111111111111111 - eventAuthority: Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1 - program: 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P - -Transaction Information: -Blockhash: 4LkonCgzoF4HF4ZmrczNCntkk5QJzoBfwEG67o99S6yR -Fee payer: Fswrw3tgQL597kCexxLEhft6a7Su4CDoqwRMqqj4BEp5 -Signature: 52ar89rghM8EwKZkxFnBMC4LaMReqtVdpxqXxGWBCMYV1DnYdLrdsqJo8Hbn9KjVpckAokqGNHzTSVfK5xuLepdC diff --git a/learning-examples/fetch_price.py b/learning-examples/fetch_price.py index f451fe1..25c3f2b 100644 --- a/learning-examples/fetch_price.py +++ b/learning-examples/fetch_price.py @@ -1,19 +1,24 @@ import asyncio import os import struct +import sys from typing import Final from construct import Bytes, Flag, Int64ul, Struct +from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solders.pubkey import Pubkey LAMPORTS_PER_SOL: Final[int] = 1_000_000_000 TOKEN_DECIMALS: Final[int] = 6 -CURVE_ADDRESS: Final[str] = "..." # Replace with actual bonding curve address +# Bonding curve address: pass as argv[1], or hardcode here. +CURVE_ADDRESS: Final[str] = sys.argv[1] if len(sys.argv) > 1 else "..." # Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py EXPECTED_DISCRIMINATOR: Final[bytes] = struct.pack(" 1 else "YOUR_TOKEN_MINT_ADDRESS_HERE" +) # Configuration constants RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") @@ -60,6 +65,10 @@ 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" ) @@ -504,9 +513,15 @@ async def buy_exact_in( # 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( - LETSBONK_PLATFORM_CONFIG, WSOL_MINT + platform_config, WSOL_MINT ) print(f"Creator fee vault: {creator_fee_vault}") @@ -558,7 +573,7 @@ async def buy_exact_in( pubkey=GLOBAL_CONFIG, is_signer=False, is_writable=False ), # global_config AccountMeta( - pubkey=LETSBONK_PLATFORM_CONFIG, is_signer=False, is_writable=False + pubkey=platform_config, is_signer=False, is_writable=False ), # platform_config AccountMeta( pubkey=pool_state, is_signer=False, is_writable=True @@ -656,6 +671,10 @@ async def buy_exact_in( 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( @@ -672,7 +691,7 @@ async def buy_exact_in( print(f"Transaction sent: https://solscan.io/tx/{tx_signature}") print("Waiting for confirmation...") - await client.confirm_transaction(tx_signature, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_signature) print("Transaction confirmed!") return tx_signature @@ -695,7 +714,9 @@ async def main(): print(f"Starting buy_exact_in for token: {TOKEN_MINT_ADDRESS}") print(f"Amount to spend: {SOL_AMOUNT_TO_SPEND} SOL") print(f"Slippage tolerance: {SLIPPAGE_TOLERANCE * 100}%") - print(f"Using RPC endpoint: {RPC_ENDPOINT}") + # 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 ''}") print() async with AsyncClient(RPC_ENDPOINT) as client: diff --git a/learning-examples/letsbonk-buy-sell/manual_buy_exact_out.py b/learning-examples/letsbonk-buy-sell/manual_buy_exact_out.py index 5509626..708e91d 100644 --- a/learning-examples/letsbonk-buy-sell/manual_buy_exact_out.py +++ b/learning-examples/letsbonk-buy-sell/manual_buy_exact_out.py @@ -18,6 +18,7 @@ import asyncio import os import struct import sys +from urllib.parse import urlsplit import base58 from dotenv import load_dotenv @@ -34,14 +35,18 @@ from solders.system_program import CreateAccountWithSeedParams, create_account_w 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( - "YOUR_TOKEN_MINT_ADDRESS_HERE" -) # Replace with actual token mint address + 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") @@ -62,6 +67,10 @@ 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" ) @@ -510,9 +519,15 @@ async def buy_exact_out( # 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( - LETSBONK_PLATFORM_CONFIG, WSOL_MINT + platform_config, WSOL_MINT ) print(f"Creator fee vault: {creator_fee_vault}") @@ -569,7 +584,7 @@ async def buy_exact_out( pubkey=GLOBAL_CONFIG, is_signer=False, is_writable=False ), # global_config AccountMeta( - pubkey=LETSBONK_PLATFORM_CONFIG, is_signer=False, is_writable=False + pubkey=platform_config, is_signer=False, is_writable=False ), # platform_config AccountMeta( pubkey=pool_state, is_signer=False, is_writable=True @@ -667,6 +682,10 @@ async def buy_exact_out( 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( @@ -683,7 +702,7 @@ async def buy_exact_out( print(f"Transaction sent: https://solscan.io/tx/{tx_signature}") print("Waiting for confirmation...") - await client.confirm_transaction(tx_signature, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_signature) print("Transaction confirmed!") return tx_signature @@ -706,7 +725,9 @@ async def main(): print(f"Starting buy_exact_out for token: {TOKEN_MINT_ADDRESS}") print(f"Amount to receive: {TOKEN_AMOUNT_TO_RECEIVE:,} tokens") print(f"Slippage tolerance: {SLIPPAGE_TOLERANCE * 100}%") - print(f"Using RPC endpoint: {RPC_ENDPOINT}") + # 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 ''}") print() async with AsyncClient(RPC_ENDPOINT) as client: diff --git a/learning-examples/letsbonk-buy-sell/manual_sell_exact_in.py b/learning-examples/letsbonk-buy-sell/manual_sell_exact_in.py index 1a64a83..3868fec 100644 --- a/learning-examples/letsbonk-buy-sell/manual_sell_exact_in.py +++ b/learning-examples/letsbonk-buy-sell/manual_sell_exact_in.py @@ -18,6 +18,7 @@ import asyncio import os import struct import sys +from urllib.parse import urlsplit import base58 from dotenv import load_dotenv @@ -34,14 +35,18 @@ from solders.system_program import CreateAccountWithSeedParams, create_account_w 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( - "YOUR_TOKEN_MINT_ADDRESS_HERE" -) # Replace with actual token mint address + 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") @@ -62,6 +67,10 @@ 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" ) @@ -507,9 +516,15 @@ async def sell_exact_in( # 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( - LETSBONK_PLATFORM_CONFIG, WSOL_MINT + platform_config, WSOL_MINT ) print(f"Creator fee vault: {creator_fee_vault}") @@ -562,7 +577,7 @@ async def sell_exact_in( pubkey=GLOBAL_CONFIG, is_signer=False, is_writable=False ), # global_config AccountMeta( - pubkey=LETSBONK_PLATFORM_CONFIG, is_signer=False, is_writable=False + pubkey=platform_config, is_signer=False, is_writable=False ), # platform_config AccountMeta( pubkey=pool_state, is_signer=False, is_writable=True @@ -658,6 +673,10 @@ async def sell_exact_in( 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( @@ -674,7 +693,7 @@ async def sell_exact_in( print(f"Transaction sent: https://solscan.io/tx/{tx_signature}") print("Waiting for confirmation...") - await client.confirm_transaction(tx_signature, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_signature) print("Transaction confirmed!") return tx_signature @@ -697,7 +716,9 @@ async def main(): print(f"Starting sell_exact_in for token: {TOKEN_MINT_ADDRESS}") print(f"Amount to sell: {TOKEN_AMOUNT_TO_SELL:,} tokens") print(f"Slippage tolerance: {SLIPPAGE_TOLERANCE * 100}%") - print(f"Using RPC endpoint: {RPC_ENDPOINT}") + # 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 ''}") print() async with AsyncClient(RPC_ENDPOINT) as client: diff --git a/learning-examples/letsbonk-buy-sell/manual_sell_exact_out.py b/learning-examples/letsbonk-buy-sell/manual_sell_exact_out.py index d5f4912..8b6db69 100644 --- a/learning-examples/letsbonk-buy-sell/manual_sell_exact_out.py +++ b/learning-examples/letsbonk-buy-sell/manual_sell_exact_out.py @@ -18,6 +18,7 @@ import asyncio import os import struct import sys +from urllib.parse import urlsplit import base58 from dotenv import load_dotenv @@ -34,14 +35,18 @@ from solders.system_program import CreateAccountWithSeedParams, create_account_w 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( - "YOUR_TOKEN_MINT_ADDRESS_HERE" -) # Replace with actual token mint address + 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") @@ -62,6 +67,10 @@ 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" ) @@ -510,9 +519,15 @@ async def sell_exact_out( # 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( - LETSBONK_PLATFORM_CONFIG, WSOL_MINT + platform_config, WSOL_MINT ) print(f"Creator fee vault: {creator_fee_vault}") @@ -564,7 +579,7 @@ async def sell_exact_out( pubkey=GLOBAL_CONFIG, is_signer=False, is_writable=False ), # global_config AccountMeta( - pubkey=LETSBONK_PLATFORM_CONFIG, is_signer=False, is_writable=False + pubkey=platform_config, is_signer=False, is_writable=False ), # platform_config AccountMeta( pubkey=pool_state, is_signer=False, is_writable=True @@ -660,6 +675,10 @@ async def sell_exact_out( 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( @@ -676,7 +695,7 @@ async def sell_exact_out( print(f"Transaction sent: https://solscan.io/tx/{tx_signature}") print("Waiting for confirmation...") - await client.confirm_transaction(tx_signature, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_signature) print("Transaction confirmed!") return tx_signature @@ -699,7 +718,9 @@ async def main(): 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}%") - print(f"Using RPC endpoint: {RPC_ENDPOINT}") + # 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 ''}") print() async with AsyncClient(RPC_ENDPOINT) as client: diff --git a/learning-examples/listen-migrations/compare_migration_listeners.py b/learning-examples/listen-migrations/compare_migration_listeners.py index 96ec8be..6f5fbd0 100644 --- a/learning-examples/listen-migrations/compare_migration_listeners.py +++ b/learning-examples/listen-migrations/compare_migration_listeners.py @@ -31,6 +31,11 @@ from solders.pubkey import Pubkey load_dotenv() +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") MIGRATION_PROGRAM_ID = Pubkey.from_string( "39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg" @@ -462,7 +467,9 @@ async def listen_for_migrations(wss_url, provider_name, tracker, known_events=No while True: try: print(f"[INFO] Connecting migration listener to {provider_name}...") - async with websockets.connect(wss_url) as websocket: + async with websockets.connect( + wss_url, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: # Subscribe to logs mentioning the migration program subscription_message = json.dumps( { @@ -555,7 +562,9 @@ async def listen_for_markets(wss_url, provider_name, tracker, known_markets): while True: try: print(f"[INFO] Connecting market listener to {provider_name}...") - async with websockets.connect(wss_url) as websocket: + async with websockets.connect( + wss_url, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: # Subscribe to program account changes sub_msg = json.dumps( { diff --git a/learning-examples/listen-migrations/listen_blocksubscribe_old_raydium.py b/learning-examples/listen-migrations/listen_blocksubscribe_old_raydium.py index 1dd2b24..4ead3b1 100644 --- a/learning-examples/listen-migrations/listen_blocksubscribe_old_raydium.py +++ b/learning-examples/listen-migrations/listen_blocksubscribe_old_raydium.py @@ -9,6 +9,11 @@ from solders.pubkey import Pubkey load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 PUMP_MIGRATOR_ID = Pubkey.from_string("39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg") @@ -39,7 +44,9 @@ def process_initialize2_transaction(data): async def listen_for_events(): while True: try: - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-migrations/listen_logsubscribe.py b/learning-examples/listen-migrations/listen_logsubscribe.py index b35a57c..bb2e79a 100644 --- a/learning-examples/listen-migrations/listen_logsubscribe.py +++ b/learning-examples/listen-migrations/listen_logsubscribe.py @@ -23,6 +23,11 @@ from solders.pubkey import Pubkey load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 MIGRATION_PROGRAM_ID = Pubkey.from_string( "39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg" ) @@ -126,7 +131,9 @@ async def listen_for_migrations(): while True: try: print("\n[INFO] Connecting to WebSocket ...") - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-migrations/listen_programsubscribe.py b/learning-examples/listen-migrations/listen_programsubscribe.py index 2c5c13e..a0c00eb 100644 --- a/learning-examples/listen-migrations/listen_programsubscribe.py +++ b/learning-examples/listen-migrations/listen_programsubscribe.py @@ -21,6 +21,11 @@ from solders.pubkey import Pubkey load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA") @@ -119,7 +124,9 @@ async def listen_new_markets(): while True: try: print("[INFO] Connecting to WebSocket...") - async with websockets.connect(WSS_ENDPOINT) as ws: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as ws: sub_msg = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-new-tokens/compare_listeners.py b/learning-examples/listen-new-tokens/compare_listeners.py index 07c92b0..ed560dc 100644 --- a/learning-examples/listen-new-tokens/compare_listeners.py +++ b/learning-examples/listen-new-tokens/compare_listeners.py @@ -49,6 +49,11 @@ from solders.transaction import VersionedTransaction load_dotenv(override=True) +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + # ============ CONSTANTS ============ # Pump.fun program ID @@ -494,7 +499,9 @@ async def listen_block_subscription(wss_url, provider_name, tracker, known_token while True: try: print(f"[INFO] Connecting block listener to {provider_name}...") - async with websockets.connect(wss_url) as websocket: + async with websockets.connect( + wss_url, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", @@ -631,7 +638,9 @@ async def listen_logs_subscription(wss_url, provider_name, tracker, known_tokens while True: try: print(f"[INFO] Connecting logs listener to {provider_name}...") - async with websockets.connect(wss_url) as websocket: + async with websockets.connect( + wss_url, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-new-tokens/listen_blocksubscribe.py b/learning-examples/listen-new-tokens/listen_blocksubscribe.py index f205276..f85fe5f 100644 --- a/learning-examples/listen-new-tokens/listen_blocksubscribe.py +++ b/learning-examples/listen-new-tokens/listen_blocksubscribe.py @@ -30,6 +30,11 @@ from solders.transaction import VersionedTransaction load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 PUMP_PROGRAM_ID = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P") # Instruction discriminators (8-byte identifiers for instruction types) @@ -250,7 +255,9 @@ async def listen_and_decode_create(): """ idl = load_idl("idl/pump_fun_idl.json") - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-new-tokens/listen_logsubscribe.py b/learning-examples/listen-new-tokens/listen_logsubscribe.py index 9a353bf..65007fd 100644 --- a/learning-examples/listen-new-tokens/listen_logsubscribe.py +++ b/learning-examples/listen-new-tokens/listen_logsubscribe.py @@ -29,6 +29,11 @@ from solders.pubkey import Pubkey load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 PUMP_PROGRAM_ID = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P") # Event discriminator for CreateEvent (8-byte identifier) @@ -165,7 +170,9 @@ def parse_create_v2_instruction(data): async def listen_for_new_tokens(): while True: try: - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-new-tokens/listen_logsubscribe_abc.py b/learning-examples/listen-new-tokens/listen_logsubscribe_abc.py index b775883..29afec3 100644 --- a/learning-examples/listen-new-tokens/listen_logsubscribe_abc.py +++ b/learning-examples/listen-new-tokens/listen_logsubscribe_abc.py @@ -30,6 +30,11 @@ from solders.pubkey import Pubkey load_dotenv() WSS_ENDPOINT = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") + +# Solana's blockSubscribe (and a busy logsSubscribe) sends frames well past +# websockets' 1 MiB default, which kills the connection with a 1009 close +# instead of delivering the message. Same value the bot's own listeners use. +WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 PUMP_PROGRAM_ID = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P") TOKEN_PROGRAM_ID = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string( @@ -183,7 +188,9 @@ def parse_create_v2_instruction(data): async def listen_for_new_tokens(): while True: try: - async with websockets.connect(WSS_ENDPOINT) as websocket: + async with websockets.connect( + WSS_ENDPOINT, max_size=WEBSOCKET_MAX_MESSAGE_BYTES + ) as websocket: subscription_message = json.dumps( { "jsonrpc": "2.0", diff --git a/learning-examples/listen-new-tokens/listen_pumpportal.py b/learning-examples/listen-new-tokens/listen_pumpportal.py index 265fc66..7f6ce66 100644 --- a/learning-examples/listen-new-tokens/listen_pumpportal.py +++ b/learning-examples/listen-new-tokens/listen_pumpportal.py @@ -16,7 +16,6 @@ For trustless monitoring, use the direct blockchain listeners (logs, block, geys import asyncio import json -from datetime import datetime import websockets diff --git a/learning-examples/live_v2_round_trip.py b/learning-examples/live_v2_round_trip.py index e975857..57a1998 100644 --- a/learning-examples/live_v2_round_trip.py +++ b/learning-examples/live_v2_round_trip.py @@ -98,7 +98,13 @@ async def main() -> int: ) try: - start_lamports = (await client.get_account_info(wallet.pubkey)).lamports + # Both balance reads must use the same commitment the trades are + # confirmed at. solana-py defaults to finalized, which lags behind + # "confirmed" by enough that the end read still sees pre-trade state and + # the reported net change comes out as exactly zero. + start_lamports = ( + await client.get_account_info(wallet.pubkey, commitment="confirmed") + ).lamports print(f"wallet: {wallet.pubkey}") print(f"start balance: {start_lamports / LAMPORTS_PER_SOL:.9f} SOL\n") @@ -142,7 +148,9 @@ async def main() -> int: if not sell.success: print(f"error: {sell.error_message}") - end_lamports = (await client.get_account_info(wallet.pubkey)).lamports + end_lamports = ( + await client.get_account_info(wallet.pubkey, commitment="confirmed") + ).lamports delta = (end_lamports - start_lamports) / LAMPORTS_PER_SOL print(f"\nend balance: {end_lamports / LAMPORTS_PER_SOL:.9f} SOL") print(f"net change: {delta:+.9f} SOL") diff --git a/learning-examples/manual_buy.py b/learning-examples/manual_buy.py index 9a3380d..81a5cff 100644 --- a/learning-examples/manual_buy.py +++ b/learning-examples/manual_buy.py @@ -7,7 +7,9 @@ import struct import base58 import pump_v2 +import tx_status import websockets +from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed from solana.rpc.types import TxOpts @@ -43,6 +45,8 @@ LAMPORTS_PER_SOL = 1_000_000_000 # RPC ENDPOINTS +load_dotenv() + RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") @@ -173,11 +177,15 @@ async def buy_token( ) tx_hash = tx_buy.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction( - tx_hash, commitment="confirmed", sleep_seconds=1 - ) + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return # Success, exit the function + except tx_status.TransactionRevertedError as e: + # The signature is already on chain and reverted. The message and + # blockhash below are fixed, so a retry would resubmit identical + # bytes and revert identically — stop instead of burning attempts. + print(f"Transaction reverted on-chain, not retrying: {e}") + return except Exception as e: print(f"Attempt {attempt + 1} failed: {str(e)[:50]}") if attempt < max_retries - 1: diff --git a/learning-examples/manual_buy_cu_optimized.py b/learning-examples/manual_buy_cu_optimized.py index 6aad16d..a4f40b1 100644 --- a/learning-examples/manual_buy_cu_optimized.py +++ b/learning-examples/manual_buy_cu_optimized.py @@ -26,7 +26,9 @@ import struct import base58 import pump_v2 +import tx_status import websockets +from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed from solana.rpc.types import TxOpts @@ -78,6 +80,8 @@ COMPUTE_BUDGET_PROGRAM = Pubkey.from_string( "ComputeBudget111111111111111111111111111111" ) +load_dotenv() + RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT") @@ -294,11 +298,15 @@ async def buy_token( ) tx_hash = tx_buy.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction( - tx_hash, commitment="confirmed", sleep_seconds=1 - ) + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return # Success, exit the function + except tx_status.TransactionRevertedError as e: + # The signature is already on chain and reverted. The message and + # blockhash below are fixed, so a retry would resubmit identical + # bytes and revert identically — stop instead of burning attempts. + print(f"Transaction reverted on-chain, not retrying: {e}") + return except Exception as e: print(f"Attempt {attempt + 1} failed: {str(e)[:50]}") if attempt < max_retries - 1: diff --git a/learning-examples/manual_buy_geyser.py b/learning-examples/manual_buy_geyser.py index 1752c22..a63557f 100644 --- a/learning-examples/manual_buy_geyser.py +++ b/learning-examples/manual_buy_geyser.py @@ -7,6 +7,8 @@ import sys import base58 import grpc import pump_v2 +import tx_status +from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed from solana.rpc.types import TxOpts @@ -63,6 +65,8 @@ SOL = Pubkey.from_string("So11111111111111111111111111111111111111112") LAMPORTS_PER_SOL = 1_000_000_000 # RPC ENDPOINTS +load_dotenv() + RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") # Geyser endpoints GEYSER_ENDPOINT = os.environ.get("GEYSER_ENDPOINT") @@ -391,11 +395,15 @@ async def buy_token( ) tx_hash = tx_buy.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction( - tx_hash, commitment="confirmed", sleep_seconds=1 - ) + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return # Success, exit the function + except tx_status.TransactionRevertedError as e: + # The signature is already on chain and reverted. The message and + # blockhash below are fixed, so a retry would resubmit identical + # bytes and revert identically — stop instead of burning attempts. + print(f"Transaction reverted on-chain, not retrying: {e}") + return except Exception as e: print(f"Attempt {attempt + 1} failed: {str(e)[:50]}") if attempt < max_retries - 1: diff --git a/learning-examples/manual_sell.py b/learning-examples/manual_sell.py index 2c16a9a..15dd861 100644 --- a/learning-examples/manual_sell.py +++ b/learning-examples/manual_sell.py @@ -4,6 +4,8 @@ import sys import base58 import pump_v2 +import tx_status +from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed from solana.rpc.types import TxOpts @@ -44,6 +46,8 @@ LAMPORTS_PER_SOL = 1_000_000_000 UNIT_PRICE = 10_000_000 UNIT_BUDGET = 100_000 +load_dotenv() + RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") @@ -221,11 +225,15 @@ async def sell_token( ) tx_hash = tx.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction( - tx_hash, commitment="confirmed", sleep_seconds=1 - ) + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return # Success, exit the function + except tx_status.TransactionRevertedError as e: + # The signature is already on chain and reverted. The message and + # blockhash below are fixed, so a retry would resubmit identical + # bytes and revert identically — stop instead of burning attempts. + print(f"Transaction reverted on-chain, not retrying: {e}") + return except Exception as e: print(f"Attempt {attempt + 1} failed: {e!s}") if attempt < max_retries - 1: diff --git a/learning-examples/mint_and_buy.py b/learning-examples/mint_and_buy.py index c65600d..fe7824a 100644 --- a/learning-examples/mint_and_buy.py +++ b/learning-examples/mint_and_buy.py @@ -5,6 +5,7 @@ from typing import Final import base58 import pump_v2 +import tx_status from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed @@ -166,32 +167,6 @@ def _find_bonding_curve_v2(mint: Pubkey) -> Pubkey: return derived_address -async def assert_transaction_succeeded(client: AsyncClient, signature) -> None: - """Raise if a confirmed transaction actually failed on-chain. - - `confirm_transaction` only waits for the transaction to land — a landed - transaction can still have reverted. Without this check a failed buy prints - as a success, which is exactly how a wrong fee recipient (NotAuthorized, - 6000) can look like a passing test. - - Args: - client: Solana RPC client - signature: Transaction signature to inspect - - Raises: - RuntimeError: If the transaction reverted - """ - result = await client.get_transaction( - signature, commitment="confirmed", max_supported_transaction_version=0 - ) - value = result.value - if value is None: - raise RuntimeError(f"Transaction {signature} not found after confirmation") - err = value.transaction.meta.err if value.transaction.meta else None - if err: - raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}") - - def create_pump_create_instruction( mint: Pubkey, mint_authority: Pubkey, @@ -351,7 +326,6 @@ async def main(): # Initial virtual reserves (from pump.fun constants) initial_virtual_token_reserves = 1_073_000_000 * 10**TOKEN_DECIMALS initial_virtual_sol_reserves = 30 * LAMPORTS_PER_SOL - initial_real_token_reserves = 793_100_000 * 10**TOKEN_DECIMALS initial_price = initial_virtual_sol_reserves / initial_virtual_token_reserves @@ -436,7 +410,7 @@ async def main(): print("Waiting for confirmation...") await client.confirm_transaction(tx_hash, commitment="confirmed") - await assert_transaction_succeeded(client, tx_hash) + await tx_status.assert_transaction_succeeded(client, tx_hash) print("Create confirmed!") buy_blockhash = await client.get_latest_blockhash() @@ -450,7 +424,7 @@ async def main(): buy_hash = buy_response.value print(f"Buy sent: https://solscan.io/tx/{buy_hash}") await client.confirm_transaction(buy_hash, commitment="confirmed") - await assert_transaction_succeeded(client, buy_hash) + await tx_status.assert_transaction_succeeded(client, buy_hash) print("Buy confirmed!") return tx_hash diff --git a/learning-examples/mint_and_buy_v2.py b/learning-examples/mint_and_buy_v2.py index 3fe9e2c..4291c33 100644 --- a/learning-examples/mint_and_buy_v2.py +++ b/learning-examples/mint_and_buy_v2.py @@ -5,6 +5,7 @@ from typing import Final import base58 import pump_v2 +import tx_status from dotenv import load_dotenv from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Confirmed @@ -283,32 +284,6 @@ def create_extend_account_instruction( return Instruction(PUMP_PROGRAM, data, accounts) -async def assert_transaction_succeeded(client: AsyncClient, signature) -> None: - """Raise if a confirmed transaction actually failed on-chain. - - `confirm_transaction` only waits for the transaction to land — a landed - transaction can still have reverted. Without this check a failed buy prints - as a success, which is exactly how a wrong fee recipient (NotAuthorized, - 6000) can look like a passing test. - - Args: - client: Solana RPC client - signature: Transaction signature to inspect - - Raises: - RuntimeError: If the transaction reverted - """ - result = await client.get_transaction( - signature, commitment="confirmed", max_supported_transaction_version=0 - ) - value = result.value - if value is None: - raise RuntimeError(f"Transaction {signature} not found after confirmation") - err = value.transaction.meta.err if value.transaction.meta else None - if err: - raise RuntimeError(f"Transaction {signature} landed but failed on-chain: {err}") - - def create_buy_instruction( global_state: Pubkey, fee_recipient: Pubkey, @@ -438,7 +413,6 @@ async def main(): # Initial virtual reserves (from pump.fun constants) initial_virtual_token_reserves = 1_073_000_000 * 10**TOKEN_DECIMALS initial_virtual_sol_reserves = 30 * LAMPORTS_PER_SOL - initial_real_token_reserves = 793_100_000 * 10**TOKEN_DECIMALS initial_price = initial_virtual_sol_reserves / initial_virtual_token_reserves @@ -525,7 +499,7 @@ async def main(): print(f"Create sent: https://solscan.io/tx/{tx_hash}") print("Waiting for confirmation...") await client.confirm_transaction(tx_hash, commitment="confirmed") - await assert_transaction_succeeded(client, tx_hash) + await tx_status.assert_transaction_succeeded(client, tx_hash) print("Create confirmed!") buy_blockhash = await client.get_latest_blockhash() @@ -539,7 +513,7 @@ async def main(): buy_hash = buy_response.value print(f"Buy sent: https://solscan.io/tx/{buy_hash}") await client.confirm_transaction(buy_hash, commitment="confirmed") - await assert_transaction_succeeded(client, buy_hash) + await tx_status.assert_transaction_succeeded(client, buy_hash) print("Buy confirmed!") return tx_hash diff --git a/learning-examples/pumpswap/manual_buy_pumpswap.py b/learning-examples/pumpswap/manual_buy_pumpswap.py index 4339d26..695eb7f 100644 --- a/learning-examples/pumpswap/manual_buy_pumpswap.py +++ b/learning-examples/pumpswap/manual_buy_pumpswap.py @@ -17,6 +17,7 @@ import asyncio import os import random import struct +import sys import base58 from dotenv import load_dotenv @@ -37,6 +38,10 @@ from spl.token.instructions import ( sync_native, ) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tx_status # noqa: E402 + load_dotenv() # ============================================================================ @@ -44,7 +49,6 @@ load_dotenv() # ============================================================================ RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") -import sys TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1] PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY")) @@ -422,7 +426,9 @@ async def calculate_token_pool_price( Returns: Price in quote asset per token """ - base_balance_resp = await client.get_token_account_balance(pool_base_token_account) + base_balance_resp = await client.get_token_account_balance( + pool_base_token_account, commitment=Confirmed + ) quote_balance_resp = await client.get_token_account_balance( pool_quote_token_account ) @@ -697,7 +703,7 @@ async def buy_pump_swap( tx_hash = tx_sig.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction(tx_hash, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return tx_hash except Exception as e: diff --git a/learning-examples/pumpswap/manual_sell_pumpswap.py b/learning-examples/pumpswap/manual_sell_pumpswap.py index 2a44b36..ecb08fd 100644 --- a/learning-examples/pumpswap/manual_sell_pumpswap.py +++ b/learning-examples/pumpswap/manual_sell_pumpswap.py @@ -15,6 +15,7 @@ import asyncio import os import random import struct +import sys import base58 from dotenv import load_dotenv @@ -29,6 +30,10 @@ from solders.pubkey import Pubkey from solders.transaction import VersionedTransaction from spl.token.instructions import get_associated_token_address +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tx_status # noqa: E402 + load_dotenv() # ============================================================================ @@ -36,7 +41,6 @@ load_dotenv() # ============================================================================ RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") -import sys TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1] PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY")) @@ -386,7 +390,9 @@ async def calculate_token_pool_price( Returns: Price in quote asset per token """ - base_balance_resp = await client.get_token_account_balance(pool_base_token_account) + base_balance_resp = await client.get_token_account_balance( + pool_base_token_account, commitment=Confirmed + ) quote_balance_resp = await client.get_token_account_balance( pool_quote_token_account ) @@ -499,8 +505,16 @@ async def sell_pump_swap( Returns: Transaction signature if successful, None otherwise """ + # Read at "confirmed", not solana-py's "finalized" default: an ATA created + # by a buy moments earlier does not exist at finalized yet ("could not find + # account"), and a post-sell balance still reads pre-sell, so the next + # transfer reverts with insufficient funds. token_balance = int( - (await client.get_token_account_balance(user_base_token_account)).value.amount + ( + await client.get_token_account_balance( + user_base_token_account, commitment=Confirmed + ) + ).value.amount ) token_balance_decimal = token_balance / 10**TOKEN_DECIMALS @@ -642,7 +656,7 @@ async def sell_pump_swap( tx_hash = tx_sig.value print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}") - await client.confirm_transaction(tx_hash, commitment="confirmed") + await tx_status.confirm_and_assert(client, tx_hash) print("Transaction confirmed") return tx_hash except Exception as e: diff --git a/learning-examples/pumpswap/sample_cashback_pumpswap.py b/learning-examples/pumpswap/sample_cashback_pumpswap.py deleted file mode 100644 index 7323bf4..0000000 --- a/learning-examples/pumpswap/sample_cashback_pumpswap.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Post-cutover sampler: find a successful cashback PumpSwap buy/sell on mainnet. - -Goal: identify the seed/position of the +1 account that the program requires for -cashback pools (27-account buy / 26-account sell vs 26 / 24 non-cashback). - -Strategy: -1. Pull recent signatures for pAMM program. -2. For each tx, fetch full tx, find the pAMM buy/sell ix. -3. Resolve the pool account from the ix; fetch its data; check byte 244 - (is_cashback_coin) — only proceed if it's 1. -4. Print the full account list with counts so we can diff against the known - 26/24 non-cashback layout in manual_buy/sell_pumpswap.py. - -Usage: - uv run learning-examples/pumpswap/sample_cashback_pumpswap.py [LIMIT] - -Env: SOLANA_NODE_RPC_ENDPOINT (defaults to public mainnet) -""" - -import asyncio -import os -import sys - -from solana.rpc.async_api import AsyncClient -from solders.pubkey import Pubkey -from solders.signature import Signature - -PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA") -BUY_DISCRIMINATOR = bytes.fromhex("66063d1201daebea") -SELL_DISCRIMINATOR = bytes.fromhex("33e685a4017f83ad") - -# Pool layout: byte 244 = is_cashback_coin (per CLAUDE.md / PR #167 notes). -POOL_IS_CASHBACK_OFFSET = 244 - -RPC = os.environ.get( - "SOLANA_NODE_RPC_ENDPOINT", "https://api.mainnet-beta.solana.com" -) - - -async def is_cashback_pool(client: AsyncClient, pool: Pubkey) -> bool | None: - resp = await client.get_account_info(pool, encoding="base64") - if resp.value is None: - return None - data = resp.value.data - if len(data) <= POOL_IS_CASHBACK_OFFSET: - return False - return data[POOL_IS_CASHBACK_OFFSET] == 1 - - -def classify_ix(ix_data: bytes) -> str | None: - if ix_data.startswith(BUY_DISCRIMINATOR): - return "buy" - if ix_data.startswith(SELL_DISCRIMINATOR): - return "sell" - return None - - -async def inspect_tx(client: AsyncClient, sig: Signature) -> dict | None: - """Return diagnostic dict if this tx contains a cashback buy/sell.""" - resp = await client.get_transaction( - sig, encoding="base64", max_supported_transaction_version=0 - ) - if resp.value is None or resp.value.transaction.meta is None: - return None - if resp.value.transaction.meta.err is not None: - return None # only successful txs - - tx = resp.value.transaction.transaction - msg = tx.message - account_keys = list(msg.account_keys) - # include loaded addresses from ALTs - loaded = resp.value.transaction.meta.loaded_addresses - if loaded is not None: - account_keys.extend(loaded.writable) - account_keys.extend(loaded.readonly) - - for ix in msg.instructions: - program_id = account_keys[ix.program_id_index] - if program_id != PUMP_AMM_PROGRAM_ID: - continue - kind = classify_ix(bytes(ix.data)) - if kind is None: - continue - - # PumpSwap convention: account index 0 of the ix is the pool. - if not ix.accounts: - continue - pool = account_keys[ix.accounts[0]] - cashback = await is_cashback_pool(client, pool) - if not cashback: - continue - - return { - "sig": str(sig), - "kind": kind, - "pool": str(pool), - "n_accounts": len(ix.accounts), - "accounts": [str(account_keys[i]) for i in ix.accounts], - } - return None - - -async def main(limit: int = 200) -> None: - async with AsyncClient(RPC) as client: - print(f"Sampling up to {limit} recent pAMM signatures from {RPC}") - sigs_resp = await client.get_signatures_for_address( - PUMP_AMM_PROGRAM_ID, limit=limit - ) - sigs = [s.signature for s in sigs_resp.value if s.err is None] - print(f" got {len(sigs)} successful signatures") - - for sig in sigs: - try: - hit = await inspect_tx(client, sig) - except (ValueError, RuntimeError) as e: - print(f" [skip] {sig}: {e}") - continue - if hit is None: - continue - print() - print(f"=== CASHBACK {hit['kind'].upper()} ===") - print(f" sig: {hit['sig']}") - print(f" pool: {hit['pool']}") - print(f" count: {hit['n_accounts']} accounts") - for i, a in enumerate(hit["accounts"]): - print(f" [{i:2d}] {a}") - return - - print("No cashback PumpSwap buy/sell found in window.") - - -if __name__ == "__main__": - n = int(sys.argv[1]) if len(sys.argv) > 1 else 200 - asyncio.run(main(n)) diff --git a/learning-examples/tx_status.py b/learning-examples/tx_status.py new file mode 100644 index 0000000..724a80e --- /dev/null +++ b/learning-examples/tx_status.py @@ -0,0 +1,100 @@ +"""Transaction status checks shared by the learning examples. + +`AsyncClient.confirm_transaction` answers one question: did this signature land +in a block? It says nothing about whether the transaction succeeded. A landed +transaction can have reverted, and RPC reports that only in `meta.err`. + +Skipping the `meta.err` read is how a broken trade path looks healthy: the script +prints "Transaction confirmed", returns a signature, and the wallet balance never +moves. That was issue #175 — buys reverting with `BuybackFeeRecipientMissing` +(6062) reported as successful buys. + +Deliberately standalone: imports nothing from `src/`, so every example under +`learning-examples/` (including the subdirectories) can use it. + +`learning-examples/verify_tx_status_checks.py` verifies the behaviour below. +""" + +from typing import Any, Protocol + + +class TransactionRevertedError(RuntimeError): + """The transaction landed in a block but reverted. + + Terminal: the signature is already on chain, so resubmitting the same signed + bytes cannot change the outcome. Callers with a retry loop should stop rather + than spend attempts on it. + """ + + +class _TransactionFetcher(Protocol): + """The slice of `AsyncClient` these helpers need.""" + + async def get_transaction(self, *args: Any, **kwargs: Any) -> Any: ... + + +async def assert_transaction_succeeded( + client: _TransactionFetcher, signature: Any, commitment: str = "confirmed" +) -> None: + """Raise if a landed transaction actually reverted on-chain. + + Call this after `confirm_transaction` and before reporting success. + + Args: + client: Solana RPC client + signature: Transaction signature to inspect + commitment: Commitment to read the transaction at. Must be at least as + strong as the one it was confirmed at, or this can pass before the + confirmation the caller asked for. + + Raises: + TransactionRevertedError: If the transaction landed but reverted + RuntimeError: If it cannot be found or carries no execution metadata + """ + result = await client.get_transaction( + signature, commitment=commitment, max_supported_transaction_version=0 + ) + value = result.value + if value is None: + raise RuntimeError(f"Transaction {signature} not found after confirmation") + # No metadata means the outcome is unknown, not that it succeeded. Treating a + # missing meta as "no error" is the exact mistake this module exists to + # prevent, so fail closed. + meta = value.transaction.meta + if meta is None: + raise RuntimeError( + f"Transaction {signature} returned without execution metadata; " + f"cannot tell whether it succeeded" + ) + if meta.err is not None: + raise TransactionRevertedError( + f"Transaction {signature} landed but failed on-chain: {meta.err}" + ) + + +async def confirm_and_assert( + client: Any, + signature: Any, + commitment: str = "confirmed", + sleep_seconds: float = 1, +) -> None: + """Wait for a transaction to land, then verify it succeeded. + + The one-call replacement for `await client.confirm_transaction(...)` in a + trade path — there is no reason for an example to do the first half without + the second. + + Args: + client: Solana RPC client + signature: Transaction signature to confirm + commitment: Confirmation commitment level + sleep_seconds: Poll interval while waiting for the signature to land + + Raises: + TransactionRevertedError: If the transaction landed but reverted + RuntimeError: If it cannot be found or carries no execution metadata + """ + await client.confirm_transaction( + signature, commitment=commitment, sleep_seconds=sleep_seconds + ) + await assert_transaction_succeeded(client, signature, commitment=commitment) diff --git a/learning-examples/verify_tx_status_checks.py b/learning-examples/verify_tx_status_checks.py new file mode 100644 index 0000000..9267762 --- /dev/null +++ b/learning-examples/verify_tx_status_checks.py @@ -0,0 +1,492 @@ +"""Verify that a landed-but-reverted transaction is never reported as a success. + +`AsyncClient.confirm_transaction` only waits for a signature to land in a block. +A landed transaction can still have reverted, so every trade path has to read +`meta.err` before it prints or returns success. Skipping that check is how issue +#175 happened: buys reverting with `BuybackFeeRecipientMissing` (6062) were +reported as confirmed, and the only way to notice was to inspect the signatures +by hand. + +Two layers are checked: + +* `tx_status.assert_transaction_succeeded` — the helper the learning examples use +* `SolanaClient.verify_transaction_succeeded` — the check the bot itself runs + +Offline stub checks run always. Pass `--live` to additionally replay the three +reverted signatures from issue #175 against mainnet: both layers must fetch them +successfully and reject them on the strength of `meta.err`, not because the fetch +failed. + +Usage: + uv run learning-examples/verify_tx_status_checks.py + uv run learning-examples/verify_tx_status_checks.py --live +""" + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "learning-examples")) +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +import tx_status # noqa: E402 + +from core.client import SolanaClient # noqa: E402 + +# Signatures from issue #175: reported as confirmed buys, actually reverted with +# BuybackFeeRecipientMissing (6062). They are permanent mainnet history, so they +# make a stable regression fixture for "landed but failed". +REVERTED_SIGNATURES = ( + "2bHRaovWyYNTX3K1KfMbMBSvefuT34ZExL2J2N83DDh9aB3Fynjyae5bcaCdRuQoihHAMHK4PzcRYoqc9hYypWb2", + "2Quu1uNZB7oSFstpKHv8KR1aKweizyvGaoGeXXKFLbMro4aqWykqZoeq7hSrHVZ8xmi6GX9J5nQVC5Ut3VV88S7T", + "4Bu9LrFK7QmUiCuLcjvejedJZRLfpKPZDiZgNqJRmw2EFjAAutmukGTErjzzJuA68emxpPQ8Pztu3NLkYhuvXKsd", +) + + +class _FakeMeta: + def __init__(self, err: object) -> None: + self.err = err + + +class _FakeTransaction: + def __init__(self, meta: _FakeMeta | None) -> None: + self.meta = meta + + +class _FakeValue: + def __init__(self, transaction: _FakeTransaction) -> None: + self.transaction = transaction + + +class _FakeResponse: + def __init__(self, value: _FakeValue | None) -> None: + self.value = value + + +class _StubClient: + """Minimal stand-in for `AsyncClient.get_transaction`.""" + + def __init__(self, response: _FakeResponse) -> None: + self._response = response + + async def get_transaction(self, *_args: Any, **_kwargs: Any) -> _FakeResponse: + return self._response + + +def _reverted_response() -> _FakeResponse: + # Shape of a real revert: {"InstructionError": [2, {"Custom": 6062}]} + err = {"InstructionError": [2, {"Custom": 6062}]} + return _FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(err)))) + + +async def check_helper_rejects_revert() -> None: + client = _StubClient(_reverted_response()) + try: + await tx_status.assert_transaction_succeeded(client, "SIG") + except RuntimeError as exc: + assert "6062" in str(exc), f"error should name the program error: {exc}" + return + raise AssertionError("assert_transaction_succeeded accepted a reverted transaction") + + +async def check_helper_rejects_missing() -> None: + client = _StubClient(_FakeResponse(None)) + try: + await tx_status.assert_transaction_succeeded(client, "SIG") + except RuntimeError: + return + raise AssertionError("assert_transaction_succeeded accepted a missing transaction") + + +async def check_helper_accepts_success() -> None: + client = _StubClient(_FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(None))))) + await tx_status.assert_transaction_succeeded(client, "SIG") + + +async def check_confirm_wrapper_rejects_revert() -> None: + """`confirm_and_assert` must surface a revert, not just a landing.""" + confirmed: list[str] = [] + + class _ConfirmStub(_StubClient): + async def confirm_transaction(self, signature: str, **_kwargs: Any) -> None: + confirmed.append(signature) + + client = _ConfirmStub(_reverted_response()) + try: + await tx_status.confirm_and_assert(client, "SIG") + except RuntimeError: + assert confirmed == ["SIG"], "confirm_transaction should still be awaited" + return + raise AssertionError("confirm_and_assert accepted a reverted transaction") + + +async def check_examples_call_a_status_check() -> None: + """Every example that confirms a trade must also verify it succeeded. + + Guards against a new example (or an edit to an existing one) reintroducing a + bare `confirm_transaction` that prints success unconditionally. + """ + examples_dir = PROJECT_ROOT / "learning-examples" + # Files that confirm transactions without calling the helper, each for a + # reason. Adding an entry here is a deliberate act; forgetting the check in a + # new example is not. + exempt = { + # defines the helper + "tx_status.py", + # this file + Path(__file__).name, + # stubs confirm_transaction out; never sends a transaction + "simulate_bot_buy_path.py", + # uses the bot's SolanaClient wrapper, which folds meta.err into its + # return value; the boolean is read at the call site + "cleanup_accounts.py", + } + offenders = [] + for path in sorted(examples_dir.rglob("*.py")): + if path.name in exempt or "__pycache__" in path.parts: + continue + source = path.read_text() + if "confirm_transaction" not in source: + continue + if not ( + "assert_transaction_succeeded" in source or "confirm_and_assert" in source + ): + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert not offenders, "examples confirm without checking meta.err: " + ", ".join( + offenders + ) + + +async def check_helper_rejects_missing_meta() -> None: + """Absent execution metadata must fail closed, not read as success. + + `meta=None` means the outcome is unknown. Folding that into "no error" is the + exact mistake this module exists to prevent. + """ + client = _StubClient(_FakeResponse(_FakeValue(_FakeTransaction(None)))) + try: + await tx_status.assert_transaction_succeeded(client, "SIG") + except RuntimeError as exc: + assert "metadata" in str(exc), f"unclear reason: {exc}" + return + raise AssertionError("a transaction with no execution metadata was accepted") + + +async def check_revert_is_a_distinct_terminal_error() -> None: + """A landed revert must be distinguishable from a transient failure. + + The retry loops in the manual buy/sell examples resubmit identical signed + bytes, so a revert can never be repaired by retrying — they need to tell it + apart from a fetch error. + """ + client = _StubClient(_reverted_response()) + try: + await tx_status.assert_transaction_succeeded(client, "SIG") + except tx_status.TransactionRevertedError: + pass + else: + raise AssertionError("revert did not raise TransactionRevertedError") + + assert issubclass(tx_status.TransactionRevertedError, RuntimeError), ( + "TransactionRevertedError must stay a RuntimeError so existing " + "except RuntimeError handlers keep working" + ) + + # not-found is transient, so it must NOT be the terminal type + missing = _StubClient(_FakeResponse(None)) + try: + await tx_status.assert_transaction_succeeded(missing, "SIG") + except tx_status.TransactionRevertedError: + raise AssertionError("not-found was reported as a terminal revert") from None + except RuntimeError: + pass + + +async def check_commitment_is_propagated() -> None: + """The status read must use the commitment the caller asked for. + + Confirming at "finalized" but reading status at "confirmed" reports success + before the finalization the caller requested. + """ + seen: dict[str, Any] = {} + + class _Recorder(_StubClient): + async def get_transaction(self, *_args: Any, **kwargs: Any) -> _FakeResponse: + seen["commitment"] = kwargs.get("commitment") + return self._response + + async def confirm_transaction(self, *_args: Any, **_kwargs: Any) -> None: + return None + + client = _Recorder(_FakeResponse(_FakeValue(_FakeTransaction(_FakeMeta(None))))) + await tx_status.confirm_and_assert(client, "SIG", commitment="finalized") + assert seen["commitment"] == "finalized", ( + f"status read at {seen['commitment']!r}, not the requested 'finalized'" + ) + + +async def check_endpoint_logging_hides_userinfo() -> None: + """Endpoint logging must use hostname, not netloc. + + netloc keeps any `user:pass@` userinfo, so redacting with it still prints the + credential for providers that put the key there. + """ + offenders = [] + for path in sorted((PROJECT_ROOT / "learning-examples").rglob("*.py")): + if "__pycache__" in path.parts or path.name == Path(__file__).name: + continue + for n, line in enumerate(path.read_text().splitlines(), 1): + if "urlsplit" in line and ".netloc" in line: + offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{n}") + assert not offenders, "urlsplit(...).netloc leaks userinfo at: " + ", ".join( + offenders + ) + + +async def check_bot_reads_the_confirmation_result() -> None: + """No caller in `src/` may throw away `confirm_transaction`'s boolean. + + Inside `src/` the only client is `SolanaClient`, whose return value carries + the `meta.err` verdict. Calling it as a bare statement discards that verdict + and whatever gets logged next is unconditional — which is how cleanup + reported "Closed successfully" for reverted close transactions. + """ + import ast + + offenders = [] + for path in sorted((PROJECT_ROOT / "src").rglob("*.py")): + if "__pycache__" in path.parts: + continue + # client.py holds the wrapper itself; its inner call is solana-py's, + # which signals failure by raising rather than by returning False. + if path.name == "client.py" and path.parent.name == "core": + continue + for node in ast.walk(ast.parse(path.read_text())): + if not isinstance(node, ast.Expr): + continue + call = node.value.value if isinstance(node.value, ast.Await) else node.value + if ( + isinstance(call, ast.Call) + and getattr(call.func, "attr", None) == "confirm_transaction" + ): + offenders.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}") + + assert not offenders, "confirm_transaction result discarded at: " + ", ".join( + offenders + ) + + +async def check_client_accepts_both_signature_types() -> None: + """`SolanaClient` must handle a base58 string and a `Signature` alike. + + The RPC client rejects a string and `json.dumps` rejects a `Signature`, so + whichever form a caller has, one of the two layers used to break. Both + failures surfaced as "not confirmed" for a transaction that in fact landed. + """ + from solders.signature import Signature + + sig_str = REVERTED_SIGNATURES[0] + sig_obj = Signature.from_string(sig_str) + + client = SolanaClient("http://127.0.0.1:1") # never contacted + bodies: list[dict[str, Any]] = [] + + async def capture_rpc(body: dict[str, Any], **_kwargs: Any) -> None: + bodies.append(body) + + client.post_rpc = capture_rpc + + for form in (sig_str, sig_obj): + bodies.clear() + await client._get_transaction_result(form) # noqa: SLF001 + assert len(bodies) == 1, f"no RPC issued for {type(form).__name__}" + param = bodies[0]["params"][0] + assert param == sig_str, f"signature not normalized: {param!r}" + # aiohttp serializes the body with json.dumps; a Signature would raise. + json.dumps(bodies[0]) + + # Malformed strings must be reported, not raised through. + assert not await client.confirm_transaction("not-a-signature") + await client.close() + + +async def check_rpc_timeouts_are_retried_not_raised() -> None: + """An RPC timeout must be retried and reported, never raised at the caller. + + aiohttp signals a request timeout with `asyncio.TimeoutError`, which is not + an `aiohttp.ClientError`. While `post_rpc` caught only the latter, every + timeout escaped un-retried — and `str()` on it is empty, so callers logged a + blank reason. This is what broke three of the four listeners in + live_listener_matrix mid-run. + """ + client = SolanaClient("http://127.0.0.1:1") + attempts = 0 + + class _TimingOutSession: + def post(self, *_args: Any, **_kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + + class _Ctx: + async def __aenter__(_self) -> Any: + raise TimeoutError + + async def __aexit__(_self, *_exc: Any) -> bool: + return False + + return _Ctx() + + async def _session() -> Any: + return _TimingOutSession() + + client._get_session = _session # noqa: SLF001 + client._rate_limiter.acquire = lambda: asyncio.sleep(0) # noqa: SLF001 + + result = await client.post_rpc({"method": "getTransaction"}, max_retries=2) + await client.close() + + assert result is None, f"expected None on repeated timeout, got {result!r}" + assert attempts == 2, f"timeout should be retried; saw {attempts} attempt(s)" + + +async def check_versioned_transactions_are_requested() -> None: + """getTransaction must opt in to versioned transactions. + + Without `maxSupportedTransactionVersion` the RPC answers -32015 for every v0 + transaction, so `meta.err` is unreadable and a successful trade reads back as + unconfirmed. The bot currently sends legacy transactions, which is the only + reason this was survivable. + """ + client = SolanaClient("http://127.0.0.1:1") # never contacted + bodies: list[dict[str, Any]] = [] + + async def capture_rpc(body: dict[str, Any], **_kwargs: Any) -> None: + bodies.append(body) + + client.post_rpc = capture_rpc + await client._get_transaction_result(REVERTED_SIGNATURES[0]) # noqa: SLF001 + await client.close() + + assert bodies, "no RPC issued" + config = bodies[0]["params"][1] + assert config.get("maxSupportedTransactionVersion") == 0, ( + f"getTransaction omits maxSupportedTransactionVersion: {config}" + ) + + +async def check_live_signatures() -> None: + load_dotenv() + rpc_endpoint = os.environ.get("SOLANA_NODE_RPC_ENDPOINT") + if not rpc_endpoint: + raise RuntimeError("SOLANA_NODE_RPC_ENDPOINT is required for --live") + + from solana.rpc.async_api import AsyncClient + from solders.signature import Signature + + bot_client = SolanaClient(rpc_endpoint) + raw_client = AsyncClient(rpc_endpoint) + try: + for signature in REVERTED_SIGNATURES: + sig = Signature.from_string(signature) + + try: + await tx_status.assert_transaction_succeeded(raw_client, sig) + except RuntimeError as exc: + print(f" examples helper rejected {signature[:16]}...: {exc}") + else: + raise AssertionError(f"examples helper accepted reverted {signature}") + + # The transaction must be readable at all — a rejection because the + # fetch failed would pass the assertion below for the wrong reason, + # which is how the missing maxSupportedTransactionVersion hid. + fetched = await bot_client._get_transaction_result(signature) # noqa: SLF001 + assert fetched, f"SolanaClient could not fetch {signature}" + assert fetched["meta"]["err"], f"expected a revert on {signature}" + + # verify_transaction_succeeded rather than confirm_transaction: these + # signatures are old, and the landing-wait polls signature statuses, + # which the RPC only keeps for recent history. + succeeded = await bot_client.verify_transaction_succeeded(signature) + assert not succeeded, f"SolanaClient accepted reverted {signature}" + print( + f" SolanaClient rejected {signature[:16]}...: " + f"{fetched['meta']['err']}" + ) + finally: + await raw_client.close() + await bot_client.close() + + +CHECKS = ( + ("helper rejects a reverted transaction", check_helper_rejects_revert), + ("helper rejects a missing transaction", check_helper_rejects_missing), + ("helper accepts a successful transaction", check_helper_accepts_success), + ("confirm_and_assert surfaces a revert", check_confirm_wrapper_rejects_revert), + ("helper rejects missing execution metadata", check_helper_rejects_missing_meta), + ("revert is a distinct terminal error", check_revert_is_a_distinct_terminal_error), + ("requested commitment is propagated", check_commitment_is_propagated), + ("endpoint logging hides userinfo", check_endpoint_logging_hides_userinfo), + ( + "SolanaClient accepts str and Signature", + check_client_accepts_both_signature_types, + ), + ( + "getTransaction requests versioned transactions", + check_versioned_transactions_are_requested, + ), + ("RPC timeouts are retried, not raised", check_rpc_timeouts_are_retried_not_raised), + ("every example checks meta.err", check_examples_call_a_status_check), + ("src/ reads the confirmation result", check_bot_reads_the_confirmation_result), +) + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--live", + action="store_true", + help="also replay issue #175's reverted signatures against mainnet", + ) + args = parser.parse_args() + + failures = 0 + for label, check in CHECKS: + # Any exception is a failure of that check, not of the run: a check that + # raises must not stop the remaining ones from reporting. Some of these + # verify that a call does NOT raise, so the raise IS the finding. + try: + await check() + except Exception as exc: # noqa: BLE001 + failures += 1 + reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + print(f"{label} -> FAIL\n {reason}") + else: + print(f"{label} -> OK") + + if args.live: + print("\nreplaying issue #175 signatures against mainnet...") + try: + await check_live_signatures() + except Exception as exc: # noqa: BLE001 + failures += 1 + reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + print(f"live signature replay -> FAIL\n {reason}") + else: + print("live signature replay -> OK") + + if failures: + print(f"\n{failures} check(s) failed.") + return 1 + print("\nAll transaction-status checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/src/cleanup/manager.py b/src/cleanup/manager.py index 3cbbf14..a7530ac 100644 --- a/src/cleanup/manager.py +++ b/src/cleanup/manager.py @@ -120,8 +120,13 @@ class AccountCleanupManager: skip_preflight=True, priority_fee=priority_fee, ) - await self.client.confirm_transaction(tx_sig) - logger.info(f"Closed successfully: {ata}") + # confirm_transaction returns False when the transaction landed + # but reverted. Logging success on that would report rent as + # reclaimed while the account is still open. + if await self.client.confirm_transaction(tx_sig): + logger.info(f"Closed successfully: {ata}") + else: + logger.error(f"Failed to close ATA {ata}: {tx_sig}") except Exception as e: logger.warning(f"Cleanup failed for ATA {ata}: {e!s}") diff --git a/src/core/client.py b/src/core/client.py index 01e40ed..92327d8 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -17,6 +17,7 @@ from solders.instruction import Instruction from solders.keypair import Keypair from solders.message import Message from solders.pubkey import Pubkey +from solders.signature import Signature from solders.transaction import Transaction from core.pubkeys import is_sol_paired @@ -174,18 +175,30 @@ class SolanaClient: raise ValueError(f"Account {pubkey} not found") return response.value - async def get_token_account_balance(self, token_account: Pubkey) -> int: + async def get_token_account_balance( + self, token_account: Pubkey, commitment: str = "confirmed" + ) -> int: """Get token balance for an account. + Defaults to "confirmed" rather than solana-py's "finalized": trades are + confirmed at "confirmed", and finalization lags it. Reading the finalized + balance right after a sell returns the pre-sell amount, and cleanup then + builds a burn for tokens the account no longer holds — the whole burn + + close transaction reverts with InsufficientFunds and the rent stays + locked. + Args: token_account: Token account address + commitment: Commitment level for the balance read Returns: Token balance as integer """ await self._rate_limiter.acquire() client = await self.get_client() - response = await client.get_token_account_balance(token_account) + response = await client.get_token_account_balance( + token_account, commitment=commitment + ) if response.value: return int(response.value.amount) return 0 @@ -210,7 +223,7 @@ class SolanaClient: priority_fee: int | None = None, compute_unit_limit: int | None = None, account_data_size_limit: int | None = None, - ) -> str: + ) -> Signature: """ Send a transaction with optional priority fee and compute unit limit. @@ -284,7 +297,7 @@ class SolanaClient: await asyncio.sleep(wait_time) async def confirm_transaction( - self, signature: str, commitment: str = "confirmed" + self, signature: str | Signature, commitment: str = "confirmed" ) -> bool: """Wait for transaction confirmation and verify execution success. @@ -293,12 +306,22 @@ class SolanaClient: can be "confirmed" (included in a block) but still fail execution. Args: - signature: Transaction signature + signature: Transaction signature, base58 string or Signature commitment: Confirmation commitment level Returns: Whether transaction was confirmed AND executed successfully """ + # The RPC client rejects a base58 string, and the resulting TypeError + # would be swallowed by the handler below — reporting "not confirmed" + # for a transaction that was never actually looked up. + if isinstance(signature, str): + try: + signature = Signature.from_string(signature) + except ValueError: + logger.exception(f"Malformed transaction signature: {signature}") + return False + await self._rate_limiter.acquire() client = await self.get_client() try: @@ -309,11 +332,29 @@ class SolanaClient: logger.exception(f"Failed to confirm transaction {signature}") return False - # Verify the transaction actually succeeded (no program errors) - result = await self._get_transaction_result(str(signature)) + return await self.verify_transaction_succeeded(signature) + + async def verify_transaction_succeeded(self, signature: str | Signature) -> bool: + """Check whether a landed transaction actually executed successfully. + + Landing in a block and succeeding are different things: RPC reports a + revert in `meta.err`, so a transaction can be "confirmed" and still have + done nothing. Split out from :meth:`confirm_transaction` so the check can + be run against a transaction that landed some time ago — signature + statuses fall out of the RPC's recent history, but `getTransaction` does + not. + + Args: + signature: Transaction signature, base58 string or Signature + + Returns: + Whether the transaction executed without a program error + """ + signature = str(signature) + result = await self._get_transaction_result(signature) if not result: logger.warning( - f"Could not fetch transaction {str(signature)[:16]}... " + f"Could not fetch transaction {signature[:16]}... " f"to verify execution — treating as unconfirmed" ) return False @@ -321,19 +362,19 @@ class SolanaClient: tx_err = result.get("meta", {}).get("err") if tx_err: logger.error( - f"Transaction {str(signature)[:16]}... confirmed but failed: {tx_err}" + f"Transaction {signature[:16]}... confirmed but failed: {tx_err}" ) return False return True async def get_transaction_token_balance( - self, signature: str, user_pubkey: Pubkey, mint: Pubkey + self, signature: str | Signature, user_pubkey: Pubkey, mint: Pubkey ) -> int | None: """Get the user's token balance after a transaction from postTokenBalances. Args: - signature: Transaction signature + signature: Transaction signature, base58 string or Signature user_pubkey: User's wallet public key mint: Token mint address @@ -361,7 +402,7 @@ class SolanaClient: async def get_buy_transaction_details( self, - signature: str, + signature: str | Signature, mint: Pubkey, sol_destination: Pubkey, quote_mint: Pubkey | None = None, @@ -375,7 +416,7 @@ class SolanaClient: mint's token balance deltas instead. Args: - signature: Transaction signature + signature: Transaction signature, base58 string or Signature mint: Token mint address sol_destination: Address where SOL is sent (bonding curve for pump.fun, quote_vault for letsbonk) @@ -385,6 +426,9 @@ class SolanaClient: Returns: Tuple of (tokens_received_raw, quote_spent_raw), or (None, None) """ + # Normalized up front: the log lines below slice it, which a Signature + # does not support. + signature = str(signature) result = await self._get_transaction_result(signature) if not result: return None, None @@ -482,22 +526,34 @@ class SolanaClient: return best - async def _get_transaction_result(self, signature: str) -> dict | None: + async def _get_transaction_result( + self, signature: str | Signature + ) -> dict | None: """Fetch transaction result from RPC. Args: - signature: Transaction signature + signature: Transaction signature, base58 string or Signature Returns: Transaction result dict or None """ + # A Signature is not JSON serializable, so it has to be stringified here + # rather than relying on every caller to remember. + signature = str(signature) body = { "jsonrpc": "2.0", "id": 1, "method": "getTransaction", "params": [ signature, - {"encoding": "jsonParsed", "commitment": "confirmed"}, + { + "encoding": "jsonParsed", + "commitment": "confirmed", + # Without this the RPC rejects every versioned (v0) + # transaction with -32015, so meta.err cannot be read and a + # perfectly good trade reads back as unconfirmed. + "maxSupportedTransactionVersion": 0, + }, ], } @@ -570,7 +626,11 @@ class SolanaClient: logger.exception(f"Failed to decode RPC response for {method}") return None - except aiohttp.ClientError: + # asyncio.TimeoutError is what aiohttp raises when the request + # timeout fires, and it is not an aiohttp.ClientError — without it + # here every RPC timeout propagated out of post_rpc unretried and + # crashed the caller with an exception whose str() is empty. + except (aiohttp.ClientError, asyncio.TimeoutError): error_attempts += 1 if error_attempts >= max_retries: logger.exception( diff --git a/trades/trades.log b/trades/trades.log deleted file mode 100644 index f1df671..0000000 --- a/trades/trades.log +++ /dev/null @@ -1,4 +0,0 @@ -{"timestamp": "2025-04-24T20:30:13.087092", "action": "buy", "token_address": "DWMUmRQUZPCBA1gwdDxTJuz6JHnQkREiWMyQpsKWGp9v", "symbol": "U8", "price": 5e-06, "amount": 20, "tx_hash": "3JvdfCep45PUB6rCcH4dB2NuwvFP8n67SCUxqJMt4MuN5ekHYc6J27aCUfwNUK3hh5rSyKNYAWXya5vQAT2qQivB"} -{"timestamp": "2025-04-24T20:30:32.759177", "action": "sell", "token_address": "DWMUmRQUZPCBA1gwdDxTJuz6JHnQkREiWMyQpsKWGp9v", "symbol": "U8", "price": 3.805530050663904e-08, "amount": 20.0, "tx_hash": "5cveLfU7XhPNCPMCZfTXyugJpmAQNmi7zr81PSqs8DsP1T2swYFjJwaB5hNSf3kFPfRzgzd7QZBVaZLd5MqsJevB"} -{"timestamp": "2025-08-02T15:37:04.403139", "action": "buy", "platform": "lets_bonk", "token_address": "7o5FtYXxpX6sqtcJJ3ES4DiWt4C9HnHzKuHrZPpjbonk", "symbol": "pants", "price": 5e-06, "amount": 20, "tx_hash": "26Uu4rZ1PcnzioHwWAh3Reca4MMNZjgiQbmihjisiurpv4xcHFtvSptZoV3zUkS5bdqZ4zpLeGb46J4bWyczu6FY"} -{"timestamp": "2025-08-02T15:37:21.984944", "action": "sell", "platform": "lets_bonk", "token_address": "7o5FtYXxpX6sqtcJJ3ES4DiWt4C9HnHzKuHrZPpjbonk", "symbol": "pants", "price": 2.7959121193874663e-08, "amount": 3712.914779, "tx_hash": "5zXxjxHZuWXxGih1Aca6HoFkybiyHT4jPghhXCg3NbkyD3nuzzaJtmjg4mnoEQDeTn64b1cUZMawhmYbeq3cwjfj"}