Building a Solana Transaction Sender in Rust

solana-client in production: nonblocking RpcClient, connection reuse, the send config that matters, and a retry loop that stops at expiry.

BoltTx Team··9 min read
solanarustsolana-clienttransaction-landingtrading-botasync

Most Rust examples for Solana show you how to send one transaction. Production senders differ in four specific places, and the defaults are wrong for all four.

Use the Nonblocking Client

solana_client::rpc_client::RpcClient is synchronous. It blocks the thread it runs on, which is fine for a script and wrong for anything sending concurrently.

use solana_client::nonblocking::rpc_client::RpcClient;
use solana_sdk::commitment_config::CommitmentConfig;

let client = RpcClient::new_with_commitment(
    rpc_url.to_string(),
    CommitmentConfig::confirmed(),
);

★The nonblocking client shares an underlying HTTP client, which means connection reuse comes for free.★ Construct one and share it via Arc rather than creating a client per task — a fresh client per send means a fresh TLS handshake per send.

use std::sync::Arc;

let client = Arc::new(RpcClient::new(rpc_url.to_string()));

// Every task clones the Arc, not the client.
let c = Arc::clone(&client);
tokio::spawn(async move { submit(c, tx).await });

The Send Config That Matters

RpcSendTransactionConfig defaults are tuned for correctness, not for trading:

use solana_client::rpc_config::RpcSendTransactionConfig;
use solana_sdk::commitment_config::CommitmentLevel;

let config = RpcSendTransactionConfig {
    skip_preflight: true,                              // ★default is false★
    preflight_commitment: Some(CommitmentLevel::Processed),
    max_retries: Some(0),                              // ★default is None★
    ..Default::default()
};

let sig = client.send_transaction_with_config(&tx, config).await?;

skip_preflight: true. Preflight costs a round trip and simulates against the current slot, which is not the slot you will land in. Simulate during development instead.

max_retries: Some(0). None lets the RPC retry on its own schedule. With your loop also retrying, two components resend on different clocks and behaviour becomes impossible to reproduce. ★Only your loop knows when the blockhash expires.★

If your Rust sender is already tuned and transactions still miss, a free BoltTx key is one line of config to test against.

Caching the Blockhash

Fetching a blockhash in the hot path is a round trip you cannot afford. Refresh it in the background:

use tokio::sync::RwLock;
use solana_sdk::hash::Hash;

#[derive(Clone)]
struct BlockhashCache {
    inner: Arc<RwLock<(Hash, u64)>>,   // (blockhash, last_valid_block_height)
}

impl BlockhashCache {
    async fn spawn(client: Arc<RpcClient>) -> Self {
        let initial = client
            .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
            .await
            .expect("initial blockhash");
        let cache = Self { inner: Arc::new(RwLock::new(initial)) };

        let bg = cache.clone();
        tokio::spawn(async move {
            let mut tick = tokio::time::interval(Duration::from_secs(5));
            loop {
                tick.tick().await;
                if let Ok(v) = client
                    .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
                    .await
                {
                    *bg.inner.write().await = v;
                }
            }
        });
        cache
    }

    async fn get(&self) -> (Hash, u64) {
        *self.inner.read().await
    }
}

★RwLock rather than Mutex★ — reads vastly outnumber writes, and every send is a read.

The Retry Loop

The pattern that actually lands transactions:

use solana_sdk::signature::Signature;
use std::time::Duration;

async fn submit_until_landed(
    client: &RpcClient,
    tx: &impl solana_sdk::transaction::SerializableTransaction,
    last_valid_block_height: u64,
) -> anyhow::Result<Option<Signature>> {
    let config = RpcSendTransactionConfig {
        skip_preflight: true,
        max_retries: Some(0),
        ..Default::default()
    };

    let sig = client.send_transaction_with_config(tx, config).await?;

    loop {
        let height = client.get_block_height().await?;
        if height > last_valid_block_height {
            return Ok(None);              // ★expired: rebuild, do not resend★
        }

        let statuses = client.get_signature_statuses(&[sig]).await?;
        if statuses.value[0].is_some() {
            return Ok(Some(sig));         // landed; inspect .err for outcome
        }

        // Same bytes, same signature — included at most once.
        let _ = client.send_transaction_with_config(tx, config).await;
        tokio::time::sleep(Duration::from_millis(400)).await;   // ~one slot
    }
}

★No backoff.★ Each attempt is a fresh chance at a new block producer, and the validity window is short. Backing off means fewer attempts in a window you cannot extend.

Compute Budget and Fees

The instructions go first, and the values should be derived rather than hardcoded:

use solana_sdk::compute_budget::ComputeBudgetInstruction;

// Fees are contested per account, not globally.
let recent = client
    .get_recent_prioritization_fees(&writable_accounts)
    .await?;
let mut fees: Vec<u64> = recent.iter().map(|f| f.prioritization_fee).collect();
fees.sort_unstable();
let median = fees.get(fees.len() / 2).copied().unwrap_or(0);

let mut instructions = vec![
    ComputeBudgetInstruction::set_compute_unit_limit(measured_units * 12 / 10),
    ComputeBudgetInstruction::set_compute_unit_price((median * 2).max(5_000)),
];
instructions.extend(your_instructions);

★measured_units should come from simulation, not a guess.★ Leaving the limit at its default means being charged against a figure well above real usage, which wastes budget precisely when fees are high.

Why Rust, and Why Not

Since this is the most argued decision, the honest version:

★Raw execution speed is rarely the differentiator.★ Signing, serialization, and instruction building are microsecond-scale work against network time measured in milliseconds. Rewriting a working TypeScript bot in Rust does not, by itself, change which slot you land in.

What Rust does give you:

No garbage collection pauses. A Node.js major GC can occasionally exceed a slot, and it happens under load when your queues are deepest.

Tighter timing distribution. For a strategy living in the tail rather than the median, that consistency is the real argument.

Lower-level connection control. Easier to guarantee no handshake in the hot path.

★If your existing bot lands within two slots consistently, a rewrite will not improve your fill rate.★ Measure the slot distribution before committing to one.

Error Handling That Distinguishes Cases

ClientError covers everything from a network timeout to a program failure. Treating them the same is how bots retry unretryable failures:

use solana_client::client_error::ClientErrorKind;

match client.send_transaction_with_config(&tx, config).await {
    Ok(sig) => { /* submitted; now confirm */ }
    Err(e) => match e.kind() {
        // Transport problems — retry with backoff.
        ClientErrorKind::Reqwest(_) => { /* backoff, retry */ }
        // ★The RPC rejected it — retrying identical bytes will not help.★
        ClientErrorKind::RpcError(_) => { /* inspect, likely rebuild */ }
        _ => { /* log and surface */ }
    },
}

★A transport error may mean the transaction was submitted successfully and only the response was lost.★ Check the signature status before assuming it failed.

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★If your Rust sender is tuned as above and your own numbers are still materially worse, what remains is routing★ — the one thing no client-side change reaches.

Where BoltTx Fits

We handle the hop after your client. Not indexing, not streaming.

Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands. You sign locally — we never hold funds, never sign, and never modify transaction contents.

The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.

Get a free API key. No monthly fee:

let client = RpcClient::new(
    "https://la.bolttx.io/?api-key=YOUR_KEY".to_string()
);

FAQ

Should I use RpcClient or the nonblocking version? The nonblocking one for anything concurrent. The synchronous client blocks its thread, which is fine for a script and wrong for a bot sending several transactions at once.

How do I reuse connections in solana-client? Construct one client and share it via Arc. The nonblocking client holds a pooled HTTP client internally, so a shared instance reuses connections. A client per send means a TLS handshake per send.

What send config should a Rust bot use? skip_preflight: true and max_retries: Some(0). The first removes a round trip that simulates the wrong slot; the second stops the RPC from retrying on a schedule your own loop cannot see.

Why set max_retries to zero in Rust? Because None lets the RPC retry independently. With your loop also resending, two components run on different clocks and you cannot reproduce a failure. Only your loop knows the expiry height.

How do I cache a blockhash in Rust? Store it behind an RwLock and refresh on a background tokio::time::interval. Reads vastly outnumber writes, so RwLock beats Mutex, and the hot path never makes a network call.

Is Rust faster than TypeScript for Solana bots? For raw execution, yes, but that portion is negligible against network time. The real advantages are the absence of GC pauses and a tighter timing distribution, which matter for tail-sensitive strategies.

Should I rewrite my TypeScript bot in Rust? Measure first. If it already lands within two slots consistently, a rewrite will not improve fill rate. If you see occasional multi-slot outliers that correlate with load, GC may be the cause and Rust helps.

How do I handle ClientError correctly? Match on ClientErrorKind. Transport errors are retryable with backoff, but check the signature status first — the transaction may have been submitted and only the response lost. RPC rejections usually need a rebuild.

How do I set compute budget instructions in Rust? ComputeBudgetInstruction::set_compute_unit_limit and set_compute_unit_price, prepended before your instructions. Derive the limit from simulation and the price from recent fees on your writable accounts.

What retry interval should a Rust sender use? About one slot. Each resend is a fresh chance at a new block producer, and the validity window is short. Do not add backoff here — that is for rate limits, not for inclusion.

Is it safe to resend the same transaction in Rust? Yes. Identical signed bytes produce an identical signature, and Solana includes any signature at most once. Continuous resubmission until expiry is the standard pattern.

How do I know when to stop retrying? Compare get_block_height() against the last_valid_block_height returned with your blockhash. Past it, the transaction is permanently invalid and you must rebuild rather than resend.

Does solana-client support versioned transactions? Yes. Build a VersionedTransaction and send it through the same methods. Reading them back requires setting the max supported version, the same as in the JavaScript client.

Should I spawn a task per transaction? Generally yes, with a shared Arc<RpcClient>. What you should not do is construct a client per task, since that discards connection reuse and adds a handshake to every send.

How do I measure slot distance in Rust? Record get_slot() before submitting and read the landed slot from get_signature_statuses. Track the difference as a distribution rather than an average, bucketed by network condition.

Why does my Rust bot perform the same as the TypeScript one? Because both are bounded by network time rather than compute. That is the expected result, and it means your optimisation effort belongs in routing, fees, and retry behaviour rather than in the language.

← Back to all posts