40ad32b8d0
* feat(core): add RPC rate limiting, retry logic, and 429 handling Addresses #44 — users on free-tier RPC endpoints hit HTTP 429 errors during buy transactions due to no rate limiting or retry handling. - Add TokenBucketRateLimiter (new file: src/core/rpc_rate_limiter.py) - Gate all RPC methods through rate limiter (both post_rpc and solana-py calls) - Rewrite post_rpc() with retry loop, exponential backoff, jitter, and specific 429 detection with Retry-After header support - Replace per-call aiohttp session with shared persistent session - Wire node.max_rps from YAML bot config through to SolanaClient - Fix cleanup manager and learning example to use SolanaClient abstraction instead of bypassing it via get_client() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): address CodeRabbitAI review feedback on rate limiting PR Validate max_rps > 0 in TokenBucketRateLimiter to prevent ZeroDivisionError and infinite loops with fractional values. Add asyncio.Lock to _get_session to fix race condition, handle non-numeric Retry-After headers gracefully, replace dead json.JSONDecodeError with aiohttp.ContentTypeError, and combine burn+close into a single transaction in cleanup example. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: document RPC rate limiting feature in README - Add section on built-in RPC rate limiting with token bucket algorithm - Document configurable max RPS and automatic retry logic - Update roadmap to mark "Configurable RPS" as completed - Clarify benefits of rate limiting for provider compliance Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use math.ceil for burst_size to handle fractional max_rps - Replace int(max_rps) with math.ceil(max_rps) in burst_size calculation - Prevents infinite loop when max_rps < 1.0 (e.g., 0.5 RPS would result in burst_size=0) - Ensures burst_size is always at least 1 for valid fractional rates - Addresses CodeRabbit feedback on rpc_rate_limiter.py:27 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(core): validate burst_size and fix table pipe consistency Address remaining CodeRabbitAI review feedback: add burst_size validation guard, fix TRY003 lint (use msg variable for ValueError), break long line under 88 chars, and fix MD055 table pipe style in README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): separate 429 retry budget from error retries in post_rpc 429 responses no longer count against max_retries — they use a dedicated max_429_retries counter (default 10) so free-tier users hitting rate limits won't exhaust retries prematurely. Also refresh the aiohttp session inside the retry loop to avoid stale references after network failures, and fix cleanup log message accuracy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Anton Sauchyk <antonsauchyk@gmail.com>
96 lines
3.2 KiB
Python
96 lines
3.2 KiB
Python
import asyncio
|
|
import os
|
|
|
|
from dotenv import load_dotenv
|
|
from solders.pubkey import Pubkey
|
|
from spl.token.instructions import BurnParams, CloseAccountParams, burn, close_account
|
|
|
|
from core.client import SolanaClient
|
|
from core.pubkeys import SystemAddresses
|
|
from core.wallet import Wallet
|
|
from utils.logger import get_logger
|
|
|
|
load_dotenv()
|
|
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")
|
|
|
|
# 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
|
|
|
|
|
|
async def close_account_if_exists(
|
|
client: SolanaClient, wallet: Wallet, account: Pubkey, mint: Pubkey
|
|
):
|
|
"""Safely close a token account if it exists and reclaim rent."""
|
|
try:
|
|
try:
|
|
await client.get_account_info(account)
|
|
except ValueError:
|
|
logger.info(f"Account does not exist or already closed: {account}")
|
|
return
|
|
|
|
# WARNING: This will permanently burn all tokens in the account before closing it
|
|
# Closing account is impossible if balance is positive
|
|
# Burn + close are combined into a single transaction to avoid race conditions
|
|
instructions = []
|
|
balance = await client.get_token_account_balance(account)
|
|
if balance > 0:
|
|
logger.info(f"Burning {balance} tokens from account {account}...")
|
|
burn_ix = burn(
|
|
BurnParams(
|
|
account=account,
|
|
mint=mint,
|
|
owner=wallet.pubkey,
|
|
amount=balance,
|
|
program_id=TOKEN_PROGRAM,
|
|
)
|
|
)
|
|
instructions.append(burn_ix)
|
|
|
|
# Account exists, attempt to close it
|
|
logger.info(f"Closing account: {account}")
|
|
close_params = CloseAccountParams(
|
|
account=account,
|
|
dest=wallet.pubkey,
|
|
owner=wallet.pubkey,
|
|
program_id=TOKEN_PROGRAM,
|
|
)
|
|
instructions.append(close_account(close_params))
|
|
|
|
tx_sig = await client.build_and_send_transaction(
|
|
instructions,
|
|
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}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error while processing account {account}: {e}")
|
|
|
|
|
|
async def main():
|
|
try:
|
|
client = SolanaClient(RPC_ENDPOINT)
|
|
wallet = Wallet(PRIVATE_KEY)
|
|
|
|
# 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)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error: {e}")
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|