From 836d873d27034bd7c1a48e503f0537a61c2485a5 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk <43762166+smypmsa@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:44:35 +0200 Subject: [PATCH] fix: report on-chain trade outcomes correctly, and repair every broken learning example (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tx-status): read meta.err before reporting a trade succeeded confirm_transaction answers "did this signature land in a block?", never "did it succeed". A landed transaction can have reverted, and RPC reports that only in meta.err. Reading whether a call threw instead of reading meta.err produced false results in both directions. False success — a reverted trade reported as confirmed: - ten examples (pump.fun, pumpswap, letsbonk) printed "Transaction confirmed" without checking meta.err - src/cleanup/manager.py discarded confirm_transaction's boolean and logged "Closed successfully" unconditionally, so a reverted close reported rent as reclaimed while the account stayed open - learning-examples/cleanup_accounts.py did the same False failure — a good trade reported as unconfirmed: - _get_transaction_result omitted maxSupportedTransactionVersion, so the RPC answered -32015 for every versioned (v0) transaction. meta.err was unreadable and a successful trade read back as failed. The bot sends legacy transactions, which is the only reason this was survivable. - confirm_transaction raised TypeError on a base58 str (solana-py wants a Signature) while _get_transaction_result raised on a Signature (not JSON serializable). Both were swallowed by a broad except into "not confirmed". The annotations pointed the wrong way too: build_and_send_transaction returns Signature, not str. Changes: - add learning-examples/tx_status.py — assert_transaction_succeeded and confirm_and_assert, replacing the copy duplicated in mint_and_buy{,_v2} - wire it into the ten examples that confirmed without checking - read the boolean in both cleanup paths - normalize str/Signature at the client boundary; correct the annotations - send maxSupportedTransactionVersion: 0 on getTransaction - split verify_transaction_succeeded out of confirm_transaction so the meta.err check can run against a transaction that landed earlier Two reporting bugs found while testing the above: - live_v2_round_trip read balances at solana-py's default (finalized) commitment while confirming trades at "confirmed", so the end read saw pre-trade state and it printed "net change: +0.000000000 SOL" after a real round trip. Verified: on a busy account finalized trails confirmed by ~263k lamports. - cleanup_accounts produced no output at all, success or failure, because get_logger attaches no handler and only the bot installs one. httpx is pinned to WARNING alongside it — the RPC endpoint carries an API key. Adds learning-examples/verify_tx_status_checks.py: offline stub checks, a scan that fails if an example confirms without checking meta.err, an AST check that nothing in src/ discards the boolean, a guard that getTransaction opts into v0, and --live, which replays issue #175's three signatures against mainnet and requires both layers to reject them on meta.err (Custom: 6062) rather than on a failed fetch. The two src/ guards were mutation-tested: reintroducing each bug makes them fail. The BuybackFeeRecipientMissing (6062) half of #175 was already fixed by the buy_v2/sell_v2 migration in 02343b7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * fix(client): retry RPC timeouts instead of letting them escape post_rpc aiohttp signals a request timeout with asyncio.TimeoutError, which is not an aiohttp.ClientError. post_rpc caught only the latter, so every RPC timeout propagated to the caller unretried — and str() on it is empty, so whatever logged it printed a blank reason. Found while running learning-examples/live_listener_matrix.py: three of the four listeners died mid-run with "CRASHED: " and no message. The endpoint was answering getHealth in ~100ms while getAccountInfo hung past 60s, and every caller that touches it (sol_balance, ata_is_closed, AccountCleanupManager.cleanup_ata) went down with it. With the retry in place the same run degrades to a logged failure and completes. Also makes the verifier report a raising check as a failure rather than aborting the run — several checks assert that a call does NOT raise, so the raise is the finding and the remaining checks still need to report. Covered by "RPC timeouts are retried, not raised" in verify_tx_status_checks.py, mutation-tested against the one-exception version. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * fix(examples): raise websocket frame limit, load .env, drop dead code Running every learning example turned up three separate reasons a shipped script could not work at all. blockSubscribe examples were completely non-functional. websockets defaults max_size to 1 MiB and Solana's blockSubscribe frames run well past that, so the connection died with a 1009 close on the first real block and the retry loop spun. listen_blocksubscribe.py logged 158,613 "message too big" errors in 35s and decoded zero tokens; compare_listeners produced 10.1M error lines and its block column never reported anything. Pass max_size=WEBSOCKET_MAX_MESSAGE_BYTES (32 MiB, the value the bot's own listeners already use) at all 12 example connect sites — logsSubscribe and programSubscribe included, since they have the same latent ceiling. After: listen_blocksubscribe decodes tokens with 0 errors, and compare_listeners reports provider_1_block alongside geyser and logs. Seven examples read SOLANA_* from the environment but never called load_dotenv(), so they only ran with variables already exported — manual_buy, manual_buy_cu_optimized, manual_buy_geyser, manual_sell, fetch_price, blockSubscribe_extract_transactions and sample_cashback_pumpswap. manual_buy died on "None isn't a valid URI: scheme isn't ws or wss" against a normal .env checkout. Dead code and artifacts: - unused datetime import in listen_pumpportal - initial_real_token_reserves computed and never read in mint_and_buy and mint_and_buy_v2 - gitignore blockSubscribe-transactions/, which a shipped example writes into 1200+ files deep and which nothing ignored - stop tracking trades/trades.log; .gitignore has listed it all along, but a tracked file ignores .gitignore ruff over src/ and learning-examples/ goes 2396 -> 2393 findings: the three removals, nothing new. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * fix: read balances at confirmed, derive letsbonk platform_config per pool Running every example against mainnet surfaced four more bugs, three of them the same root cause as the commitment bug already in this branch: solana-py defaults to "finalized", but trades confirm at "confirmed", and finalization lags far enough behind that anything read in between is pre-trade state. get_token_account_balance defaulted to finalized. Cleanup reads it to decide whether to burn before closing, so right after a sell it saw the pre-sell amount and built a burn for tokens the account no longer held — the burn + close reverted with InsufficientFunds and the rent stayed locked. Observed live: "Burning 35766666 tokens" on an account the sell had already emptied, then Custom(1). Defaults to confirmed now, which is also what confirm_transaction uses. manual_sell_pumpswap read the user's base balance at finalized, which failed two ways in one session: "could not find account" when the ATA had been created by a buy moments earlier, and a stale non-zero balance whose transfer then reverted with insufficient funds. Pool vault reads move to confirmed too, so quotes are not priced off stale reserves. The letsbonk examples hardcoded platform_config. LaunchLab pools do not share one — partner launches carry their own — so every buy/sell against such a pool failed with ConstraintAddress (2012): AnchorError caused by account: platform_config Left: 5thqcDwKp5QQ8US4XRMoseGeGbmLKMmoKZmS6zHrQAsA Right: FfYek5vEz23cMkWsdJwG2oa6EphsvXSHrGpdALN4g6W1 PoolState carries platform_config at field 18 and these scripts already parse the pool, so they now take the pool's own value and keep the constant only as a fallback. src/platforms/letsbonk already documented this; only the examples were stale. All four scripts now simulate and land (buy_exact_in, sell_exact_in, buy_exact_out, sell_exact_out). They also discarded simulation logs on failure, printing an error number with no indication of which account or constraint broke. They print the program logs now — that is how the above was diagnosed. Runnability and credentials: - fetch_price, cleanup_accounts and the four letsbonk scripts hardcoded a placeholder ("...", "YOUR_TOKEN_MINT_ADDRESS_HERE") and could not run at all without editing the source. They take argv[1] now, matching manual_sell and the pumpswap scripts. cleanup_accounts takes "2022" as argv[2] for Token-2022 mints, which every pump.fun coin is. - six scripts printed the RPC/WSS endpoint, which carries an API key, into stdout. They print only the host now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * fix(cleanup): never burn wrapped SOL, and label what actually happened learning-examples/cleanup_accounts.py burned any non-zero balance before closing. The token program rejects a burn of native (wrapped) SOL with NativeNotSupported (error 10), so the burn + close transaction reverted and a WSOL account could never be cleaned up — the pumpswap examples leave one behind on every sell. Closing a WSOL account already returns both the wrapped lamports and the rent, so there is nothing to burn first. src/cleanup/manager.py already had this guard; only the example was missing it. Observed live: "Burning 2206381 tokens" then Custom(10), with 2.2m lamports stranded until the guard went in. The success line also claimed "Burned and closed" for the unwrap path, which burns nothing. It now reports Unwrapped/Burned/Closed to match the instructions actually built. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * chore: drop captured-output artifacts and a spent one-off investigation script learning-examples/decoded_buy_tx_from_getTransaction.json and decoded_create_tx_from_getTransaction.json are not JSON — they are captured stdout from the decode scripts, several objects concatenated, so json.load raises "Extra data" on both. Nothing reads them and nothing can. The raw_*.json fixtures stay: all three are valid input and were re-checked through decode_from_getTransaction / decode_from_blockSubscribe / decode_from_getAccountInfo. learning-examples/pumpswap/sample_cashback_pumpswap.py was added by #168 to find the position of the extra account that cashback PumpSwap pools require. That question is answered — the layouts are in platforms/pumpfun/instruction_builder.py and machine-checked against the IDL by verify_v2_account_layout.py. It is undocumented, unlike every other pumpswap example in the README, and nothing imports it. Not touched: logs/ (run history), .cursor/.kiro/.windsurf (deliberate mirrors of the same rules for other editors), and the raw_*.json fixtures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa * fix(tx-status): fail closed on missing metadata, stop retrying landed reverts Addresses the must-fix findings from review. Two of them were wrong in code this branch added. assert_transaction_succeeded folded meta=None into err=None and returned success. Missing execution metadata means the outcome is unknown, not that it succeeded — which is the exact mistake this module exists to prevent. It raises now. It also always read at "confirmed" while confirm_and_assert accepted a commitment argument, so confirming at "finalized" and then reading status at "confirmed" could report success before the finalization the caller asked for. The commitment is threaded through. The manual buy/sell retry loops built one message and blockhash before the loop, so a landed revert was retried by resubmitting byte-identical signed bytes — three attempts with backoff that could never succeed. Reverts now raise TransactionRevertedError (a RuntimeError subclass, so existing handlers keep working) and the loops treat it as terminal. Rebuilding and re-signing per attempt is the fuller fix but a bigger change to these scripts than this branch should carry. cleanup_accounts guessed the mint's token program, defaulting to legacy SPL unless argv[2] was "2022". The ATA address differs between programs, so a wrong guess derives an address that does not exist and the script reports "already closed" for an account it never looked at. It reads the owner off the mint account instead, which is authoritative, and rejects anything not owned by a token program. Verified live: pump.fun coins resolve to Token-2022, letsbonk and USDC to legacy. argv[2] is gone — nothing to guess. urlsplit(...).netloc keeps any user:pass@ userinfo, so the endpoint redaction added earlier still printed credentials for providers that put the key there. Uses .hostname in all five sites. Four checks added to verify_tx_status_checks.py, each mutation-tested: missing metadata, revert-is-terminal, commitment propagation, and a scan that fails if any example goes back to netloc. Skipped, both minor and pre-existing repo-wide rather than introduced here: validating that RPC/WSS env vars are non-None before connecting (every example has this shape; fixing two of them would just make it inconsistent), and the cryptic base58 error when a letsbonk script runs with no argument, which matches the "..." placeholder convention the pumpswap and manual_sell examples already use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFrxcZfCf9C76voCb7M8Pa --------- Co-authored-by: Claude Opus 5 (1M context) --- .gitignore | 2 + CLAUDE.md | 31 ++ .../blockSubscribe_extract_transactions.py | 12 +- learning-examples/cleanup_accounts.py | 93 +++- .../copytrading/listen_wallet_transactions.py | 14 +- .../decoded_buy_tx_from_getTransaction.json | 215 -------- ...decoded_create_tx_from_getTransaction.json | 263 ---------- learning-examples/fetch_price.py | 7 +- .../letsbonk-buy-sell/manual_buy_exact_in.py | 33 +- .../letsbonk-buy-sell/manual_buy_exact_out.py | 33 +- .../letsbonk-buy-sell/manual_sell_exact_in.py | 33 +- .../manual_sell_exact_out.py | 33 +- .../compare_migration_listeners.py | 13 +- .../listen_blocksubscribe_old_raydium.py | 9 +- .../listen-migrations/listen_logsubscribe.py | 9 +- .../listen_programsubscribe.py | 9 +- .../listen-new-tokens/compare_listeners.py | 13 +- .../listen_blocksubscribe.py | 9 +- .../listen-new-tokens/listen_logsubscribe.py | 9 +- .../listen_logsubscribe_abc.py | 9 +- .../listen-new-tokens/listen_pumpportal.py | 1 - learning-examples/live_v2_round_trip.py | 12 +- learning-examples/manual_buy.py | 14 +- learning-examples/manual_buy_cu_optimized.py | 14 +- learning-examples/manual_buy_geyser.py | 14 +- learning-examples/manual_sell.py | 14 +- learning-examples/mint_and_buy.py | 32 +- learning-examples/mint_and_buy_v2.py | 32 +- .../pumpswap/manual_buy_pumpswap.py | 12 +- .../pumpswap/manual_sell_pumpswap.py | 22 +- .../pumpswap/sample_cashback_pumpswap.py | 134 ----- learning-examples/tx_status.py | 100 ++++ learning-examples/verify_tx_status_checks.py | 492 ++++++++++++++++++ src/cleanup/manager.py | 9 +- src/core/client.py | 94 +++- trades/trades.log | 4 - 36 files changed, 1083 insertions(+), 766 deletions(-) delete mode 100644 learning-examples/decoded_buy_tx_from_getTransaction.json delete mode 100644 learning-examples/decoded_create_tx_from_getTransaction.json delete mode 100644 learning-examples/pumpswap/sample_cashback_pumpswap.py create mode 100644 learning-examples/tx_status.py create mode 100644 learning-examples/verify_tx_status_checks.py delete mode 100644 trades/trades.log 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"}