Multi-Hop CPI Remediation
Overview
Related Detector: Multi-Hop CPI
Each CPI hop hands your accounts — and, with invoke_signed, your PDA authority — to another program, which may itself invoke further programs. Three failure modes compound with depth: an unvalidated program ID anywhere in the chain lets an attacker splice in a hostile program; state validated before the chain is stale by the end of it; and Solana’s invocation depth limit means a chain that works standalone can fail when composed by an aggregator, aborting mid-sequence. The remediation: pin every program ID, re-validate state between hops, and keep chains short enough to leave depth headroom for callers.
Recommended Fix
Before (Vulnerable)
pub fn route(ctx: Context<Route>, amount: u64) -> Result<()> {
// WRONG: both program accounts are caller-supplied and unchecked
invoke(&swap_ix(ctx.accounts.dex_a.key, amount), &ctx.accounts.leg_a())?;
invoke(&swap_ix(ctx.accounts.dex_b.key, amount), &ctx.accounts.leg_b())?;
Ok(())
}
After (Fixed)
pub fn route(ctx: Context<Route>, amount: u64) -> Result<()> {
// Pin each hop to a known program
require_keys_eq!(*ctx.accounts.dex_a.key, DEX_A_PROGRAM_ID, InvalidProgram);
require_keys_eq!(*ctx.accounts.dex_b.key, DEX_B_PROGRAM_ID, InvalidProgram);
invoke(&swap_ix(ctx.accounts.dex_a.key, amount), &ctx.accounts.leg_a())?;
// Re-validate between hops: leg A changed these balances
ctx.accounts.intermediate.reload()?;
let received = ctx.accounts.intermediate.amount;
require!(received >= minimum_out_a(amount), SlippageExceeded);
invoke(&swap_ix(ctx.accounts.dex_b.key, received), &ctx.accounts.leg_b())?;
ctx.accounts.destination.reload()?;
require!(ctx.accounts.destination.amount >= minimum_out_total(amount), SlippageExceeded);
Ok(())
}
Validating the intermediate balance between hops turns each leg into an independently checked step, so a compromised or misbehaving first leg cannot feed poisoned inputs to the second.
Alternative Mitigations
- Allowlist in a config PDA. When target programs must be updatable, store the approved program IDs in an admin-controlled config account and check hops against it, instead of accepting caller-supplied IDs.
- Flatten the chain. If your program calls A only so A can call B, calling B directly removes a hop, a trust dependency, and a depth level.
- Scope PDA signatures per hop. Use distinct PDAs for distinct hops so a signature minted for leg A is worthless to leg B.
Common Mistakes
Mistake: Validating Only the First Hop
require_keys_eq!(*ctx.accounts.dex_a.key, DEX_A_PROGRAM_ID, InvalidProgram);
invoke(&ix_a, &accounts_a)?;
invoke(&ix_b, &accounts_b)?; // WRONG: second hop unpinned
Every hop your program initiates needs its own program-ID check — trust does not carry over.
Mistake: Ignoring the Depth Budget
A program already three levels deep cannot start a two-hop chain: Solana caps CPI depth, and the abort happens mid-chain at the deepest call. Document your program’s own depth consumption and test composed flows, not just direct entry.