fix: report on-chain trade outcomes correctly, and repair every broken learning example (#177)

* fix(tx-status): read meta.err before reporting a trade succeeded

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

False success — a reverted trade reported as confirmed:

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

False failure — a good trade reported as unconfirmed:

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

Changes:

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

Two reporting bugs found while testing the above:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dead code and artifacts:

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

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

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

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

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

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

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

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

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

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

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

Runnability and credentials:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-29 13:44:35 +02:00
committed by GitHub
parent 02343b775b
commit 836d873d27
36 changed files with 1083 additions and 766 deletions
+2
View File
@@ -1,4 +1,6 @@
trades/*
# written by learning-examples/blockSubscribe_extract_transactions.py
blockSubscribe-transactions/
.vscode
.pylintrc
+31
View File
@@ -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
@@ -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",
+80 -13
View File
@@ -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}")
@@ -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 '<unset>'}")
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))
@@ -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
@@ -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
+6 -1
View File
@@ -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("<Q", 6966180631402821399)
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
@@ -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")
@@ -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 '<unset>'}")
print()
async with AsyncClient(RPC_ENDPOINT) as client:
@@ -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 '<unset>'}")
print()
async with AsyncClient(RPC_ENDPOINT) as client:
@@ -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 '<unset>'}")
print()
async with AsyncClient(RPC_ENDPOINT) as client:
@@ -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 '<unset>'}")
print()
async with AsyncClient(RPC_ENDPOINT) as client:
@@ -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(
{
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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
+10 -2
View File
@@ -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")
+11 -3
View File
@@ -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:
+11 -3
View File
@@ -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:
+11 -3
View File
@@ -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:
+11 -3
View File
@@ -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:
+3 -29
View File
@@ -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
+3 -29
View File
@@ -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
@@ -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:
@@ -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:
@@ -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))
+100
View File
@@ -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)
@@ -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()))
+7 -2
View File
@@ -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}")
+77 -17
View File
@@ -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(
-4
View File
@@ -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"}