mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* fix(cookbook,tools): read GEYSER_AUTH_TYPE and reject an unknown scheme
Four Geyser clients advertised the variable and ignored it, sending Basic for
anything that was not exactly "x-token". Validate against {x-token, basic}.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): detect blockSubscribe creations from logs alone
The envelope fallback cannot see a create reached by CPI, so it covered a
subset of the log route. Envelope decoding stays in the decode/ script.
Also skip meta.err: logs emitted before a failure are kept, and that mint
does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): raise when the graduation baseline cannot be read
The watchers fell back to a hardcoded 793,100,000 and still printed it as
"(from Global)". Every progress figure is measured against that baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): report an unsent create flag as unknown, not false
Truncated create_v2 args and legacy create were filled in as False, reading
as a coin the program said is not in mayhem mode. Leave them out, print N/A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): build Geyser credentials before the reconnect loop
The loop catches every exception and sleeps, so an unusable auth type
reconnected forever instead of reporting a misconfiguration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): treat a short Global buffer as an unreadable baseline
The guard only caught a missing account, so a short read raised struct.error
past the documented ValueError.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): surface a listener's exception instead of an idle market
A listener that raised on connect returned nothing, exactly like a quiet
market, in the harness whose job is to certify listeners. Take the listener
list from the factory too, so one added there is not silently skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): simulate the transaction the bot built
The hook rebuilt the transaction itself and dropped the account data size
limit, so the simulation was more permissive than the send. Hook the RPC
client instead, build the client before detection so its blockhash cache is
warm, and report the compute budget read back off the wire.
A listener that raised on connect also read as a quiet market here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tools): skip failed migrations on err, not on the word "Error"
The notification carries err beside the logs. Scanning the text dropped a
migration whose logs merely said "Error" and kept ones that truly failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): refuse a letsbonk buy that accepts any output
The only one of the four without the guard. Near graduation the floor
division reaches zero, and the buy submits with no slippage protection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): require the pool's own platform_config
The fallback was a partner-specific address that reverts with
ConstraintAddress (2012). A pool that omits it cannot be traded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): raise when curve data ends before a flag
is_mayhem_mode picks the fee recipient for a buy, and the wrong one reverts
with NotAuthorized (6000), so defaulting it only moves the failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cookbook): trade the coin's own PumpSwap pool, not an arbitrary one
Anyone can open a pool against any mint and getProgramAccounts has no defined
order, so [0] was a stranger's pool. Select on coin_creator, which migration
copies from the bonding curve and nothing else sets, and refuse rather than
guess when there is no single match. --pool trades a specific one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""Wait for the next pump.fun coin over Geyser gRPC, then buy it.
|
|
|
|
WARNING: this submits a real transaction and spends real funds.
|
|
|
|
Usage:
|
|
uv run cookbook/pumpfun/trade/pumpfun_snipe_token_geyser.py
|
|
|
|
The same snipe as `pumpfun_snipe_token_blocksubscribe.py`, detected over a Yellowstone Geyser stream
|
|
instead of a WebSocket subscription. Geyser is the fastest of the four detection
|
|
methods and the only one that needs a separate endpoint — set `GEYSER_ENDPOINT`,
|
|
`GEYSER_API_TOKEN` and `GEYSER_AUTH_TYPE` in `.env`.
|
|
|
|
Buying an existing coin, without the listener, is `pumpfun_buy_token_v2.py`.
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# solana_transaction_status.py lives in cookbook/solana/; the generated geyser stubs live at
|
|
# src/geyser/generated off the repo root.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "solana"))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
|
|
import base58
|
|
import grpc
|
|
import pumpfun_instructions_v2 as pump_v2
|
|
import solana_transaction_status as tx_status
|
|
from dotenv import load_dotenv
|
|
from solana.rpc.async_api import AsyncClient
|
|
from solana.rpc.commitment import Confirmed
|
|
from solana.rpc.core import TxOptsModel
|
|
from solders.account import Account
|
|
from solders.compute_budget import set_compute_unit_price
|
|
from solders.keypair import Keypair
|
|
from solders.message import MessageV0
|
|
from solders.pubkey import Pubkey
|
|
from solders.transaction import VersionedTransaction
|
|
from spl.token.instructions import (
|
|
create_idempotent_associated_token_account,
|
|
)
|
|
|
|
from src.geyser.generated import (
|
|
geyser_pb2,
|
|
geyser_pb2_grpc,
|
|
)
|
|
|
|
# Here and later all the discriminators are precalculated. See cookbook/solana/anchor_calculate_discriminator.py
|
|
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
|
|
TOKEN_DECIMALS = 6
|
|
|
|
# Global constants
|
|
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
|
|
PUMP_GLOBAL = Pubkey.from_string("4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf")
|
|
PUMP_EVENT_AUTHORITY = Pubkey.from_string(
|
|
"Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1"
|
|
)
|
|
PUMP_FEE = Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM")
|
|
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
|
|
|
|
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
|
|
SYSTEM_TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
|
|
SYSTEM_TOKEN_2022_PROGRAM = Pubkey.from_string(
|
|
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
|
)
|
|
SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
|
|
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
|
|
)
|
|
# Defaults for the command line below, not fixed settings.
|
|
DEFAULT_BUY_AMOUNT_SOL = 0.000_01
|
|
DEFAULT_SLIPPAGE = 0.3
|
|
|
|
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")
|
|
GEYSER_API_TOKEN = os.environ.get("GEYSER_API_TOKEN")
|
|
AUTH_TYPE = os.environ.get("GEYSER_AUTH_TYPE", "x-token").lower()
|
|
|
|
BAD_AUTH_TYPE_MSG = "GEYSER_AUTH_TYPE must be 'x-token' or 'basic'"
|
|
|
|
PUMP_CREATE_DISCRIMINATOR = struct.pack("<Q", 8576854823835016728)
|
|
PUMP_CREATE_V2_DISCRIMINATOR = bytes([214, 144, 76, 236, 95, 139, 49, 180])
|
|
|
|
|
|
BondingCurveState = pump_v2.BondingCurveState
|
|
|
|
|
|
async def get_pump_curve_state(
|
|
conn: AsyncClient, curve_address: Pubkey
|
|
) -> BondingCurveState:
|
|
response = await conn.get_account_info(curve_address, encoding="base64")
|
|
if not response.value or not response.value.data:
|
|
raise ValueError("Invalid curve state: No data")
|
|
|
|
data = response.value.data
|
|
if data[:8] != EXPECTED_DISCRIMINATOR:
|
|
raise ValueError("Invalid curve state discriminator")
|
|
|
|
return pump_v2.BondingCurveState(data)
|
|
|
|
|
|
async def _get_mint_account_info(conn: AsyncClient, address: Pubkey) -> Account:
|
|
"""Fetch an account, unwrapping solana-py's `.value` envelope.
|
|
|
|
Adapter for `pump_v2.resolve_quote_token_program`, which wants the account
|
|
object itself (with `.owner` and `.data`) rather than the RPC wrapper.
|
|
|
|
Args:
|
|
conn: Solana RPC client
|
|
address: Account to fetch
|
|
|
|
Raises:
|
|
ValueError: If the account does not exist
|
|
"""
|
|
response = await conn.get_account_info(address, encoding="base64")
|
|
if response.value is None:
|
|
raise ValueError(f"Could not fetch account info for {address}")
|
|
return response.value
|
|
|
|
|
|
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
|
|
"""Price of one whole token in whole units of the curve's quote asset.
|
|
|
|
Args:
|
|
curve_state: Parsed curve state
|
|
|
|
Returns:
|
|
Price in the quote asset
|
|
|
|
Raises:
|
|
ValueError: If reserves are empty
|
|
"""
|
|
price = curve_state.price_per_token()
|
|
if price <= 0:
|
|
raise ValueError("Invalid reserve state")
|
|
return price
|
|
|
|
|
|
async def create_geyser_connection():
|
|
"""Establish a secure connection to the Geyser endpoint using the configured auth type.
|
|
|
|
Raises:
|
|
ValueError: If GEYSER_AUTH_TYPE names a scheme the endpoint does not take
|
|
"""
|
|
if AUTH_TYPE == "x-token":
|
|
auth = grpc.metadata_call_credentials(
|
|
lambda _, callback: callback((("x-token", GEYSER_API_TOKEN),), None)
|
|
)
|
|
elif AUTH_TYPE == "basic":
|
|
auth = grpc.metadata_call_credentials(
|
|
lambda _, callback: callback(
|
|
(("authorization", f"Basic {GEYSER_API_TOKEN}"),), None
|
|
)
|
|
)
|
|
else:
|
|
raise ValueError(BAD_AUTH_TYPE_MSG)
|
|
|
|
creds = grpc.composite_channel_credentials(grpc.ssl_channel_credentials(), auth)
|
|
channel = grpc.aio.secure_channel(GEYSER_ENDPOINT, creds)
|
|
return geyser_pb2_grpc.GeyserStub(channel)
|
|
|
|
|
|
def create_subscription_request():
|
|
"""Create a subscription request for Pump.fun transactions."""
|
|
request = geyser_pb2.SubscribeRequest()
|
|
request.transactions["pump_filter"].account_include.append(str(PUMP_PROGRAM))
|
|
request.transactions["pump_filter"].failed = False
|
|
request.commitment = geyser_pb2.CommitmentLevel.PROCESSED
|
|
return request
|
|
|
|
|
|
def decode_create_instruction_geyser(ix_data: bytes, keys, accounts) -> dict:
|
|
"""Decode a create instruction from Geyser transaction data."""
|
|
# Skip past the 8-byte discriminator prefix
|
|
offset = 8
|
|
|
|
# Extract account keys in base58 format
|
|
def get_account_key(index):
|
|
if index >= len(accounts):
|
|
return "N/A"
|
|
account_index = accounts[index]
|
|
return base58.b58encode(keys[account_index]).decode()
|
|
|
|
# Read string fields (prefixed with length)
|
|
def read_string():
|
|
nonlocal offset
|
|
# Get string length (4-byte uint)
|
|
length = struct.unpack_from("<I", ix_data, offset)[0]
|
|
offset += 4
|
|
# Extract and decode the string
|
|
value = ix_data[offset : offset + length].decode()
|
|
offset += length
|
|
return value
|
|
|
|
def read_pubkey():
|
|
nonlocal offset
|
|
value = base58.b58encode(ix_data[offset : offset + 32]).decode("utf-8")
|
|
offset += 32
|
|
return value
|
|
|
|
name = read_string()
|
|
symbol = read_string()
|
|
uri = read_string()
|
|
creator = read_pubkey()
|
|
|
|
return {
|
|
"name": name,
|
|
"symbol": symbol,
|
|
"uri": uri,
|
|
"creator": creator,
|
|
"mint": get_account_key(0),
|
|
"bondingCurve": get_account_key(2),
|
|
"associatedBondingCurve": get_account_key(3),
|
|
"user": get_account_key(7),
|
|
}
|
|
|
|
|
|
async def listen_for_create_transaction_geyser():
|
|
"""Listen for new token creation using Geyser."""
|
|
print(f"Connecting to Geyser using {AUTH_TYPE.upper()} authentication...")
|
|
stub = await create_geyser_connection()
|
|
request = create_subscription_request()
|
|
|
|
print("Subscribed to Pump.fun transactions via Geyser")
|
|
|
|
async for update in stub.Subscribe(iter([request])):
|
|
# Skip non-transaction updates
|
|
if not update.HasField("transaction"):
|
|
continue
|
|
|
|
tx = update.transaction.transaction.transaction
|
|
msg = getattr(tx, "message", None)
|
|
if msg is None:
|
|
continue
|
|
|
|
# Check each instruction in the transaction
|
|
for ix in msg.instructions:
|
|
# Check which create instruction was used
|
|
token_program = None
|
|
if ix.data.startswith(PUMP_CREATE_DISCRIMINATOR):
|
|
token_program = SYSTEM_TOKEN_PROGRAM
|
|
elif ix.data.startswith(PUMP_CREATE_V2_DISCRIMINATOR):
|
|
token_program = SYSTEM_TOKEN_2022_PROGRAM
|
|
else:
|
|
continue
|
|
|
|
# Skip txs whose create ix references ALT-loaded accounts not in
|
|
# msg.account_keys (indices >= len(msg.account_keys)).
|
|
if any(idx >= len(msg.account_keys) for idx in ix.accounts):
|
|
continue
|
|
|
|
# Found a create instruction
|
|
token_data = decode_create_instruction_geyser(
|
|
ix.data, msg.account_keys, ix.accounts
|
|
)
|
|
|
|
# Add token program info to decoded args
|
|
token_data["token_program"] = str(token_program)
|
|
token_data["is_token_2022"] = token_program == SYSTEM_TOKEN_2022_PROGRAM
|
|
|
|
signature = base58.b58encode(
|
|
bytes(update.transaction.transaction.signature)
|
|
).decode()
|
|
print(f"Transaction signature: {signature}")
|
|
|
|
return token_data
|
|
|
|
|
|
async def buy_token(
|
|
mint: Pubkey,
|
|
bonding_curve: Pubkey,
|
|
associated_bonding_curve: Pubkey,
|
|
creator_vault: Pubkey,
|
|
token_program: Pubkey,
|
|
amount: float,
|
|
slippage: float = 0.25,
|
|
max_retries=5,
|
|
):
|
|
private_key = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
|
|
payer = Keypair.from_bytes(private_key)
|
|
|
|
async with AsyncClient(RPC_ENDPOINT) as client:
|
|
# Fetch bonding curve state for price, mayhem mode and quote asset.
|
|
curve_state = await get_pump_curve_state(client, bonding_curve)
|
|
quote_mint = pump_v2.normalize_quote_mint(
|
|
getattr(curve_state, "quote_mint", None)
|
|
)
|
|
|
|
# Resolve the quote mint before pricing: one read gives the token program
|
|
# -- Token-2022 for every tokenized equity pump.fun admits, and passing
|
|
# SPL Token for one fails the account constraints -- and the decimals the
|
|
# price and cap are in.
|
|
quote_token_program_id = await pump_v2.resolve_quote_token_program(
|
|
quote_mint, lambda pk: _get_mint_account_info(client, pk)
|
|
)
|
|
quote_unit = pump_v2.quote_units(quote_mint)
|
|
|
|
token_price_sol = calculate_pump_curve_price(curve_state)
|
|
token_amount = amount / token_price_sol
|
|
print(f"Quote asset: {quote_mint}")
|
|
|
|
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
|
|
buy_ix = pump_v2.build_buy_v2_instruction(
|
|
base_mint=mint,
|
|
creator=curve_state.creator,
|
|
user=payer.pubkey(),
|
|
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
|
|
max_quote_cost_raw=int(amount * quote_unit * (1 + slippage)),
|
|
quote_mint=quote_mint,
|
|
base_token_program=token_program,
|
|
is_mayhem_mode=curve_state.is_mayhem_mode,
|
|
quote_token_program_id=quote_token_program_id,
|
|
)
|
|
instructions = [
|
|
set_compute_unit_price(1_000),
|
|
create_idempotent_associated_token_account(
|
|
payer.pubkey(), payer.pubkey(), mint, token_program
|
|
),
|
|
]
|
|
# SOL-paired coins settle in native SOL and only seed-check the quote
|
|
# ATA, so creating it would burn rent for nothing. Any other quote --
|
|
# a stablecoin, another coin, a tokenized equity -- needs a real one.
|
|
if not pump_v2.is_sol_paired(quote_mint):
|
|
instructions.append(
|
|
create_idempotent_associated_token_account(
|
|
payer.pubkey(),
|
|
payer.pubkey(),
|
|
quote_mint,
|
|
token_program_id=quote_token_program_id,
|
|
)
|
|
)
|
|
instructions.append(buy_ix)
|
|
recent_blockhash = await client.get_latest_blockhash()
|
|
# The blockhash is fixed for every attempt, so the transaction is built
|
|
# once: each retry resubmits identical bytes.
|
|
transaction = VersionedTransaction(
|
|
MessageV0.try_compile(
|
|
payer.pubkey(), instructions, [], recent_blockhash.value.blockhash
|
|
),
|
|
[payer],
|
|
)
|
|
opts = TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
|
|
|
|
print("Simulating transaction...")
|
|
try:
|
|
sim_result = await client.simulate_transaction(transaction)
|
|
print(f"Simulation result: {sim_result}")
|
|
if sim_result.value.err:
|
|
print(f"Simulation error: {sim_result.value.err}")
|
|
except Exception as e:
|
|
print(f"Simulation failed: {e}")
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
tx_buy = await client.send_transaction(transaction, opts=opts)
|
|
tx_hash = tx_buy.value
|
|
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
|
|
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
|
|
# transaction above is 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:
|
|
wait_time = 2**attempt
|
|
print(f"Retrying in {wait_time} seconds...")
|
|
await asyncio.sleep(wait_time)
|
|
else:
|
|
print("Max retries reached. Unable to complete the transaction.")
|
|
|
|
|
|
async def snipe(amount: float, slippage: float):
|
|
print("Waiting for a new token creation...")
|
|
token_data = await listen_for_create_transaction_geyser()
|
|
print("New token created:")
|
|
print(json.dumps(token_data, indent=2))
|
|
|
|
sleep_duration_sec = 15
|
|
print(
|
|
f"Waiting {sleep_duration_sec}s for the bonding curve account to propagate..."
|
|
)
|
|
await asyncio.sleep(sleep_duration_sec)
|
|
|
|
mint = Pubkey.from_string(token_data["mint"])
|
|
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
|
|
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
|
|
creator_vault = pump_v2.find_creator_vault(
|
|
Pubkey.from_string(token_data["creator"])
|
|
)
|
|
token_program = Pubkey.from_string(token_data["token_program"])
|
|
|
|
# Fetch the token price
|
|
# async with AsyncClient(RPC_ENDPOINT) as client:
|
|
# curve_state = await get_pump_curve_state(client, bonding_curve)
|
|
# token_price_sol = calculate_pump_curve_price(curve_state)
|
|
|
|
# Amount of SOL to spend (adjust as needed)
|
|
|
|
print(f"Bonding curve address: {bonding_curve}")
|
|
print(
|
|
f"Token Program: {token_program} ({'Token2022' if token_data['is_token_2022'] else 'Standard Token'})"
|
|
)
|
|
# print(f"Token price: {token_price_sol:.10f} SOL")
|
|
print(
|
|
f"Buying {amount:.6f} SOL worth of the new token with {slippage * 100:.1f}% slippage tolerance..."
|
|
)
|
|
await buy_token(
|
|
mint,
|
|
bonding_curve,
|
|
associated_bonding_curve,
|
|
creator_vault,
|
|
token_program,
|
|
amount,
|
|
slippage,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Parse the command line and snipe the next coin."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Wait for the next pump.fun coin over Geyser, then buy it"
|
|
)
|
|
parser.add_argument(
|
|
"amount",
|
|
nargs="?",
|
|
type=float,
|
|
default=DEFAULT_BUY_AMOUNT_SOL,
|
|
help=f"SOL to spend (default {DEFAULT_BUY_AMOUNT_SOL})",
|
|
)
|
|
parser.add_argument(
|
|
"--slippage",
|
|
type=float,
|
|
default=DEFAULT_SLIPPAGE,
|
|
help=f"Slippage tolerance (default {DEFAULT_SLIPPAGE})",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
asyncio.run(snipe(args.amount, args.slippage))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|