Skip to main content
Sigvex

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.

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