DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Disproportionate Share Distribution in Initial Deposits

Summary

PerpetualVault has an issue in calculating shares when the vault is almost empty, which allows the second depositor to get a disproportionate amount of shares compared to the first depositor.

Vulnerability Details

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
@=> if (totalAmountBefore == 0) totalAmountBefore = 1;
@=> _shares = amount * totalShares / totalAmountBefore;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
//...
}

In the _mint function, when totalShares == 0, shares are calculated by depositInfo[depositId].amount * 1e8 and for subsequent deposits, shares are calculated based on the proportion amount * totalShares / totalAmountBefore.

When totalAmountBefore = 0, the code uses the value 1 as a fallback, this causes the share calculation to be very inaccurate because dividing by 1 will result in a large value where users who deposit after totalAmount approaches 0 will get a much larger share.

Impact

Bob got more shares than he should have.

Scenario

1. Alice makes the first deposit:

  • Deposit: 1000 USDC

  • Gets shares: 1000 * 1e8 = 100,000,000,000 shares

2. There is a big loss due to the market crash:

  • The total value of the vault drops to almost 0 (eg 0.000001 USDC) and the system will use the fallback value of 1

3. Bob makes a deposit:

  • Deposit: 1000 USDC

  • Shares calculation: 1000 * 100,000,000,000 / 1

  • Bob gets more shares than he should

POC

Add this to PerpetualVault.t.sol and run it forge test --match-test test_ShareCalculationExploit --rpc-url arbitrum -vvvv.

function test_ShareCalculationExploit() external {
// Setup initial state
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 depositAmount = 1000e6; // 1000 USDC
// Alice's initial deposit
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(alice);
deal(address(collateralToken), alice, depositAmount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, depositAmount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(depositAmount);
vm.stopPrank();
// Verify Alice's initial shares
uint256[] memory aliceDeposits = PerpetualVault(vault).getUserDeposits(alice);
(, uint256 aliceShares,,,,) = PerpetualVault(vault).depositInfo(aliceDeposits[0]);
assertEq(aliceShares, depositAmount * 1e8); // Initial shares calculation
// Simulate market crash by directly manipulating vault balance
// We do this by transferring out almost all USDC, leaving minimal amount
uint256 vaultBalance = collateralToken.balanceOf(vault);
uint256 crashedBalance = 1; // Leave 1 wei
uint256 transferAmount = vaultBalance - crashedBalance;
vm.prank(vault);
collateralToken.transfer(address(0xdead), transferAmount);
// Bob's deposit with same amount after crash
vm.startPrank(bob);
deal(address(collateralToken), bob, depositAmount);
collateralToken.approve(vault, depositAmount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(depositAmount);
vm.stopPrank();
// Verify Bob's shares
uint256[] memory bobDeposits = PerpetualVault(vault).getUserDeposits(bob);
(, uint256 bobShares,,,,) = PerpetualVault(vault).depositInfo(bobDeposits[0]);
// Bob should get significantly more shares than Alice despite same deposit
assertTrue(bobShares > aliceShares);
emit log_named_uint("Alice Shares", aliceShares);
emit log_named_uint("Bob Shares", bobShares);
// Calculate share percentages
uint256 totalShares = PerpetualVault(vault).totalShares();
uint256 alicePercentage = (aliceShares * 10000) / totalShares;
uint256 bobPercentage = (bobShares * 10000) / totalShares;
emit log_named_uint("Alice Share Percentage (bps)", alicePercentage);
emit log_named_uint("Bob Share Percentage (bps)", bobPercentage);
// Verify the massive disparity in ownership
assertTrue(bobPercentage > 9900); // Bob should own >99% of shares
assertTrue(alicePercentage < 100); // Alice should own <1% of shares
}

Result:

├─ emit Minted(depositId: 1, depositor: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], shareAmount: 100000000000000000 [1e17], depositAmount: 1000000000 [1e9])

Alice initial deposit and Alice gets 1e17 shares for a 1000 USDC deposit.

├─ [10163] 0xaf88d065e77c8cC2239327C5EDb3A432268e5831::transfer(0x000000000000000000000000000000000000dEaD, 999999999 [9.999e8])

Market crash simulation.

├─ emit Minted(depositId: 2, depositor: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], shareAmount: 100000000000000000000000000 [1e26], depositAmount: 1000000000 [1e9])

Bob deposits and Bob gets 1e26 shares for the same deposit of 1000 USDC.

├─ emit log_named_uint(key: "Alice Share Percentage (bps)", val: 0)
├─ emit log_named_uint(key: "Bob Share Percentage (bps)", val: 9999)

Comparison of final ownership percentages where Alice only owns ~0% of the total shares and Bob owns ~99.99% of the total shares.

Logs:

Alice Shares: 100000000000000000
Bob Shares: 100000000000000000000000000
Alice Share Percentage (bps): 0
Bob Share Percentage (bps): 9999

Tools Used

  • Manual review

  • Foundry

Recommendations

Add minimum threshold and validate shares ratio.

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
// Add minimum threshold
uint256 MIN_TOTAL_AMOUNT = 1e6; // Example: 1 USDC
if (totalAmountBefore < MIN_TOTAL_AMOUNT) {
revert Error.InsufficientVaultBalance();
}
_shares = amount * totalShares / totalAmountBefore;
// Validation of shares ratio
uint256 shareRatio = (_shares * 10000) / totalShares;
if (shareRatio > 9000) { // Maximum 90% of total shares
revert Error.ExcessiveShareAllocation();
}
}
// ... rest of the function
}
Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

invalid_totalAmountBefore_is_1_incorrect_calculation_supposition

No proof when this can happen: Most of the time totalAmountBefore equals 0 (balance minus amount sent), it means totalShares equals 0. If it could happen with very specific conditions, report with that tag didn't add the needed details to be validated.

Support

FAQs

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