Lone Wintergreen Rattlesnake
High
A precision loss in the WeightedIndex contract will cause a complete loss of tokens for users when debonding small amounts, as the calculation for tokens with small weights rounds down to zero during the debonding process. This occurs when _percSharesX96 is too small relative to _totalAssets, causing integer division to truncate to zero.
In WeightedIndex.sol
, the debonding calculation uses fixed-point arithmetic that can result in rounding to zero for tokens with small weights:
uint256 _debondAmount = (_totalAssets[indexTokens[_i].token] * _percSharesX96) / FixedPoint96.Q96;
When dealing with tokens that have small weights and small debond amounts, the multiplication and subsequent division by Q96 can result in the amount rounding down to zero.
User bonds into a pod that has tokens with varying weights (e.g., 59%, 20%, 1%) User attempts to debond a small amount of pod tokens The debonding calculation for the token with 1% weight:
_percSharesX96 = (_amountAfterFee * FixedPoint96.Q96) / _totalSupply
_debondAmount = (_totalAssets[indexTokens[_i].token] * _percSharesX96) / FixedPoint96.Q96
Results in _debondAmount = 0 due to precision loss User receives zero tokens for the small-weight component of their debonding, effectively losing those tokens
The users suffer a partial loss of funds proportional to the weight of the affected token(s) in the index. For example, with a 1% weight token, users lose 1% of their expected returns when debonding small amounts. The lost tokens remain trapped in the contract.
Click to view the full test code
address public dai = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address public usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
uint256 public bondAmt = 1000e18;
uint16 fee = 2000;
uint256 public bondAmtAfterFee = bondAmt - (bondAmt * fee) / 10000;
uint256 public feeAmtOnly1 = (bondAmt * fee) / 10000;
uint256 public feeAmtOnly2 = (bondAmtAfterFee * fee) / 10000;
// Test users
address public alice = address(0x1);
address public bob = address(0x2);
address public carol = address(0x3);
event FlashMint(address indexed executor, address indexed recipient, uint256 amount);
event AddLiquidity(address indexed user, uint256 idxLPTokens, uint256 pairedLPTokens);
event RemoveLiquidity(address indexed user, uint256 lpTokens);
function setUp() public override {
super.setUp();
peas = PEAS(0x02f92800F57BCD74066F5709F1Daa1A4302Df875);
twapUtils = new V3TwapUtilities();
rewardsWhitelist = new RewardsWhitelist();
dexAdapter = new UniswapDexAdapter(
twapUtils,
0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D, // Uniswap V2 Router
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45, // Uniswap SwapRouter02
false
);
IDecentralizedIndex.Config memory _c;
IDecentralizedIndex.Fees memory _f;
_f.bond = fee;
_f.debond = fee;
address[] memory _t = new address[](3);
_t[0] = address(peas);
_t[1] = dai;
_t[2] = usdc;
uint256[] memory _w = new uint256[](3);
_w[0] = 59;
_w[1] = 1;
_w[2] = 20;
address _pod = _createPod(
"Test",
"pTEST",
_c,
_f,
_t,
_w,
address(0),
false,
abi.encode(
dai,
address(peas),
0x6B175474E89094C44Da98b954EedeAC495271d0F,
0x7d544DD34ABbE24C8832db27820Ff53C151e949b,
rewardsWhitelist,
0x024ff47D552cB222b265D68C7aeB26E586D5229D,
dexAdapter
)
);
pod = WeightedIndex(payable(_pod));
flashMintRecipient = new MockFlashMintRecipient();
// Initial token setup for test users
deal(address(peas), address(this), bondAmt * 100);
deal(address(peas), alice, bondAmt * 100);
deal(address(peas), bob, bondAmt * 100);
deal(address(peas), carol, bondAmt * 100);
deal(address(0x6B175474E89094C44Da98b954EedeAC495271d0F), address(this), bondAmt * 100);
deal(address(usdc), address(this), bondAmt * 100);
deal(address(0x6B175474E89094C44Da98b954EedeAC495271d0F), bob, bondAmt * 100);
deal(address(0x6B175474E89094C44Da98b954EedeAC495271d0F), alice, bondAmt * 100);
deal(address(usdc), bob, bondAmt * 100);
deal(address(usdc), alice, bondAmt * 100);
// Approve tokens for all test users
vm.startPrank(alice);
peas.approve(address(pod), type(uint256).max);
IERC20(dai).approve(address(pod), type(uint256).max);
IERC20(usdc).approve(address(pod), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
peas.approve(address(pod), type(uint256).max);
IERC20(dai).approve(address(pod), type(uint256).max);
IERC20(usdc).approve(address(pod), type(uint256).max);
vm.stopPrank();
vm.startPrank(carol);
peas.approve(address(pod), type(uint256).max);
IERC20(dai).approve(address(pod), type(uint256).max);
vm.stopPrank();
}
function test_bondWithDecimalLoss() public {
peas.approve(address(pod), peas.totalSupply());
IERC20(dai).approve(address(pod), type(uint256).max);
IERC20(usdc).approve(address(pod), type(uint256).max);
uint256 addressThisUSDCBalanceBeforeBond = IERC20(usdc).balanceOf(address(this));
pod.bond(address(peas), 1e16, 0);
vm.startPrank(alice);
pod.bond(address(peas), 1e16, 0);
vm.stopPrank();
vm.startPrank(bob);
pod.bond(address(peas), 1e16, 0);
vm.stopPrank();
uint256 addressThisUSDCBalance = IERC20(usdc).balanceOf(address(this));
// Shows that the balance decreases after bonding
assertLt(addressThisUSDCBalance, addressThisUSDCBalanceBeforeBond);
address[] memory _n1;
uint8[] memory _n2;
pod.debond(1e10, _n1, _n2);
uint256 addressThisUSDCBalanceAfter = IERC20(usdc).balanceOf(address(this));
// Shows that the balance doesnt change after debonding
assertEq(addressThisUSDCBalanceAfter, addressThisUSDCBalance);
}
test forge test --match-contract WeightedIndexTest --match-test test_bondWithDecimalLoss -vvv --fork-url
[⠊] Compiling...
[⠢] Compiling 1 files with Solc 0.8.28
[⠆] Solc 0.8.28 finished in 2.14s
Compiler run successful!
Ran 1 test for test/WeightedIndex.t.sol:WeightedIndexTest
[PASS] test_bondWithDecimalLoss() (gas: 696242)
Logs:
..................................addressThisUSDCBalance 99999999999999999996611
CA: .........debondAmount 7999999999
CA: .........debondAmount 135593220
CA: .........debondAmount 0
..................................addressThisUSDCBalance 99999999999999999996611
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.26s (389.01ms CPU time)
Ran 1 test suite in 4.64s (4.26s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Instead of:
if (_debondAmount > 0) {
_totalAssets[indexTokens[_i].token] -= _debondAmount;
IERC20(indexTokens[_i].token).safeTransfer(_msgSender(), _debondAmount);
}
should add a check to ensure that debond amount > 0
require(_debondAmount >, "Amount too small");