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.
Recommended Fix
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.