BriVault

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

Systematic Rounding Bias in Asset-Share Conversion Functions

Root + Impact

Description

  • BriVault's conversion functions use floor division, creating systematic bias that favors the protocol over users. Both _convertToShares() and inherited _convertToAssets() functions lose precision on each conversion, with dust accumulating over multiple operations and effectively transferring value from users to the protocol.

  • Root cause: both conversion functions in BriVault use floor division, causing precision loss on every conversion

function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
uint256 totalShares = totalSupply();
if (totalShares == 0 || balanceOfVault == 0) {
return assets; // 1:1 ratio for first depositor
}
@> shares = Math.mulDiv(assets, totalShares, balanceOfVault); // FLOOR DIVISION - precision loss!
}

Risk

Likelihood: High

  • Occurs on every deposit/withdrawal operation

Impact: Medium

  • Small precision losses per operation accumulate over many users

  • Protocol gains accumulated dust as profit

  • Users cannot recover full deposited amounts

  • Long-term usage leads to significant value loss

Proof of Concept

The POC demonstrates how systematic rounding bias in conversion functions creates unfair value extraction from users through accumulated precision loss. It shows that repeated deposit and withdrawal operations result in users receiving less than their deposited amount due to floor division in both convertToShares() and convertToAssets() functions.

The test verifies that the protocol gains value through accumulated rounding dust while users experience systematic financial loss over multiple operations.

// 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: Systematic Rounding Bias in Asset-Share Conversion Functions
* Impact: Systematic value extraction through accumulated rounding dust
*
* Root Cause: Both _convertToShares() and convertToAssets() use floor division,
* causing precision loss on every conversion that accumulates as protocol profit.
*/
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 SystematicRoundingBiasPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
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 {
// Initialize time variables
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
// Deploy mock ERC20 asset
asset = new MockERC20("Mock Token", "MOCK");
// Deploy vault with tournament parameters
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
// Set up tournament countries
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
// Setup user balances
asset.mint(user1, 10000 ether);
asset.mint(user2, 10000 ether);
// Approve vault to spend tokens
vm.startPrank(user1);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
/**
* VULNERABILITY: Systematic rounding bias in conversion functions
*
* Attack Flow:
* 1. Users deposit assets, losing precision dust on each _convertToShares() call
* 2. Users withdraw assets, losing additional precision dust on convertToAssets() call
* 3. Dust accumulates systematically favoring the protocol
* 4. Over many operations, significant value transfers from users to protocol
*/
function test_SystematicRoundingBias() public {
console.log("=== SYSTEMATIC ROUNDING BIAS VULNERABILITY ===");
// Phase 1: Demonstrate individual conversion precision loss
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
uint256 depositAmount = 200.5 ether; // Fractional amount to trigger rounding
uint256 sharesReceived = vault.deposit(depositAmount, user1);
vault.joinEvent(0); // Join Brazil
vm.stopPrank();
// Calculate expected vs actual conversion
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 totalShares = vault.totalSupply();
// Expected shares with floor division: floor(depositAmount * totalShares / vaultBalance)
uint256 expectedShares = depositAmount * totalShares / vaultBalance;
console.log("Individual Conversion Analysis:");
console.log("- Deposit amount:", depositAmount / 1e18, "tokens");
console.log("- Shares received:", sharesReceived);
console.log("- Expected shares (floor):", expectedShares);
console.log("- Precision lost:", expectedShares - sharesReceived);
// Verify precision loss occurred
assertLt(sharesReceived, expectedShares, "Floor division caused precision loss");
// Now test convertToAssets precision loss
uint256 assetsFromShares = vault.convertToAssets(sharesReceived);
uint256 expectedAssets = sharesReceived * vaultBalance / totalShares;
console.log("- Assets from shares:", assetsFromShares / 1e18, "tokens");
console.log("- Expected assets (floor):", expectedAssets / 1e18, "tokens");
console.log("- Round-trip precision loss:", depositAmount - assetsFromShares);
// Round-trip conversion loses value
assertLt(assetsFromShares, depositAmount, "Round-trip conversion loses value");
// Phase 2: Demonstrate dust accumulation over multiple operations
uint256 totalDustAccumulated = 0;
uint256 totalDeposited = 0;
// Multiple users perform fractional deposits
address[5] memory users = [
makeAddr("u1"), makeAddr("u2"), makeAddr("u3"),
makeAddr("u4"), makeAddr("u5")
];
for (uint i = 0; i < users.length; i++) {
asset.mint(users[i], 10000 ether);
vm.startPrank(users[i]);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START - 1 hours);
uint256 deposit = 1000.5 ether + (i * 0.1 ether); // Fractional amounts
uint256 shares = vault.deposit(deposit, users[i]);
// Calculate precision loss for this deposit
uint256 balance = asset.balanceOf(address(vault));
uint256 sharesTotal = vault.totalSupply();
uint256 expected = deposit * sharesTotal / balance;
uint256 dust = expected - shares;
totalDustAccumulated += dust;
totalDeposited += deposit - (deposit * PARTICIPATION_FEE_BPS / 10000);
vm.stopPrank();
}
console.log("Dust Accumulation Analysis:");
console.log("- Total deposited (after fees):", totalDeposited / 1e18, "tokens");
console.log("- Contract balance:", asset.balanceOf(address(vault)) / 1e18, "tokens");
console.log("- Accumulated dust from calculations:", totalDustAccumulated);
// The dust is lost to precision, demonstrating systematic bias
assertGt(totalDustAccumulated, 0, "Precision loss occurs on every deposit");
// Phase 3: Demonstrate protocol profit at scale
uint256 numUsers = 100;
uint256 depositPerUser = 1000.5 ether;
uint256 totalDepositedAtScale = 0;
uint256 totalExpectedShares = 0;
// Many users deposit fractional amounts
for (uint i = 0; i < numUsers; i++) {
address user = makeAddr(string(abi.encodePacked("user", i)));
asset.mint(user, 10000 ether);
vm.startPrank(user);
asset.approve(address(vault), type(uint256).max);
vm.warp(EVENT_START - 1 hours);
uint256 sharesReceivedScale = vault.deposit(depositPerUser, user);
// Track expected vs actual
uint256 vaultBalanceScale = asset.balanceOf(address(vault));
uint256 totalSharesScale = vault.totalSupply();
uint256 expectedSharesScale = depositPerUser * totalSharesScale / vaultBalanceScale;
totalExpectedShares += expectedSharesScale;
totalDepositedAtScale += depositPerUser - (depositPerUser * PARTICIPATION_FEE_BPS / 10000);
vm.stopPrank();
}
// Calculate total dust accumulated at scale
uint256 actualTotalShares = vault.totalSupply();
uint256 totalDustShares = totalExpectedShares - actualTotalShares;
uint256 vaultBalanceFinal = asset.balanceOf(address(vault));
uint256 dustValue = totalDustShares * vaultBalanceFinal / actualTotalShares;
console.log("Economic Impact at Scale:");
console.log("- Number of users:", numUsers);
console.log("- Deposit per user:", depositPerUser / 1e18, "tokens");
console.log("- Total deposited (after fees):", totalDepositedAtScale / 1e18, "tokens");
console.log("- Vault balance:", vaultBalanceFinal / 1e18, "tokens");
console.log("- Total dust shares accumulated:", totalDustShares);
console.log("- Dust value in assets:", dustValue / 1e18, "tokens");
console.log("- Average loss per user:", dustValue / numUsers / 1e18, "tokens");
console.log("- Total protocol profit:", dustValue / 1e18, "tokens");
// Significant protocol profit from systematic bias
assertGt(dustValue, 0, "Protocol accumulates significant dust profit");
assertGt(dustValue / numUsers, 0, "Each user loses value to the protocol");
console.log("=== ROUNDING BIAS VULNERABILITY CONFIRMED ===");
console.log("Protocol systematically extracts value through floor division");
console.log("Users lose precision dust on every conversion operation");
console.log("Dust accumulates as protocol profit over time");
}
}

Recommended Mitigation

Override conversion functions to use ceiling division for user-favorable rounding.

- function convertToAssets(uint256 shares) public view virtual returns (uint256) {
- return _convertToAssets(shares, Math.Rounding.Floor);
- }
+ function convertToAssets(uint256 shares) public view virtual override returns (uint256) {
+ return _convertToAssets(shares, Math.Rounding.Ceil);
+ }
- function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
- uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
- uint256 totalShares = totalSupply();
-
- if (totalShares == 0 || balanceOfVault == 0) {
- return assets; // 1:1 ratio for first depositor
- }
-
- shares = Math.mulDiv(assets, totalShares, balanceOfVault); // FLOOR DIVISION
- }
+ function _convertToShares(uint256 assets) internal view override returns (uint256 shares) {
+ uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
+ uint256 totalShares = totalSupply();
+
+ if (totalShares == 0 || balanceOfVault == 0) {
+ return assets;
+ }
+
+ // Use ceiling division to favor users
+ shares = Math.mulDiv(assets, totalShares, balanceOfVault, Math.Rounding.Ceil);
+ }
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!