Skip to main content
Sigvex

Remediating Business Logic Errors

How to identify and fix business logic errors by enforcing protocol invariants, correctly ordering state updates, and validating edge cases.

Remediating Business Logic Errors

Overview

Related Detector: Business Logic Error

Business logic errors occur when smart contract code correctly executes its instructions but those instructions encode incorrect protocol rules. Remediation requires enforcing protocol invariants explicitly, ordering state updates correctly, validating edge cases, and using established standards where available.

Before (Vulnerable)

// Reward calculation updates timestamp after transfer — double-claim risk
contract VulnerableRewards {
    mapping(address => uint256) public stakes;
    mapping(address => uint256) public lastClaim;
    IERC20 public rewardToken;
    uint256 constant RATE = 1e15; // per second per staked token

    function claim() external {
        uint256 pending = calculatePending(msg.sender);
        require(pending > 0, "Nothing to claim");

        // VULNERABLE: external call before accounting update
        rewardToken.transfer(msg.sender, pending);  // callback possible here
        lastClaim[msg.sender] = block.timestamp;    // too late
    }

    function calculatePending(address user) public view returns (uint256) {
        return stakes[user] * RATE * (block.timestamp - lastClaim[user]);
    }
}

After (Fixed)

// Fixed: accounting update precedes external transfer
contract SecureRewards {
    mapping(address => uint256) public stakes;
    mapping(address => uint256) public lastClaim;
    IERC20 public rewardToken;
    uint256 constant RATE = 1e15;

    function claim() external {
        uint256 pending = calculatePending(msg.sender);
        require(pending > 0, "Nothing to claim");

        // FIXED: update accounting state BEFORE transfer
        lastClaim[msg.sender] = block.timestamp;
        rewardToken.transfer(msg.sender, pending);
    }

    function calculatePending(address user) public view returns (uint256) {
        return stakes[user] * RATE * (block.timestamp - lastClaim[user]);
    }
}

The fix applies the Checks-Effects-Interactions pattern: update all state (Effects) before making external calls (Interactions). This ensures that even if the recipient re-enters the claim function, the lastClaim timestamp is already updated and the pending amount calculates to zero.

Alternative Mitigations

Add Explicit Invariant Checks

Assert invariants at the end of critical functions:

function deposit(uint256 amount) external {
    uint256 sharesBefore = totalShares;
    uint256 assetsBefore = totalAssets();

    token.transferFrom(msg.sender, address(this), amount);
    uint256 newShares = calculateShares(amount);
    totalShares += newShares;
    _mint(msg.sender, newShares);

    // Assert invariant: share price must not decrease after deposit
    assert(totalAssets() * 1e18 / totalShares >= assetsBefore * 1e18 / sharesBefore);
}

Use Minimum Amounts to Prevent Rounding Exploits

uint256 constant MIN_DEPOSIT = 1e6;  // 1 USDC minimum

function deposit(uint256 amount) external {
    require(amount >= MIN_DEPOSIT, "Below minimum");
    uint256 fee = (amount * FEE_BPS) / 10000;
    require(fee > 0, "Fee rounds to zero — amount too small");
    // Proceed with deposit
}

Follow ERC-4626 for Vaults

ERC-4626 specifies the correct ordering for vault deposit and withdrawal operations. Adopt its previewDeposit / previewWithdraw model:

// ERC-4626 compliant: preview uses CURRENT state before mutation
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    shares = previewDeposit(assets);      // calculate with current state
    _deposit(msg.sender, receiver, assets, shares);  // transfer + mint
}

Common Mistakes

Mistake: Calculating Shares After Transfer

// WRONG: calculates shares using post-transfer total assets
function deposit(uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);  // transfers first
    // Now totalAssets() includes the new amount — calculation is wrong
    uint256 shares = (amount * totalShares) / totalAssets();
    _mint(msg.sender, shares);
}

// CORRECT: calculate before transfer
function deposit(uint256 amount) external {
    uint256 shares = totalShares == 0
        ? amount
        : (amount * totalShares) / totalAssets();  // uses pre-transfer state
    token.transferFrom(msg.sender, address(this), amount);
    _mint(msg.sender, shares);
}

Mistake: Unguarded Admin Functions With No Invariant Checks

// WRONG: admin can set arbitrary parameters without bounds checking
function setRewardRate(uint256 newRate) external onlyOwner {
    rewardRate = newRate;  // No validation — could set to astronomically high value
}

// CORRECT: validate parameters maintain protocol invariants
function setRewardRate(uint256 newRate) external onlyOwner {
    require(newRate <= MAX_REWARD_RATE, "Exceeds maximum allowed rate");
    require(newRate >= MIN_REWARD_RATE, "Below minimum viable rate");
    rewardRate = newRate;
    emit RewardRateUpdated(newRate);
}

References