Skip to content

Cache inconsistency in _clean function affects burn operations #433

@leopashov

Description

@leopashov

Cantina finding: # 212
Severity: Low


Summary

Cache inconsistency in _clean function of RecoverableWrapper.sol can lead to incorrect settled balance calculations in burn function, potentially allowing unauthorized burns or causing legitimate burns to fail.


Finding Description

The _clean function in RecoverableWrapper.sol contains a critical bug in cache management that can lead to cache inconsistency. When expired records are deleted from the queue, the cacheIndex can be set to an invalid index that no longer exists in the queue. The problem is here:

// Lines 325-327 in _clean function
if (index <= cacheIndex) {
    unsettled -= r.amount;
    if (r.frozen > 0) {
        unsettledFrozen -= r.frozen;
    }
} else {
    cacheIndex = index;  // ← CRITICAL BUG: Setting to deleted index
}
When a record is deleted from the queue (dequeue(true)), its index becomes invalid. However, the code sets cacheIndex = index for uncached records, creating an inconsistency where cacheIndex points to a non-existent record. Also, the _unsettledBalanceOf function relies on cacheIndex to determine which records are cached:

solidity
Copy code
uint256 cacheIndex = _unsettledRecords[account].cacheIndex;  // ← Invalid index
// ... later tries to access this index
if (_unsettledRecords[account].getAt(cacheIndex).settlementTime <= block.timestamp) {
    return (unsettledTotal, unsettledFrozen);
}
These two functions are very important part of _burnSettled function, which is called through the burn function in InterchainTEL.sol. This cache inconsistency can result in unauthorized token burning when the settled balance is overestimated, meaning onlyTokenManager, in this case, can burn more tokens than the users actually have.

Proof of Concept
Add the following test function to the existing test/ITS/InterchainTELTest.t.sol file:

solidity
Copy code
function test_cacheInconsistencyBugBalanceCheck() public {
    console2.log("=== Cache Inconsistency Bug - Balance Check Test ===");
    console2.log("Testing cache inconsistency through balance inconsistencies");
    
    address testUser = address(0x1234);
    vm.deal(testUser, 1000 ether);
    
    // Step 1: Create records that will expire
    vm.prank(testUser);
    iTEL.doubleWrap{value: 10 ether}(); // Record 1
    
    vm.warp(block.timestamp + 100);
    vm.prank(testUser);
    iTEL.doubleWrap{value: 20 ether}(); // Record 2
    
    vm.warp(block.timestamp + 100);
    vm.prank(testUser);
    iTEL.doubleWrap{value: 30 ether}(); // Record 3
    
    console2.log("Created 3 records that will expire");
    
    // Step 2: Let records expire
    vm.warp(block.timestamp + iTEL.recoverableWindow() + 1);
    
    // Step 3: Check initial state
    console2.log("Before _clean:");
    uint256 initialSettled = iTEL.balanceOf(testUser, false);
    uint256 initialTotal = iTEL.balanceOf(testUser, true);
    console2.log("Settled balance:", initialSettled);
    console2.log("Total balance:", initialTotal);
    
    // Step 4: Trigger _clean which should set cacheIndex to 0
    vm.prank(testUser);
    iTEL.transfer(address(user), 0, false);
    
    console2.log("After _clean (cacheIndex should be 0):");
    uint256 afterCleanSettled = iTEL.balanceOf(testUser, false);
    uint256 afterCleanTotal = iTEL.balanceOf(testUser, true);
    console2.log("Settled balance:", afterCleanSettled);
    console2.log("Total balance:", afterCleanTotal);
    
    // Step 5: Create new record - this should trigger cache inconsistency
    vm.prank(testUser);
    iTEL.doubleWrap{value: 40 ether}(); // Record 4
    
    console2.log("After creating new record:");
    uint256 afterNewRecordSettled = iTEL.balanceOf(testUser, false);
    uint256 afterNewRecordTotal = iTEL.balanceOf(testUser, true);
    console2.log("Settled balance:", afterNewRecordSettled);
    console2.log("Total balance:", afterNewRecordTotal);
    
    // Step 6: Check for cache inconsistency
    // If cacheIndex is invalid, settled and total balance should be inconsistent
    if (afterNewRecordSettled != afterNewRecordTotal) {
        console2.log("CACHE INCONSISTENCY BUG DETECTED!");
        console2.log("Settled balance:", afterNewRecordSettled);
        console2.log("Total balance:", afterNewRecordTotal);
        console2.log("Difference:", afterNewRecordTotal - afterNewRecordSettled);
        console2.log("This indicates cache corruption after _clean operation");
        
        // This proves the bug exists
        assertTrue(false, "Cache inconsistency bug detected: settled and total balance are inconsistent");
    }
// Step 7: Try to burn the settled amount
    vm.prank(iTEL.tokenManagerAddress());
    
    console2.log("Attempting burn with potentially corrupted cache...");
    
    try iTEL.burn(testUser, afterNewRecordSettled) {
        console2.log("Burn succeeded");
        
        uint256 afterBurnSettled = iTEL.balanceOf(testUser, false);
        uint256 afterBurnTotal = iTEL.balanceOf(testUser, true);
        console2.log("Balance after burn - settled:", afterBurnSettled);
        console2.log("Balance after burn - total:", afterBurnTotal);
        
        // Check if burn was correct
        if (afterBurnSettled != 0) {
            console2.log("BURN BUG DETECTED!");
            console2.log("Settled balance should be 0 after burn, but is:", afterBurnSettled);
            console2.log("This indicates cache corruption affected burn calculation");
            
            assertTrue(false, "Burn bug detected: settled balance not zero after burn");
        }
        
    } catch Error(string memory reason) {
        console2.log("BURN FAILED DUE TO CACHE INCONSISTENCY!");
        console2.log("Error:", reason);
        console2.log("This proves the cache inconsistency bug affects burn function");
        
        assertTrue(false, "Legitimate burn should not fail due to cache inconsistency");
    }
    
    console2.log("Cache inconsistency balance check test completed");
    console2.log("Analysis: Cache corruption in _clean function affects balance calculations");
}

Run the test with:
forge test --match-test test_cacheInconsistencyBugBalanceCheck -vv

This will be the output:

[FAIL: Cache inconsistency bug detected: settled and total balance are inconsistent] test_cacheInconsistencyBugBalanceCheck() (gas: 808127)
Logs:
  === Cache Inconsistency Bug - Balance Check Test ===
  Testing cache inconsistency through balance inconsistencies
  Created 3 records that will expire
  Before _clean:
  Settled balance: 60000000000000000000
  Total balance: 60000000000000000000
  After _clean (cacheIndex should be 0):
  Settled balance: 60000000000000000000
  Total balance: 60000000000000000000
  After creating new record:
  Settled balance: 60000000000000000000
  Total balance: 100000000000000000000
  CACHE INCONSISTENCY BUG DETECTED!
  Settled balance: 60000000000000000000
  Total balance: 100000000000000000000
  Difference: 40000000000000000000
  This indicates cache corruption after _clean operation

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 16.59ms (1.17ms CPU time)
This proves that the cacheIndex becomes invalid after the _clean operation, causing the cache inconsistency bug.

Recommendation

Fix the cache management logic in the _clean function

function _clean(address account) internal virtual {
    uint128 unsettled = _accountState[account].cachedUnsettled;
    uint256 cacheIndex = _unsettledRecords[account].cacheIndex;
    uint128 unsettledFrozen = _accountState[account].cachedUnsettledFrozen;
    uint16 i = 0;
    Record memory r;

    // Delete expired records from the head of the queue
    while (_unsettledRecords[account].head > 0 && i < MAX_TO_CLEAN) {
        uint256 index;
        (r, index) = _unsettledRecords[account].first();
        if (r.settlementTime > block.timestamp) break;
        
        if (r.frozen > 0) {
            _unsettledRecords[account].dequeue(false);
        } else {
            _unsettledRecords[account].dequeue(true);
        }

        if (index <= cacheIndex) {
            // this has already been cached
            unsettled -= r.amount;
            if (r.frozen > 0) {
                unsettledFrozen -= r.frozen;
            }
        }
        // REMOVED: cacheIndex = index; - This was the bug
        i++;
    }

    // Reset cacheIndex if it's now invalid
    if (cacheIndex < _unsettledRecords[account].head || 
        _unsettledRecords[account].isEmpty()) {
        cacheIndex = _unsettledRecords[account].isEmpty() ? 0 : _unsettledRecords[account].head;
    }

    // Cache new records that haven't been cached yet
    uint256 head = _unsettledRecords[account].head;
    for (i; i < MAX_TO_CLEAN && cacheIndex > 0; i++) {
        r = _unsettledRecords[account].getAt(cacheIndex);
        if (r.next == 0) break;
        
        cacheIndex = r.next;
        r = _unsettledRecords[account].getAt(cacheIndex);
        unsettled += r.amount;
        unsettledFrozen += r.frozen;
    }

    _accountState[account].cachedUnsettled = unsettled;
    _unsettledRecords[account].cacheIndex = cacheIndex;
    _accountState[account].cachedUnsettledFrozen = unsettledFrozen;
}

The fix removes the buggy line that incorrectly sets cacheIndex to a deleted record's index, and adds proper validation to ensure cacheIndex always points to a valid record or is set to 0 when the queue is empty. This prevents cache inconsistency by maintaining a valid cache state that correctly tracks unsettled records for balance calculations.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions