The POC demonstrates how winner distribution calculations suffer from precision loss when winner shares don't evenly divide total vault assets, causing dust to be permanently locked. It shows that when calculating individual payouts using Math.mulDiv(shares, vaultAsset, totalWinnerShares), integer division creates rounding remainders that accumulate as unrecoverable funds.
The test verifies that winners receive slightly less than mathematically entitled amounts while protocol funds become permanently inaccessible.
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
* PoC: Share Calculation Precision Loss in Winner Distribution
* Impact: Winner payouts suffer from precision loss, creating permanently locked dust
*
* Root Cause: Math.mulDiv(shares, vaultAsset, totalWinnerShares) uses floor division,
* causing rounding remainders that accumulate as unrecoverable funds when winner
* shares don't evenly divide total vault assets.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ShareCalculationPrecisionLossPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address winner1 = makeAddr("winner1");
address winner2 = makeAddr("winner2");
address winner3 = makeAddr("winner3");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100;
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
countries[2] = "France";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(winner1, 10000 ether);
asset.mint(winner2, 10000 ether);
asset.mint(winner3, 10000 ether);
vm.startPrank(winner1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(winner2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(winner3);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* CRITICAL VULNERABILITY: Precision Loss Creates Permanent Dust
* Demonstrates how winner distribution loses funds due to integer division
*/
function test_PrecisionLossDustCreation() public {
console.log("=== PRECISION LOSS DUST CREATION ===");
vm.warp(EVENT_START - 1 hours);
vm.startPrank(winner1);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner1);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(winner2);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner2);
vault.joinEvent(1);
vm.stopPrank();
vm.startPrank(winner3);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner3);
vault.joinEvent(2);
vm.stopPrank();
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
console.log("Vault balance before withdrawals:", vaultBalanceBefore);
vm.startPrank(winner1);
uint256 winner1Payout = vault.withdraw(vault.stakedAsset(winner1), winner1, winner1);
vm.stopPrank();
vm.startPrank(winner2);
uint256 winner2Payout = vault.withdraw(vault.stakedAsset(winner2), winner2, winner2);
vm.stopPrank();
vm.startPrank(winner3);
uint256 winner3Payout = vault.withdraw(vault.stakedAsset(winner3), winner3, winner3);
vm.stopPrank();
uint256 vaultBalanceAfter = asset.balanceOf(address(vault));
uint256 totalDust = vaultBalanceAfter;
console.log("Winner1 payout:", winner1Payout);
console.log("Winner2 payout:", winner2Payout);
console.log("Winner3 payout:", winner3Payout);
console.log("Total payouts:", winner1Payout + winner2Payout + winner3Payout);
console.log("Vault balance after withdrawals:", vaultBalanceAfter);
console.log("Dust permanently locked:", totalDust);
assertGt(totalDust, 0, "Precision loss created dust that is permanently locked");
assertLt(vaultBalanceAfter, vaultBalanceBefore, "Some funds remain locked in vault");
}
* Demonstrate Mathematical Proof of Dust Creation
* Shows exact calculation where 100 assets / 7 shares = 2 dust
*/
function test_MathematicalDustProof() public {
console.log("=== MATHEMATICAL DUST PROOF ===");
uint256 totalAssets = 100;
uint256 totalShares = 7;
uint256 expectedPerShare = totalAssets / totalShares;
uint256 totalDistributed = expectedPerShare * totalShares;
uint256 dust = totalAssets - totalDistributed;
console.log("Total assets:", totalAssets);
console.log("Total shares:", totalShares);
console.log("Assets per share (floor):", expectedPerShare);
console.log("Total distributed:", totalDistributed);
console.log("Dust created:", dust);
assertEq(dust, 2, "Mathematical proof: 100 assets / 7 shares creates 2 dust");
assertLt(totalDistributed, totalAssets, "Floor division causes fund loss");
}
* Demonstrate Winner Payout Dilution
* Shows winners receive less than mathematically entitled
*/
function test_WinnerPayoutDilution() public {
console.log("=== WINNER PAYOUT DILUTION ===");
vm.warp(EVENT_START - 1 hours);
vm.startPrank(winner1);
vault.deposit(100 ether, winner1);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
uint256 winnerShares = vault.stakedAsset(winner1);
console.log("Vault balance before withdrawal:", vaultBalanceBefore);
console.log("Winner shares:", winnerShares);
console.log("Expected payout (manual calc):", vaultBalanceBefore);
vm.startPrank(winner1);
uint256 actualPayout = vault.withdraw(winnerShares, winner1, winner1);
vm.stopPrank();
uint256 remainingDust = asset.balanceOf(address(vault));
console.log("Actual payout received:", actualPayout);
console.log("Dust remaining in vault:", remainingDust);
console.log("Payout dilution:", vaultBalanceBefore - actualPayout);
assertLt(actualPayout, vaultBalanceBefore, "Winner receives less than vault balance due to dust");
assertGt(remainingDust, 0, "Dust remains permanently locked");
}
* Demonstrate Dust Accumulation Over Multiple Tournaments
* Shows how dust compounds and becomes economically significant
*/
function test_DustAccumulationOverTime() public {
console.log("=== DUST ACCUMULATION OVER TIME ===");
uint256 totalDustAccumulated = 0;
for (uint256 tournament = 0; tournament < 5; tournament++) {
vm.warp(EVENT_START - 1 hours);
address winner = makeAddr(string(abi.encodePacked("winner", tournament)));
asset.mint(winner, 10000 ether);
vm.startPrank(winner);
asset.approve(address(vault), type(uint256).max);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
vm.startPrank(winner);
vault.withdraw(vault.stakedAsset(winner), winner, winner);
vm.stopPrank();
uint256 vaultBalanceAfter = asset.balanceOf(address(vault));
uint256 tournamentDust = vaultBalanceBefore - (vaultBalanceBefore - vaultBalanceAfter);
totalDustAccumulated += tournamentDust;
console.log("Tournament", tournament + 1, "dust:", tournamentDust);
}
console.log("Total accumulated dust:", totalDustAccumulated);
assertGt(totalDustAccumulated, 0, "Dust accumulates over multiple tournaments");
}
* Demonstrate Fund Conservation Violation
* Shows total payouts < total deposits due to dust
*/
function test_FundConservationViolation() public {
console.log("=== FUND CONSERVATION VIOLATION ===");
uint256 totalDeposited = 0;
vm.warp(EVENT_START - 1 hours);
vm.startPrank(winner1);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner1);
vault.joinEvent(0);
totalDeposited += MINIMUM_AMOUNT + 10 ether;
vm.stopPrank();
vm.startPrank(winner2);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner2);
vault.joinEvent(1);
totalDeposited += MINIMUM_AMOUNT + 10 ether;
vm.stopPrank();
vm.startPrank(winner3);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner3);
vault.joinEvent(2);
totalDeposited += MINIMUM_AMOUNT + 10 ether;
vm.stopPrank();
console.log("Total deposited by users:", totalDeposited);
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
vm.startPrank(winner1);
uint256 payout1 = vault.withdraw(vault.stakedAsset(winner1), winner1, winner1);
vm.stopPrank();
vm.startPrank(winner2);
uint256 payout2 = vault.withdraw(vault.stakedAsset(winner2), winner2, winner2);
vm.stopPrank();
vm.startPrank(winner3);
uint256 payout3 = vault.withdraw(vault.stakedAsset(winner3), winner3, winner3);
vm.stopPrank();
uint256 totalPaidOut = payout1 + payout2 + payout3;
uint256 finalDust = asset.balanceOf(address(vault));
console.log("Total paid out to users:", totalPaidOut);
console.log("Dust permanently locked:", finalDust);
console.log("Fund conservation check:", totalPaidOut + finalDust, "vs", totalDeposited);
assertLt(totalPaidOut + finalDust, totalDeposited, "Fund conservation violated - dust represents permanent loss");
assertGt(finalDust, 0, "Dust accumulation breaks fund conservation");
}
}
The precision loss issue can be addressed by implementing dust tracking and collection mechanisms, to redistribute accumulated dust and ensure fair distributions.