Cross-Program Invocation Reentrancy Risk in Solana DeFi
CPI does not have EVM-style reentrancy, but recursive CPI chains and sysvar manipulation open distinct attack surfaces — this post maps every known CPI exploit pattern and shows the Anchor constraints that close them. Essential reading before you audit a program that calls AMM pools mid-instruction.
Solana's cross-program invocation reentrancy risk is not the same beast as the EVM's infamous call-then-drain loop, but it is real, it has cost protocols real money, and most developers writing Anchor programs that touch AMM pools or lending markets mid-instruction are not thinking about it carefully enough. This article maps the actual attack surface, names specific exploit patterns, and shows the constraints that close each one.
Why Solana CPI Is Not EVM Reentrancy — and Why That Framing Is Dangerous
On the EVM, reentrancy happens because call transfers control to external code before state is written. The classic fix is checks-effects-interactions: write your state first, then call out.
Solana's execution model is fundamentally different. An instruction runs inside a single BPF virtual machine context. When you issue a CPI with invoke or invoke_signed, the runtime hands control to the callee program synchronously within the same transaction slot — there is no async gap. Crucially, Solana does not allow a callee to re-invoke the original caller in the same instruction context. The runtime maintains a call depth counter (hard-capped at 4 as of recent versions) and will reject a recursive call back up the chain.
So the "re-entrancy" framing from EVM is misleading. The attack surfaces that actually exist are different:
- Shared mutable account references across CPI depth
- Account constraint bypasses via CPI signer escalation
- State that is read before a CPI and stale after it
- Sysvar clock / epoch manipulation within a single slot
The Shared Mutable Account Problem
Solana's account model requires you to pass every account an instruction touches as an explicit argument. When your program issues a CPI, the callee gets references to a subset of those accounts. Here is where things go wrong: if both programs hold a &mut Account<T> reference to the same account, the second write can silently overwrite the first's intermediate state.
Consider a vault program that:
- Reads a pool's
token_balancefield from account A - CPIs into an AMM to execute a swap (which mutates account A)
- Uses the pre-CPI
token_balanceto compute a fee
After step 2, the cached value in step 3 is stale. If the AMM drained or repriced the pool mid-instruction, your fee calculation is wrong — and in adversarial conditions, an attacker-controlled AMM can manipulate exactly what value you see post-CPI.
The fix: never cache account field reads across a CPI boundary. Re-read from the account's data slice after the CPI returns. In Anchor, reload() an account after a CPI that may have mutated it:
ctx.accounts.pool.reload()?;
let post_cpi_balance = ctx.accounts.pool.token_balance;
Signer Privilege Escalation via invoke_signed
invoke_signed lets your program sign CPIs with a PDA it controls. The risk is that a carelessly constructed PDA seed set can be spoofed. If your seeds include a user-supplied field — a token mint, a user pubkey, a market ID — an attacker can construct an account that satisfies your PDA derivation but points to a program-controlled address they own.
The Anchor seeds constraint closes most of this, but only if you also add bump binding and do not accept the bump from user-supplied data. The canonical safe pattern:
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump = vault.bump, // stored at init time, not user-supplied
)]
pub vault: Account<'info, Vault>,
Accepting a user-supplied bump and passing it to find_program_address is the antipattern. The bump must come from your own stored state.
The CPI Depth Limit as a DoS Vector
The runtime's CPI depth limit (currently 4, not including the top-level instruction) is a guard rail, but it can be turned into a denial-of-service vector. A protocol that issues 3 levels of CPI as part of normal operation leaves only one level of headroom. A grief attack triggers your instruction in a context that has already consumed depth — perhaps as a callback from a composable protocol — and your transaction fails at the CPI limit.
Practical rule: if your program does legitimate 2-deep CPI, design your public interface to document this explicitly. Protocols that call your program need to know how much CPI budget they are consuming. Treat depth as a shared resource in composable DeFi, not an internal implementation detail.
Account Confusion Attacks Mid-CPI
A subtler variant: your program validates account ownership and discriminator at instruction entry — correct Anchor behavior. But if you accept a program ID or account address as a parameter (rather than hardcoding it), an attacker can pass a malicious program that mimics the expected interface.
This is the CPI equivalent of a phishing program. Your code calls invoke with a user-supplied program_id. The callee is adversarial. It will:
- Return success (so your transaction does not revert)
- Mutate shared accounts to values you do not expect
- Leave your program's post-CPI invariants broken
The fix is always hardcode expected program IDs and validate them at constraint time:
#[account(address = EXPECTED_AMM_PROGRAM_ID)]
pub amm_program: UncheckedAccount<'info>,
If you need configurability, store the allowed program ID at protocol initialization and validate against that stored value — never against user call data.
Anchor Constraints That Actually Close These Vectors
Anchor's constraint system, used correctly, eliminates most CPI attack surface at compile time. The constraints that matter most for CPI security:
has_one— enforces that a foreign key on one account matches the pubkey of another passed account. Prevents account substitution.owner— validates which program owns an account. Use this on any account whose data you interpret but that was not created by your program.address— exact pubkey equality. Use for program IDs, sysvars, and known singleton accounts.seeds+bump— PDA validation with stored bump. Never derive the bump at CPI call time from a user parameter.constraint = expr— arbitrary invariant checked before instruction body runs. Put your pre-CPI balance assertions here; add post-CPI assertions withreload()and a manualrequire!.
The pattern to internalize: validate everything at account resolution, not inside instruction logic. By the time your instruction body runs, every account should already be proven. CPI calls within the body should only touch accounts whose identity has been structurally verified.
What This Means in Production
We review these vectors on every smart contract engagement we run, because protocols that compose heavily — vaults calling AMMs calling lending markets — stack CPI risks additively. Each hop is another opportunity for stale state, privilege confusion, or depth exhaustion.
The attack patterns above are not theoretical. The Solend exploit of 2022 involved stale oracle reads around CPI boundaries. The Crema Finance hack used a forged tick-account that passed instruction-entry checks but was substituted mid-CPI. The common thread: the vulnerability was invisible if you only audited the program in isolation, and only visible when you traced what account state looked like across the CPI boundary.
If you are building a protocol that issues CPIs into AMM pools, lending markets, or other composable programs, the audit checklist is: trace every account that is both passed to a CPI and read after it returns; verify every PDA uses a stored bump; hardcode all program IDs; and set reload() calls as a discipline after any CPI that touches shared accounts.
If you are about to ship a Solana program with CPI into external protocols and want a second pair of eyes before mainnet, reach out — we do security reviews as part of our smart contract development work and have caught every pattern described here in real client code.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article