BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: low
Valid

Share Calculation Precision Loss in Winner Distribution

Root + Impact

Description

  • The BriVault protocol distributes tournament winnings proportionally based on winner shares. When calculating individual payouts using Math.mulDiv(shares, vaultAsset, totalWinnerShares), integer division creates rounding remainders that are permanently lost. This dust accumulation violates fund conservation principles and results in protocol funds becoming permanently inaccessible.

  • For example, if 100 assets are distributed among 7 winners, each receives 14 assets. 100/7 = 14.285, but 14*7 = 98, leaving 2 assets permanently locked as dust.

  • Root cause: the vulnerability exists in the winner distribution logic in the withdraw() function, which uses floor division, creating rounding dust when winner shares don't evenly divide total vault assets. The protocol lacks mechanisms to collect or redistribute this dust, causing permanent fund loss.

    // Lines 307-308 in withdraw() function
    uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);

Risk

Likelihood: Medium

  • Mathematical Certainty: Occurs naturally whenever shares don't evenly divide assets

  • Tournament Size Impact: Higher likelihood in tournaments with prime number participants or uneven share distributions

Impact: Medium

  • Fund Conservation Violation: Protocol loses permanent access to accumulated dust

  • Winner Payout Dilution: Winners receive slightly less than mathematically entitled amounts

  • Protocol Fund Lock: Dust accumulates over multiple tournaments, becoming significant over time

  • Economic Inefficiency: Creates dead capital that cannot be redistributed

Proof of Concept

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.

// SPDX-License-Identifier: MIT
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; // 1%
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
);
// Set up tournament with 3 countries
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
countries[2] = "France";
vault.setCountry(countries);
vm.stopPrank();
// Setup winner balances and approvals
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 ===");
// All three winners participate and join different countries
vm.warp(EVENT_START - 1 hours);
vm.startPrank(winner1);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner1);
vault.joinEvent(0); // Brazil
vm.stopPrank();
vm.startPrank(winner2);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner2);
vault.joinEvent(1); // Argentina
vm.stopPrank();
vm.startPrank(winner3);
vault.deposit(MINIMUM_AMOUNT + 10 ether, winner3);
vault.joinEvent(2); // France
vm.stopPrank();
// Fast forward and declare Brazil as winner
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
vm.stopPrank();
// Check vault balance before withdrawals
uint256 vaultBalanceBefore = asset.balanceOf(address(vault));
console.log("Vault balance before withdrawals:", vaultBalanceBefore);
// Winner1 (Brazil) withdraws
vm.startPrank(winner1);
uint256 winner1Payout = vault.withdraw(vault.stakedAsset(winner1), winner1, winner1);
vm.stopPrank();
// Winner2 (Argentina) withdraws
vm.startPrank(winner2);
uint256 winner2Payout = vault.withdraw(vault.stakedAsset(winner2), winner2, winner2);
vm.stopPrank();
// Winner3 (France) withdraws
vm.startPrank(winner3);
uint256 winner3Payout = vault.withdraw(vault.stakedAsset(winner3), winner3, winner3);
vm.stopPrank();
// Check vault balance after withdrawals
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);
// Verify dust was created
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 ===");
// Demonstrate the classic 100/7 = 14.285... problem
uint256 totalAssets = 100;
uint256 totalShares = 7;
// Floor division: 100 / 7 = 14 (remainder 2)
uint256 expectedPerShare = totalAssets / totalShares; // 14
uint256 totalDistributed = expectedPerShare * totalShares; // 14 * 7 = 98
uint256 dust = totalAssets - totalDistributed; // 100 - 98 = 2
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);
// Verify mathematical dust creation
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 ===");
// Setup single winner scenario
vm.warp(EVENT_START - 1 hours);
vm.startPrank(winner1);
vault.deposit(100 ether, winner1); // Exact amount for clean calculation
vault.joinEvent(0);
vm.stopPrank();
// Declare winner
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);
// Winner withdraws
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);
// Verify payout dilution occurred
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;
// Simulate 5 tournaments with dust creation
for (uint256 tournament = 0; tournament < 5; tournament++) {
// Reset for new 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); // All join Brazil
vm.stopPrank();
// Declare winner and withdraw
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);
// Verify dust accumulation
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 ===");
// Track total deposits
uint256 totalDeposited = 0;
// Setup multiple winners
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);
// Declare winner and collect all payouts
vm.warp(EVENT_END + 1 days);
vm.startPrank(owner);
vault.setWinner(0); // Brazil wins
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);
// Verify fund conservation is violated
assertLt(totalPaidOut + finalDust, totalDeposited, "Fund conservation violated - dust represents permanent loss");
assertGt(finalDust, 0, "Dust accumulation breaks fund conservation");
}
}

Recommended Mitigation

The precision loss issue can be addressed by implementing dust tracking and collection mechanisms, to redistribute accumulated dust and ensure fair distributions.

- uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
+ // Add dust tracking and collection
+ uint256 public accumulatedDust;
+
+ function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) {
+ // ... existing logic ...
+
+ // Collect dust from winner distribution
+ uint256 expectedTotal = 0;
+ for (uint256 i = 0; i < winners.length; i++) {
+ expectedTotal += Math.mulDiv(winners[i].shares, vaultAsset, totalWinnerShares);
+ }
+
+ uint256 actualTotal = vaultAsset - address(this).balance;
+ accumulatedDust += (vaultAsset - expectedTotal);
+
+ // ... rest of function ...
+ }
+
+ // Redistribute accumulated dust to future winners or protocol
+ function redistributeDust() external onlyOwner {
+ require(accumulatedDust > 0, "No dust to redistribute");
+
+ // Redistribute dust to protocol treasury or future winners
+ uint256 dustAmount = accumulatedDust;
+ accumulatedDust = 0;
+
+ // Transfer dust to designated recipient
+ asset.transfer(dustRecipient, dustAmount);
+ }
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Dust in the contract

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!