Missing Idempotency Remediation
How to fix missing idempotency guards on critical state-changing operations.
Missing Idempotency Remediation
Overview
Related Detector: Missing Idempotency
Critical operations without idempotency guards can be double-executed by submitting the same logical instruction in multiple transactions. The fix is to implement nonce-based tracking, slot-based expiry, or processed-ID sets that ensure each operation executes exactly once.
Recommended Fix
Before (Vulnerable)
pub fn claim_reward(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
// No tracking -- same claim can be submitted repeatedly
**accounts[0].try_borrow_mut_lamports()? -= amount;
**accounts[1].try_borrow_mut_lamports()? += amount;
Ok(())
}
After (Fixed)
pub fn claim_reward(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let claim_state = &accounts[2];
let data = claim_state.try_borrow_data()?;
// Check if already claimed
let claimed = data[0] != 0;
if claimed {
return Err(ProgramError::InvalidArgument); // Already claimed
}
drop(data);
// Execute transfer
**accounts[0].try_borrow_mut_lamports()? -= amount;
**accounts[1].try_borrow_mut_lamports()? += amount;
// Mark as claimed
claim_state.try_borrow_mut_data()?[0] = 1;
Ok(())
}
Alternative Mitigations
1. Sequence number (nonce) pattern
Best for operations that repeat with different parameters:
#[account]
pub struct OperationState {
pub next_sequence: u64,
}
pub fn execute_operation(
ctx: Context<Execute>,
sequence: u64,
data: Vec<u8>,
) -> Result<()> {
let state = &mut ctx.accounts.operation_state;
require!(sequence == state.next_sequence, ErrorCode::InvalidSequence);
// Process operation...
state.next_sequence = state.next_sequence
.checked_add(1)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
2. Slot-based expiry
Best for time-sensitive operations that should only execute within a window:
pub fn execute_with_deadline(
ctx: Context<Execute>,
deadline_slot: u64,
) -> Result<()> {
let clock = Clock::get()?;
require!(clock.slot <= deadline_slot, ErrorCode::Expired);
// Process operation...
Ok(())
}
3. Processed-ID bitmap (Anchor)
Best for batch operations like airdrops with many recipients:
#[account]
pub struct ClaimBitmap {
pub bits: [u8; 1024], // Track 8192 claims
}
pub fn claim(ctx: Context<Claim>, index: u16) -> Result<()> {
let bitmap = &mut ctx.accounts.claim_bitmap;
let byte_idx = (index / 8) as usize;
let bit_idx = index % 8;
require!(byte_idx < bitmap.bits.len(), ErrorCode::InvalidIndex);
require!(bitmap.bits[byte_idx] & (1 << bit_idx) == 0, ErrorCode::AlreadyClaimed);
bitmap.bits[byte_idx] |= 1 << bit_idx;
// Process claim...
Ok(())
}
Common Mistakes
Mistake 1: Relying solely on blockhash expiry
// WRONG: blockhash prevents exact TX replay but not logical replay
// An attacker can submit the same instruction data in a new transaction
pub fn transfer(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
// No idempotency -- different blockhash = new valid transaction
Ok(())
}
Blockhash expiry prevents the same transaction bytes from being submitted twice, but does not prevent submitting the same instruction logic in a new transaction.
Mistake 2: Using timestamps for strict uniqueness
// WRONG: timestamps are not guaranteed unique
if current_timestamp == last_processed_timestamp {
return Err(ProgramError::InvalidArgument);
}
Multiple transactions can execute in the same slot with the same timestamp. Use monotonically increasing nonces instead.
Mistake 3: Checking but not persisting the guard
// WRONG: nonce is validated but never incremented
let nonce = load_nonce(accounts)?;
require!(nonce == expected_nonce, ErrorCode::InvalidNonce);
// Forgot to increment and store nonce!
Always persist the updated guard value after the operation succeeds.