Rust for Smart Contracts & Performance-Sensitive Systems
Rust shows up in two very different places on a serious Web3 project: on-chain (Solana programs, and increasingly EVM precompile-adjacent tooling) and off-chain (the trading backends, ingesters, and game engines that talk to those contracts). Both benefit from the same things - memory safety, predictable performance, strong types - but the patterns that matter are different.
This post is the shortlist I actually reach for when writing Rust for smart contracts and for the performance-sensitive systems that drive them.
Why Rust, honestly
Marketing says "memory safety and zero-cost abstractions". In production I care about three things:
- Predictable latency. No GC pauses and tight control over allocations means p99 is close to p50. That is the difference between "lands the bundle" and "misses the slot".
- Types that make illegal states unrepresentable. An
enumwith exhaustive matching will catch more bugs than any test suite. - The compiler is the first reviewer. On a smart contract, a data race or
double-free is not a segfault - it is a lost treasury. Rust rejects entire
classes of those before you run
cargo test.
On-chain Rust: Solana programs
On Solana, Rust is the language. Anchor is the framework. The patterns that keep a program safe and cheap:
1. Account model first, code second
You design accounts before you write instructions. Which PDA seeds? Which
authority? What's owned by the program? Skipping this step is how you end up with
remaining_accounts spaghetti and CPIs that surprise you.
#[account]
pub struct MarketState {
pub authority: Pubkey,
pub bump: u8,
pub total_volume: u64,
pub fee_bps: u16,
pub paused: bool,
}
#[derive(Accounts)]
pub struct PlaceTrade<'info> {
#[account(
mut,
seeds = [b"market", market.key().as_ref()],
bump = market_state.bump,
constraint = !market_state.paused @ ErrorCode::MarketPaused,
)]
pub market_state: Account<'info, MarketState>,
#[account(mut)]
pub trader: Signer<'info>,
// ...
}constraint = ... @ ErrorCode::X is the pattern I use everywhere - the account
validation and the error message live in the same place, which is much easier to
audit than if-statements scattered through the instruction body.
2. Compute-unit budget is a feature
You have ~200k CU to spend per instruction (1.4M per tx). Every CPI, every
Copy-style clone, every large serialization costs budget. Two habits:
- Prefer
AccountInfo+ manual deserialization on hot paths. - Use
zero_copyaccounts for large structs (orderbook state, game tables).
#[account(zero_copy)]
#[repr(C)]
pub struct Orderbook {
pub bids: [Order; 1024],
pub asks: [Order; 1024],
}3. Checked arithmetic, always
let new_balance = user
.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;Unchecked arithmetic + user-controlled inputs is a recurring pattern in exploit reports. Anchor's default panics are safer than EVM-style silent wrapping, but explicit checks read better to auditors and are cheap.
4. CPIs with signer seeds, not hope
When the program signs on behalf of a PDA (SPL-Token transfers from a vault, re-distributing fees, etc.), use the signer-seeds form:
let seeds = &[b"vault", market.key().as_ref(), &[vault_bump]];
let signer_seeds = &[&seeds[..]];
token::transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
Transfer {
from: vault.to_account_info(),
to: recipient.to_account_info(),
authority: vault_authority.to_account_info(),
},
signer_seeds,
),
amount,
)?;Don't reinvent signing with invoke_signed by hand unless you have to.
Off-chain Rust: performance-sensitive backends
Off-chain, Rust earns its keep in the services that have to react in milliseconds: ingesters, routers, bundlers, game engines, market makers. The pattern that keeps those systems honest:
1. Tokio is the runtime, not a toolbox
Pick a structure early:
tokio::spawnper long-lived task (ingest, executor, risk, metrics).tokio::sync::mpscfor backpressure-aware channels between tasks.tokio::sync::broadcastfor fan-out of market events.tokio::select!for shutdown + timers.
let (tx, mut rx) = tokio::sync::mpsc::channel::<Update>(1024);
tokio::spawn(async move {
while let Some(update) = rx.recv().await {
if let Err(e) = strategy.on_update(update).await {
tracing::error!(?e, "strategy error");
}
}
});Channels with bounded capacity are non-negotiable. An unbounded channel hides backpressure until OOM.
2. Zero-copy parsing on the hot path
Whenever you're decoding gRPC/geyser messages at thousands of events per second,
avoid .clone() and String::from(...) in the decode path. Use Bytes,
&[u8], and borrowed data all the way to the strategy.
3. Async traits without tears
#[async_trait::async_trait]
pub trait Executor: Send + Sync {
async fn submit(&self, tx: PreparedTx) -> Result<Signature, ExecError>;
}Mock the trait in tests. Swap Jito / Nozomi / direct-RPC implementations at runtime via config. This is how you A/B-test inclusion providers without rewriting the strategy.
4. Error types that say what happened
#[derive(thiserror::Error, Debug)]
pub enum ExecError {
#[error("jito bundle rejected: {0}")]
JitoRejected(String),
#[error("rpc error: {0}")]
Rpc(#[from] solana_client::client_error::ClientError),
#[error("risk rejected: {0:?}")]
Risk(crate::risk::RiskError),
}anyhow everywhere is tempting. In code you'll actually have to alert on,
thiserror variants pay for themselves - you can pattern-match in the metrics
layer and count specific failures.
5. Instrumentation is not optional
use tracing::{info, instrument};
#[instrument(skip(ctx), fields(strategy = %self.name))]
pub async fn on_signal(&self, ctx: &Ctx, signal: Signal) -> anyhow::Result<()> {
info!(?signal, "signal received");
// ...
Ok(())
}Structured tracing spans + a metrics crate (metrics, prometheus) are the
lowest-effort, highest-leverage instrumentation I know.
Shared patterns: where on-chain meets off-chain
The most common mistake I see in Web3 Rust codebases: repeating types between on-chain and off-chain. Every discriminator, every account struct, every enum drifts.
Fix:
- Put on-chain types in a
*-programcrate. - Expose a
*-clientcrate that re-exports them and adds IDL-free helpers. - Off-chain services depend on
*-client, never on a hand-rolled copy of the types.
programs/my_market # on-chain, #[program]
client/my-market-client # re-exports types + pure helpers
bots/my-market-arb # depends on client
When you change an account layout, the bot refuses to compile. That's what you want.
Security checklist for Rust smart contracts
A compressed version of what I run through before a mainnet deploy:
- All arithmetic uses
checked_*orsaturating_*on user-controlled values. - Every account has explicit
owner/signer/seeds/bumpconstraints. - No
unwraporexpectin instructions; only typed errors withErrorCodevariants. - CPIs use
new_with_signerwhere the program authority signs. - Reentrancy-equivalent state (ongoing actions, in-progress rounds) is protected by a status enum checked at entry and updated before CPIs.
- Admin-only instructions are gated by
constraint = auth.key() == state.authority. -
close = receiveron instruction accounts that should be closed on exit paths; any lamports left behind is a subtle exploit surface. - Tests cover happy path, revert path, and at least one fuzzed input.
Performance checklist for off-chain Rust
- Every channel is bounded. Backpressure is a feature, not a bug.
- No allocation inside the decode/dispatch loop.
Bytesand&[u8]only. - One Tokio runtime. No nested block_on.
-
#[instrument]on every public async method of a hot component. - Metrics exported over HTTP, scraped by Prometheus or equivalent.
- Crash-only design - supervised by systemd / Kubernetes. Don't try to recover in-process from arbitrary errors; log, bail, restart.
Conclusion
Rust earns its reputation in Web3 for the same reason it earns it in kernels and browsers: when you need the system to be both safe and fast, almost nothing else gets you there with so little ceremony.
The trick is to stop treating "on-chain Rust" and "off-chain Rust" as different languages. The patterns above - typed state, bounded channels, checked arithmetic, signer-seed CPIs, instrumented async - compose. You use them in the smart contract, you use them in the bundler, and the whole system stays honest.