Skip to main content
Sigvex

Multi-Hop CPI Remediation

How to secure multi-hop CPI chains by validating program IDs at every hop, re-validating state between calls, and staying clear of the invocation depth limit.

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.

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.

References