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.
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;
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";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(user1, 10000 ether);
asset.mint(user2, 10000 ether);
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 ===");
vm.startPrank(user1);
vm.warp(EVENT_START - 1 hours);
uint256 depositAmount = 200.5 ether;
uint256 sharesReceived = vault.deposit(depositAmount, user1);
vault.joinEvent(0);
vm.stopPrank();
uint256 vaultBalance = asset.balanceOf(address(vault));
uint256 totalShares = vault.totalSupply();
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);
assertLt(sharesReceived, expectedShares, "Floor division caused 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);
assertLt(assetsFromShares, depositAmount, "Round-trip conversion loses value");
uint256 totalDustAccumulated = 0;
uint256 totalDeposited = 0;
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);
uint256 shares = vault.deposit(deposit, users[i]);
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);
assertGt(totalDustAccumulated, 0, "Precision loss occurs on every deposit");
uint256 numUsers = 100;
uint256 depositPerUser = 1000.5 ether;
uint256 totalDepositedAtScale = 0;
uint256 totalExpectedShares = 0;
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);
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();
}
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");
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");
}
}
Override conversion functions to use ceiling division for user-favorable rounding.