mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-03 02:37:43 +00:00
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:
@@ -1,4 +1,6 @@
|
||||
trades/*
|
||||
# written by learning-examples/blockSubscribe_extract_transactions.py
|
||||
blockSubscribe-transactions/
|
||||
|
||||
.vscode
|
||||
.pylintrc
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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()))
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user