Skip to main content
Sigvex

Cross-Program State Remediation

How to fix stale account state after cross-program invocations by reloading accounts and re-checking invariants before use.

Cross-Program State Remediation

Overview

Related Detector: Cross-Program State

Any account passed to a CPI as writable can be modified by the callee before control returns. Data read into a local variable before the invoke is stale afterward, and decisions made on that stale copy — balance checks, authority checks, state-machine transitions — no longer describe the account on chain. The fix has two parts: reload every writable account after a CPI, and move invariant checks so they run on the reloaded data, not the pre-CPI snapshot.

Before (Vulnerable)

pub fn settle(ctx: Context<Settle>, amount: u64) -> Result<()> {
    // Snapshot taken before the CPI
    let vault_balance = ctx.accounts.vault.amount;

    token::transfer(ctx.accounts.transfer_ctx(), amount)?;

    // WRONG: vault_balance is stale — the transfer changed it,
    // and the callee may have changed other fields too
    require!(vault_balance >= ctx.accounts.pool.min_reserve, LowReserve);
    ctx.accounts.pool.recorded_balance = vault_balance;
    Ok(())
}

After (Fixed)

pub fn settle(ctx: Context<Settle>, amount: u64) -> Result<()> {
    token::transfer(ctx.accounts.transfer_ctx(), amount)?;

    // Reload deserializes the account from its current on-chain data
    ctx.accounts.vault.reload()?;

    let vault_balance = ctx.accounts.vault.amount;
    require!(vault_balance >= ctx.accounts.pool.min_reserve, LowReserve);
    ctx.accounts.pool.recorded_balance = vault_balance;
    Ok(())
}

reload() re-deserializes the account from the runtime’s view, so every check after it operates on what the CPI actually left behind. Without it, Anchor keeps serving the copy deserialized at instruction entry.

Alternative Mitigations

  • Order operations to avoid the problem. If all reads and checks complete before the CPI and nothing after the CPI depends on the account, no reload is needed. Restructuring the instruction so the CPI is the last effect is often simpler than reloading.
  • Re-derive instead of caching. For values computed from account data (share prices, exchange rates), compute them where they are used rather than hoisting them above a CPI.
  • Split the instruction. When a CPI sits in the middle of a long invariant chain, splitting into two instructions with an explicit intermediate state makes the staleness window visible and testable.

Common Mistakes

Mistake: Reloading the Wrong Account

invoke(&swap_ix, &accounts)?;
ctx.accounts.user_token.reload()?;   // reloaded
// WRONG: pool_token was also writable in the CPI but is never reloaded
let owed = ctx.accounts.pool_token.amount - before;

Reload every account the callee could write, not just the one you expect to change.

Mistake: Checking Invariants Only at Entry

require!(ctx.accounts.vault.state == State::Active, Inactive);
invoke(&external_ix, &accounts)?;
// WRONG: the callee may have moved state out of Active
withdraw(&ctx.accounts.vault)?;

Re-check state-machine invariants after any CPI that can reach the account.

References