mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* chore(deps): upgrade solders to 0.29 and solana to 0.40 in cookbook and tools solana 0.40 moves TxOpts (now TxOptsModel), MemcmpOpts and TokenAccountOpts from solana.rpc.types to solana.rpc.core, and spl.token's *Params dataclasses to spl.token.models as pydantic models — validated and keyword-only. Three call sites only failed against mainnet: MemcmpOpts.bytes now takes a base58 str, SyncNativeParams was built positionally, and confirm_transaction was passed a bare "confirmed". Also moves every cookbook and tools send path off the legacy Transaction onto VersionedTransaction + MessageV0, and teaches the getTransaction decoder the json and base64 encodings alongside jsonParsed — the base64 v1 fixture needs solders 0.29 to deserialize at all. src/ is untouched and still on the old imports; the offline suite is 3/19 until that lands. Verified on mainnet: a 0.003 SOL buy_v2/sell_v2 round trip confirmed and the wallet returned to SOL only, all six listeners and every read-only script run live, five trade paths simulated with error=None. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): move the bot onto solders 0.29 and solana 0.40 Same three API moves as the cookbook commit, now in src: TxOpts is TxOptsModel in solana.rpc.core, and spl.token's BurnParams/CloseAccountParams come from spl.token.models. build_and_send_transaction builds a VersionedTransaction from MessageV0 with no lookup tables, so the bot no longer sends a legacy transaction anywhere. verify_transaction_v1 proved the log route by stripping the logs and expecting nothing back, which only held while solders could not read a v1 envelope. On 0.29 it can, so the check is inverted: make the envelope unreadable, keep the logs, and the create must still arrive. A second check pins the envelope fallback, so a decode regression is reported rather than silently absorbed by the log route. Offline: 19/19. Live on mainnet, funds returned: - tools/simulate_bot_buy_path: zero-RPC buy path, err=None, 107,015 CU - tools/simulate_v2_trades: buy_v2 95,573 CU, buy+sell 174,190 — both inside the existing 180k/120k limits, so no retune - tools/live_v2_round_trip: buy_v2 + sell_v2 both landed - tools/live_listener_matrix: all four listeners detected; logs and pumpportal ran full round trips; geyser and blocks both drew a coin on an unconfigured quote mint and correctly refused to size a buy. Every ATA closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bring the rest of the dependencies to current Every remaining dependency is now at its latest release, with the floors raised to the versions actually tested rather than the oldest that resolves: grpcio 1.73.1 -> 1.84.0 grpcio-tools 1.73.1 -> 1.84.0 protobuf 6.33.5 -> 7.36.2 uvloop 0.21.0 -> 0.22.1 pyyaml 6.0.2 -> 6.0.3 python-dotenv 1.2.2 -> 1.2.3 construct 2.10.70 (floor) websockets 17.1 (floor) ruff 0.12.4 -> 0.16.8 aiohttp and base58 were already current. winloop is left at its old floor: it is Windows-only and cannot be installed, let alone tested, here. protobuf 7 arrives with grpcio-tools 1.84 and is the only major. The committed geyser stubs are gencode 6.31.1 and still pass ValidateProtobufRuntimeVersion against it; they are not regenerated, because the committed files import each other absolutely and protoc does not emit that, so the output would need rewriting. Noted in CLAUDE.md with the verification date. ruff 0.16 needs no config change. The repo-wide baseline moves 1680 -> 1713 because the rules moved, not the code; CLAUDE.md carries the new figure so it is not misread as a regression. Verified per dependency, not just in aggregate: all four bot configs load under the new pyyaml/dotenv; the logs, blocks and pumpportal listeners run under the uvloop policy bot_runner installs; both geyser paths (token listener and the account-filtered graduation watcher) run under grpcio 1.84 / protobuf 7. Offline 19/19, including verify_listener_cancellation for websockets. One more live buy_v2/sell_v2 round trip on the final set, both landed, ATA closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(utils): redact RPC credentials from every log record The provider endpoints carry their API key in the URL and the HTTP clients log a full request URL at INFO, so anything that raises the root logger to INFO prints the key. tools/cleanup_accounts.py did, four times, in a terminal. It was already silencing httpx and httpcore by name. That stopped working the moment solana-py 0.40 switched to httpx2: the rename took the guard with it and nothing said so. Naming the clients is now a convenience on top of the real control — install_secret_redaction masks the value itself, at log-record creation, whichever logger emits it. It redacts the *rendered* message rather than record.msg plus the string arguments. The first version of this did the latter and still leaked: httpx2 passes its URL as a URL object, so the type check skipped the only argument carrying the key. Confirmed by forcing httpx2 back to INFO and watching the key print anyway. The host is kept, so a log line still says which provider answered. Installed from get_logger, from both logging.basicConfig sites, and from setup_file_logging before it attaches the handler — logs/ outlives a terminal. verify_no_rpc_credentials_logged covers all of it, including the non-str argument. Checked against the defeated version first: it fails there and passes here. Suite is 20/20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): make a real bot run the verification, not the simulations The ground rule said never to run a bot with real funds, which points at the tools/ harnesses instead. Those call the buyer, seller and listeners directly: they skip bot_runner, config loading, the trader loop, the exit strategy and cleanup, so passing there says nothing about the paths only a full run reaches. Verification is a real run, with explicit approval for that session, covering every mode the change touches and ending with the wallet holding SOL only. Also records why a run can look clean when nothing happened: pump_bot runs every bots/*.yaml, three of the four committed configs ship enabled: false, and with all of them disabled it prints nothing and exits 0. The log file is the tell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(utils): keep the redaction comment provider-neutral The parameter list is what matters, not which provider uses which spelling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""Simulate pump.fun buy_v2 / sell_v2 against mainnet without spending funds.
|
|
|
|
Builds both instructions through the bot's real code path (address provider,
|
|
instruction builder, curve manager) and runs them through
|
|
`simulateTransaction`, which executes the program against live state but never
|
|
submits. Reports the program error (if any) and the compute units consumed —
|
|
use the latter to tune `get_buy_compute_unit_limit` / `get_sell_compute_unit_limit`.
|
|
|
|
The wallet is only used to derive a pubkey and sign nothing: simulation runs
|
|
with sigVerify disabled.
|
|
|
|
Usage:
|
|
# simulate against a specific coin
|
|
uv run tools/simulate_v2_trades.py <MINT>
|
|
|
|
# discover a fresh coin via geyser, then simulate against it
|
|
uv run tools/simulate_v2_trades.py
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from base64 import b64encode
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
|
|
|
from dotenv import load_dotenv # noqa: E402
|
|
from solders.compute_budget import set_compute_unit_limit # noqa: E402
|
|
from solders.message import MessageV0 # noqa: E402
|
|
from solders.pubkey import Pubkey # noqa: E402
|
|
from solders.transaction import VersionedTransaction # noqa: E402
|
|
|
|
from core.client import SolanaClient # noqa: E402
|
|
from core.pubkeys import ( # noqa: E402
|
|
TOKEN_DECIMALS,
|
|
SystemAddresses,
|
|
normalize_quote_mint,
|
|
quote_units_per_token,
|
|
resolve_quote_token_program,
|
|
)
|
|
from core.wallet import Wallet # noqa: E402
|
|
from interfaces.core import Platform, TokenInfo # noqa: E402
|
|
from platforms import get_platform_implementations # noqa: E402
|
|
|
|
load_dotenv(PROJECT_ROOT / ".env")
|
|
|
|
# Buy this many whole tokens in the simulation. Small enough that the quote cost
|
|
# stays well under a test wallet's balance on a fresh curve.
|
|
SIMULATED_TOKEN_AMOUNT = 20
|
|
# Generous ceiling so simulation reports true consumption rather than hitting
|
|
# the limit. The real bot uses the builder's tuned values.
|
|
SIMULATION_CU_LIMIT = 400_000
|
|
|
|
|
|
async def discover_mint(timeout_seconds: float = 45.0) -> Pubkey | None:
|
|
"""Listen for a freshly created pump.fun coin via geyser.
|
|
|
|
Args:
|
|
timeout_seconds: How long to wait for a creation event
|
|
|
|
Returns:
|
|
Mint of the first coin seen, or None on timeout
|
|
"""
|
|
from monitoring.listener_factory import ListenerFactory
|
|
|
|
listener = ListenerFactory.create_listener(
|
|
listener_type="geyser",
|
|
geyser_endpoint=os.environ["GEYSER_ENDPOINT"],
|
|
geyser_api_token=os.environ["GEYSER_API_TOKEN"],
|
|
geyser_auth_type=os.environ.get("GEYSER_AUTH_TYPE", "x-token"),
|
|
platforms=[Platform.PUMP_FUN],
|
|
)
|
|
|
|
seen: list[TokenInfo] = []
|
|
|
|
async def on_token(token_info: TokenInfo) -> None:
|
|
seen.append(token_info)
|
|
|
|
task = asyncio.create_task(listener.listen_for_tokens(on_token))
|
|
try:
|
|
deadline = timeout_seconds / 0.5
|
|
for _ in range(int(deadline)):
|
|
if seen:
|
|
break
|
|
await asyncio.sleep(0.5)
|
|
finally:
|
|
task.cancel()
|
|
|
|
return seen[0].mint if seen else None
|
|
|
|
|
|
async def build_token_info(
|
|
mint: Pubkey, client: SolanaClient
|
|
) -> tuple[TokenInfo, dict]:
|
|
"""Assemble TokenInfo for an existing coin from its on-chain curve state.
|
|
|
|
Args:
|
|
mint: Base token mint
|
|
client: Solana RPC client
|
|
|
|
Returns:
|
|
Tuple of (TokenInfo, decoded curve state)
|
|
"""
|
|
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
|
|
provider = implementations.address_provider
|
|
curve_manager = implementations.curve_manager
|
|
|
|
bonding_curve = provider.derive_pool_address(mint)
|
|
state = await curve_manager.get_pool_state(bonding_curve, commitment="processed")
|
|
|
|
creator = state["creator"]
|
|
creator = Pubkey.from_string(creator) if isinstance(creator, str) else creator
|
|
quote_mint = normalize_quote_mint(state["quote_mint"])
|
|
|
|
# Which token program owns the base mint decides the base ATA derivation.
|
|
account = await client.get_account_info(mint)
|
|
base_token_program = (
|
|
SystemAddresses.TOKEN_2022_PROGRAM
|
|
if str(account.owner) == str(SystemAddresses.TOKEN_2022_PROGRAM)
|
|
else SystemAddresses.TOKEN_PROGRAM
|
|
)
|
|
|
|
token_info = TokenInfo(
|
|
name="simulation",
|
|
symbol="SIM",
|
|
uri="",
|
|
mint=mint,
|
|
platform=Platform.PUMP_FUN,
|
|
bonding_curve=bonding_curve,
|
|
associated_bonding_curve=provider.derive_associated_bonding_curve(
|
|
mint, bonding_curve, base_token_program
|
|
),
|
|
creator=creator,
|
|
creator_vault=provider.derive_creator_vault(creator),
|
|
token_program_id=base_token_program,
|
|
is_mayhem_mode=state["is_mayhem_mode"],
|
|
is_cashback_coin=state["is_cashback_coin"],
|
|
quote_mint=quote_mint,
|
|
quote_token_program_id=await resolve_quote_token_program(
|
|
quote_mint, client.get_account_info
|
|
),
|
|
)
|
|
return token_info, state
|
|
|
|
|
|
async def simulate(
|
|
client: SolanaClient, wallet: Wallet, instructions: list, label: str
|
|
) -> bool:
|
|
"""Run a set of instructions through simulateTransaction and report.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
wallet: Wallet whose pubkey pays and signs
|
|
instructions: Instructions to simulate
|
|
label: Human-readable name for output
|
|
|
|
Returns:
|
|
True if the simulation reported no program error
|
|
"""
|
|
blockhash = await client.get_latest_blockhash()
|
|
message = MessageV0.try_compile(
|
|
wallet.pubkey,
|
|
[set_compute_unit_limit(SIMULATION_CU_LIMIT), *instructions],
|
|
[],
|
|
blockhash,
|
|
)
|
|
transaction = VersionedTransaction(message, [wallet.keypair])
|
|
|
|
response = await client.post_rpc(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "simulateTransaction",
|
|
"params": [
|
|
b64encode(bytes(transaction)).decode(),
|
|
{
|
|
"encoding": "base64",
|
|
"sigVerify": False,
|
|
"replaceRecentBlockhash": True,
|
|
"commitment": "processed",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
if not response or "result" not in response:
|
|
print(f" {label}: RPC call failed: {response}")
|
|
return False
|
|
|
|
value = response["result"]["value"]
|
|
err = value.get("err")
|
|
units = value.get("unitsConsumed")
|
|
account_count = len(instructions[-1].accounts)
|
|
|
|
print(f" {label}: accounts={account_count} unitsConsumed={units} err={err}")
|
|
if err:
|
|
for line in value.get("logs") or []:
|
|
if "Error" in line or "failed" in line or "Instruction:" in line:
|
|
print(f" {line}")
|
|
return err is None
|
|
|
|
|
|
async def main() -> int:
|
|
"""Simulate a buy_v2 and sell_v2 for one coin.
|
|
|
|
Returns:
|
|
Process exit code (0 if the buy simulation succeeded)
|
|
"""
|
|
mint_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
|
|
|
if mint_arg:
|
|
mint = Pubkey.from_string(mint_arg)
|
|
else:
|
|
print("No mint given; listening for a fresh pump.fun coin via geyser...")
|
|
mint = await discover_mint()
|
|
if mint is None:
|
|
print("No coin seen before timeout. Pass a mint explicitly.")
|
|
return 2
|
|
|
|
client = SolanaClient(os.environ["SOLANA_NODE_RPC_ENDPOINT"])
|
|
wallet = Wallet(os.environ["SOLANA_PRIVATE_KEY"])
|
|
|
|
try:
|
|
token_info, state = await build_token_info(mint, client)
|
|
quote_mint = token_info.quote_mint
|
|
quote_unit = quote_units_per_token(quote_mint)
|
|
|
|
print(f"\nmint: {mint}")
|
|
print(f"bonding_curve: {token_info.bonding_curve}")
|
|
print(f"quote_mint: {quote_mint}")
|
|
print(f"base program: {token_info.token_program_id}")
|
|
print(
|
|
f"mayhem={token_info.is_mayhem_mode} "
|
|
f"cashback={token_info.is_cashback_coin} "
|
|
f"complete={state['complete']}"
|
|
)
|
|
print(f"price: {state['price_per_token']:.10f} quote/token")
|
|
print(f"wallet: {wallet.pubkey}\n")
|
|
|
|
implementations = get_platform_implementations(Platform.PUMP_FUN, client)
|
|
provider = implementations.address_provider
|
|
builder = implementations.instruction_builder
|
|
|
|
token_raw = SIMULATED_TOKEN_AMOUNT * 10**TOKEN_DECIMALS
|
|
# Cap the quote spend generously; simulation only needs it affordable.
|
|
max_quote_raw = int(
|
|
SIMULATED_TOKEN_AMOUNT * state["price_per_token"] * quote_unit * 2
|
|
) or int(0.001 * quote_unit)
|
|
|
|
print("simulating:")
|
|
buy_instructions = await builder.build_buy_v2_instruction(
|
|
token_info, wallet.pubkey, max_quote_raw, token_raw, provider
|
|
)
|
|
buy_ok = await simulate(client, wallet, buy_instructions, "buy_v2 ")
|
|
|
|
sell_instructions = await builder.build_sell_v2_instruction(
|
|
token_info, wallet.pubkey, token_raw, 1, provider
|
|
)
|
|
await simulate(client, wallet, sell_instructions, "sell_v2")
|
|
print(
|
|
" ^ expected to fail with AccountNotInitialized on "
|
|
"associated_base_user unless\n the wallet already holds this "
|
|
"coin. That error means all 26 accounts validated."
|
|
)
|
|
|
|
# Buy and sell in one transaction so the position exists mid-tx. This is
|
|
# the only way to get a representative sell_v2 CU number without
|
|
# actually holding the coin. Sell slightly less than bought to stay
|
|
# inside the realised balance.
|
|
combined = [*buy_instructions, *sell_instructions[:-1]]
|
|
resell_raw = int(token_raw * 0.9)
|
|
combined.append(
|
|
(
|
|
await builder.build_sell_v2_instruction(
|
|
token_info, wallet.pubkey, resell_raw, 1, provider
|
|
)
|
|
)[-1]
|
|
)
|
|
combined_ok = await simulate(client, wallet, combined, "buy+sell")
|
|
if combined_ok:
|
|
print(" ^ subtract the buy_v2 figure above to estimate sell_v2 CU")
|
|
|
|
return 0 if buy_ok else 1
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(asyncio.run(main()))
|