Landing transactions on pump.fun
pump.fun buys compete against every other bot watching the same mint, and the winner is usually decided by who reaches the leader first rather than who paid most. This covers bonding curve mechanics, the compute profile of a buy, slippage under contention, and how to structure a sniper that lands.
- pump.fun buys are decided by arrival order far more than by fee, because everyone competing has already raised their fee.
- Most of your latency is detection, not sending. Polling an RPC has already lost before you build anything.
- Pre-build everything that does not depend on the mint. At the moment of truth you should only be deriving addresses and signing.
- Set maxSolCost from how many competitors you will tolerate landing ahead of you, not from a percentage someone picked.
pump.fun launches are the purest form of the delivery problem on Solana. A mint appears, a few hundred automated systems see it within the same second, and they all submit near-identical buys against the same bonding curve account. Ordering decides the outcome and nothing else does.
Which makes it a good lens for the whole subject, because every optimisation either shows up in the fill price or it does not.
The shape of the race#
From mint creation to a landed buy, the clock runs like this:
| Stage | Where the time goes | Can you control it? |
|---|---|---|
| Detection | Noticing the mint exists | Yes, and this is the big one |
| Decision | Deciding whether to buy | Yes, if your logic is cheap |
| Build and sign | Constructing the transaction | Yes, mostly by pre-computing |
| Delivery | Reaching the leader | Yes, through your path |
| Admission | The leader reading your packet | Only via stake |
| Ordering | Scheduling among what was read | Via fee, with a ceiling |
Notice how much of this happens before you send anything. People obsess over fees, which is the second-to-last stage, while losing hundreds of milliseconds in the first one.
The bonding curve#
A pump.fun token trades against a constant-product bonding curve seeded with virtual reserves. Buys move the price up along the curve; sells move it down. When the curve accumulates enough SOL the token graduates to a conventional AMM pool.
Two consequences matter for a buyer:
- Price is deterministic given reserves. There is no oracle and no external price. If you know the reserve state at execution, you know exactly what you pay.
- Every buy ahead of you raises your price. Not probabilistically. Mechanically, by an amount you can compute.
That second point is what makes slippage on a curve a different problem from slippage on an AMM, and we come back to it below.
Detection is most of the latency#
Here is the uncomfortable arithmetic. If you are polling an RPC for new pools every 500ms, your average detection latency is 250ms and your worst case is 500ms, before you have executed a single line of your own logic.
Meanwhile a competitor on a Geyser stream saw the mint within a few milliseconds of the validator processing it. You are not slightly behind. You are behind by more than the entire rest of the pipeline costs.
1use anyhow::Result;2use futures::StreamExt;3use yellowstone_grpc_client::GeyserGrpcClient;4use yellowstone_grpc_proto::prelude::*;5use std::collections::HashMap;67const PUMP_PROGRAM: &str = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";89/// Watch for new mints at the source.10///11/// This is where the race is actually won or lost. Polling an RPC for new12/// pools costs you hundreds of milliseconds of staleness before you have done13/// anything at all; by the time a poll returns, the transaction you are14/// reacting to is old news and forty other bots already saw it.15///16/// A Geyser stream pushes account and transaction updates as the validator17/// processes them. The difference is not incremental, it is the difference18/// between competing and not.19pub async fn watch_new_mints(endpoint: String, token: Option<String>) -> Result<()> {20 let mut client = GeyserGrpcClient::build_from_shared(endpoint)?21 .x_token(token)?22 .connect()23 .await?;2425 let mut transactions = HashMap::new();26 transactions.insert(27 "pump".to_string(),28 SubscribeRequestFilterTransactions {29 vote: Some(false),30 failed: Some(false),31 account_include: vec![PUMP_PROGRAM.to_string()],32 ..Default::default()33 },34 );3536 let request = SubscribeRequest {37 transactions,38 // "processed" is the earliest you can see anything. Waiting for39 // confirmed adds a slot you cannot afford.40 commitment: Some(CommitmentLevel::Processed as i32),41 ..Default::default()42 };4344 let (_sink, mut stream) = client.subscribe_with_request(Some(request)).await?;4546 while let Some(message) = stream.next().await {47 let Ok(update) = message else { continue };48 if let Some(UpdateOneof::Transaction(tx)) = update.update_oneof {49 // Decode, look for a create instruction, extract the mint, and50 // hand it to the builder. Every microsecond spent in here is51 // a microsecond your competitors are not spending.52 handle_transaction(tx).await?;53 }54 }55 Ok(())56}
processed commitment. Waiting for confirmed adds a slot, and a slot is 400ms, which in this context is an eternity. You are accepting a small chance of reacting to a block that gets dropped, and that is a much better trade than being reliably late.If you take one thing from this post: fix detection before you touch anything else. It is the largest single term in the budget, and no amount of fee tuning compensates for seeing the opportunity last.
Pre-building the transaction#
Once you have detected a mint you have a few hundred milliseconds. Every allocation, every derivation, every network call in that window is spent competing against people who did the work in advance.
1import {2 Connection, Keypair, PublicKey, TransactionInstruction,3 TransactionMessage, VersionedTransaction, ComputeBudgetProgram,4} from "@solana/web3.js";56/**7 * Pre-build everything that does not depend on the mint.8 *9 * When a mint appears you have a few hundred milliseconds before the10 * opportunity is gone. Anything you can compute in advance should already be11 * computed: budget instructions, your own account keys, the program IDs, the12 * serialization scaffolding. The only work left at the moment of truth should13 * be deriving the mint-specific addresses and signing.14 */15export class PumpBuyer {16 private budgetIxs: TransactionInstruction[];1718 constructor(19 private rpc: Connection,20 private payer: Keypair,21 private program: PublicKey,22 opts: { computeUnits: number; microLamports: number },23 ) {24 // Built once. These never change per mint.25 this.budgetIxs = [26 ComputeBudgetProgram.setComputeUnitLimit({ units: opts.computeUnits }),27 ComputeBudgetProgram.setComputeUnitPrice({ microLamports: opts.microLamports }),28 ];29 }3031 /** Everything mint-specific, derived synchronously. No await, no RPC. */32 private derive(mint: PublicKey) {33 const [bondingCurve] = PublicKey.findProgramAddressSync(34 [Buffer.from("bonding-curve"), mint.toBuffer()],35 this.program,36 );37 const [associatedCurve] = PublicKey.findProgramAddressSync(38 [bondingCurve.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()],39 ASSOCIATED_TOKEN_PROGRAM_ID,40 );41 const [userAta] = PublicKey.findProgramAddressSync(42 [this.payer.publicKey.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()],43 ASSOCIATED_TOKEN_PROGRAM_ID,44 );45 return { bondingCurve, associatedCurve, userAta };46 }4748 /**49 * Build and sign. The blockhash comes from a cache that a background task50 * keeps fresh, so this function never touches the network.51 */52 build(mint: PublicKey, blockhash: string, solIn: bigint, maxSolCost: bigint) {53 const { bondingCurve, associatedCurve, userAta } = this.derive(mint);5455 const message = new TransactionMessage({56 payerKey: this.payer.publicKey,57 recentBlockhash: blockhash,58 instructions: [59 ...this.budgetIxs,60 createAssociatedTokenAccountIdempotentInstruction(61 this.payer.publicKey, userAta, this.payer.publicKey, mint,62 ),63 buyInstruction({64 program: this.program,65 mint, bondingCurve, associatedCurve, userAta,66 user: this.payer.publicKey,67 amount: solIn,68 maxSolCost,69 }),70 ],71 }).compileToV0Message();7273 const tx = new VersionedTransaction(message);74 tx.sign([this.payer]);75 return tx;76 }77}
The discipline is simple: at the moment of truth you should be doing three things and nothing else.
- Derive the mint-specific addresses, synchronously, with no await.
- Assemble the message from pre-built parts.
- Sign and send.
The blockhash must come from a cache a background task keeps fresh. Fetching one here costs a network round trip on the critical path and burns window you cannot spare. That pattern is in the blockhash lifecycle.
Slippage under contention#
This is where most snipers are quietly wrong. A percentage tolerance is a number chosen by feel, and it maps to nothing real. On a bonding curve you can compute exactly what you will pay if N competitors land ahead of you.
1/**2 * Slippage on a bonding curve is not the same problem as slippage on an AMM.3 *4 * The curve is deterministic: given the reserves at the moment your5 * transaction executes, the price is exactly computable. The uncertainty is6 * not about the maths, it is about how many buys land ahead of you.7 *8 * So the right way to think about maxSolCost is "how many competitors am I9 * willing to be behind", not "what percentage feels safe".10 */11export function priceAfterBuys(12 virtualSolReserves: bigint,13 virtualTokenReserves: bigint,14 tokensOut: bigint,15): bigint {16 // Constant product: solIn = (reserveSol * tokensOut) / (reserveTokens - tokensOut)17 return (virtualSolReserves * tokensOut) / (virtualTokenReserves - tokensOut);18}1920/**21 * Work out what you would pay if N competitors of a typical size land first.22 * That number, not a percentage, is what maxSolCost should be.23 */24export function maxCostBehind(25 virtualSolReserves: bigint,26 virtualTokenReserves: bigint,27 yourTokens: bigint,28 competitorsAhead: number,29 typicalCompetitorTokens: bigint,30): bigint {31 let sol = virtualSolReserves;32 let tokens = virtualTokenReserves;3334 for (let i = 0; i < competitorsAhead; i += 1) {35 const cost = priceAfterBuys(sol, tokens, typicalCompetitorTokens);36 sol += cost;37 tokens -= typicalCompetitorTokens;38 }3940 return priceAfterBuys(sol, tokens, yourTokens);41}4243// Being explicit beats a percentage:44// "I will pay up to what it costs if 8 buys of 0.5 SOL land ahead of me"45// is a decision. "5% slippage" is a number somebody picked.
So set your tolerance as a decision about competition, not as a percentage. “I will pay up to what it costs if eight buys of half a SOL land ahead of me” is a position you can defend. “Five percent” is a number somebody typed once.
The two failure modes are asymmetric and both are real:
- Too tight and you fail on chain, having paid the fee. You lose the fee and the opportunity.
- Too loose and you fill at a price the launch does not justify. You lose the difference, which is usually much larger than the fee.
The compute profile#
A pump.fun buy is not expensive: roughly 35,000 to 80,000 units depending on whether it creates a token account. The 1.4 million unit default that sniper templates love is off by a factor of twenty.
That factor is not academic. It is a direct multiplier on your priority fee:
| Requested units | Bid | Priority fee |
|---|---|---|
| 1,400,000 | 500,000 µL/CU | 700,000 lamports |
| 80,000 (measured) | 500,000 µL/CU | 40,000 lamports |
Same bid, same scheduling position, seventeen times the cost. Measure your units once and keep the difference. The method is in compute units explained.
Pre-creating accounts#
Creating an associated token account inline costs about 25,000 units and adds instructions to the critical path. Use the idempotent creation instruction so a repeat is harmless, and where you know the mint in advance, create it beforehand.
Also keep enough wrapped SOL ready rather than wrapping inside the buy. Wrapping is a separate instruction, more units, and more that can fail at the worst moment.
A realistic latency budget#
Where the time actually goes, for a well-built sniper and a naive one.
| Stage | Naive | Tuned |
|---|---|---|
| Detection | 250 to 500ms (polling) | 2 to 15ms (Geyser) |
| Decision logic | 10 to 50ms | under 1ms |
| Blockhash | 50 to 200ms (fetched inline) | 0ms (cached) |
| Build and sign | 5 to 20ms | 1 to 3ms |
| Delivery to leader | 50 to 300ms (shared RPC) | 5 to 20ms (direct or relay) |
| Total | ~365 to 1070ms | ~9 to 39ms |
An order of magnitude, and most of it comes from two changes: stream instead of poll, and cache the blockhash. Neither requires infrastructure. Both are an afternoon.
An honest word about this#
We sell transaction delivery, so treat the following as coming from an interested party.
Delivery is the last term in that budget, and it is the smallest one for most people reading this. If you are polling an RPC for mints, your delivery path is not your problem and no relay will fix it. Fix detection. Then fix the blockhash. Then measure your compute. Those three are free.
Delivery becomes the binding constraint once the rest is tight, and you will know because your losses stop correlating with anything you control. That is the situation described in why your transactions are not landing, and it is a real one. It is just not the first one.