Read-Only Reentrancy Remediation
How to prevent read-only reentrancy attacks where stale state is observed mid-CPI.
Read-Only Reentrancy Remediation
Overview
Related Detector: Read-Only Reentrancy
Read-only reentrancy occurs when a program exposes a “view” function that reads state, and another program invokes that view in the middle of an operation that has not yet committed its updates. The reader sees stale data and acts on it — minting wrong amounts, accepting bad collateral ratios, or distributing rewards based on pre-update balances.
Recommended Fix
Before (Vulnerable)
pub fn get_total_value(accounts: &[AccountInfo]) -> Result<u64, ProgramError> {
let pool = &accounts[0];
let data = pool.try_borrow_data()?;
let pool_state = PoolState::deserialize(&data)?;
// VULNERABLE: returns potentially stale state in the middle
// of another instruction that is updating it.
Ok(pool_state.token_a_balance + pool_state.token_b_balance)
}
pub fn swap(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let pool = &accounts[0];
transfer_in(pool, amount)?;
// CPI here triggers a callback that calls get_total_value
// before transfer_out updates pool_state.
invoke(&hook_instruction(), &[..])?;
transfer_out(pool, amount * price)?;
update_pool_state(pool)?; // too late
Ok(())
}
After (Fixed) — Update state before external CPIs
pub fn swap(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let pool = &accounts[0];
// 1. Compute the new state.
let mut state = PoolState::load(pool)?;
state.token_a_balance = state.token_a_balance.checked_sub(amount).ok_or(...)?;
state.token_b_balance = state.token_b_balance.checked_add(amount * price).ok_or(...)?;
// 2. Persist BEFORE any external CPI.
state.save(pool)?;
// 3. Now external readers see fresh state.
transfer_in(pool, amount)?;
invoke(&hook_instruction(), &[..])?;
transfer_out(pool, amount * price)?;
Ok(())
}
The fix is the Solana equivalent of the Checks-Effects-Interactions pattern: update on-chain state before invoking any program that might read it.
Alternative Mitigations
Add a reentrancy guard
#[derive(BorshSerialize, BorshDeserialize)]
pub struct Pool {
pub locked: bool,
// ...
}
pub fn swap(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let pool = &accounts[0];
let mut state = Pool::load(pool)?;
if state.locked {
return Err(ProgramError::Custom(REENTRANCY));
}
state.locked = true;
state.save(pool)?;
// ... do work, including CPIs ...
state.locked = false;
state.save(pool)?;
Ok(())
}
The view function checks locked and refuses to return state when an update is in progress.
Return version numbers from views
Have the view function return both the value and a monotonic version number. Consumers can detect that the state is mid-update by comparing versions across calls.
Avoid exposing view functions for hot state
If the state changes during normal operation, do not expose it via a CPI-callable view at all. Other programs should fetch the data themselves and assume responsibility for consistency.
Common Mistakes
Using borrow_data thinking it provides isolation. Borrowing only prevents Rust-level aliasing within the same instruction. It does nothing to prevent reentrancy from other invocations.
Updating partial state before CPI. If only some fields are updated before the CPI, the reader observes a torn state that may be worse than fully stale data.
Trusting that CPIs cannot call back. Any program can invoke any other program. Treat every CPI boundary as untrusted.
References
- Reentrancy in Solana — Sealevel Attacks
- CWE-841: Improper Enforcement of Behavioral Workflow
- Cross-Program Invocation — Solana Docs
- Daian, P. et al. “Flash Boys 2.0: Frontrunning, Transaction Reordering, and Consensus Instability in Decentralized Exchanges.” IEEE S&P 2020.