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

PerpetualVault Can Incorrectly Finalize Withdrawals Even If Transfers Fail, Locking User Funds

Brief

The PerpetualVault contract’s withdrawal process can finalize a user’s withdrawal even when the actual token transfer fails by unconditionally updating internal accounting variables. Specifically, the _transferToken function proceeds with reducing totalDepositAmount and burning user shares after a token transfer failure. Attackers or non-standard ERC-20 tokens can exploit this discrepancy to lock user funds within the contract while the vault state incorrectly reflects a completed withdrawal.

Details

The vulnerability comes from the _transferToken function within PerpetualVault, which attempts two consecutive token transfers but concludes state updates regardless of success. The relevant code snippet is as follows:

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;
}
  1. safeTransfer is used for sending fees to the treasury, causing the entire transaction to revert if that fee transfer fails, whereas the user transfer uses a plain transfer inside a try/catch block.

  2. If collateralToken.transfer to the user’s recipient reverts, the contract attempts a second transfer to the treasury, but does not verify success of this fallback route.

  3. After these transfers (or failures) complete, totalDepositAmount is unconditionally decremented, and the contract’s share data is burned in _burn(depositId). This finalizes the withdrawal flow on-chain even if no actual tokens left the contract.

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.

Specific Impact

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.

Proof Of Concept

Run with forge test --match-test test_Y2_WithdrawalAccountingWithFailedTransfers:

// SPDX-License-Identifier: UNLICENSED
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 we're configured to fail transfers to users but not to treasury
if (failTransfers && to != treasuryAddress) {
if (revertOnFail) {
revert("Transfer failed");
} else {
// Return false without reverting, emulating tokens like USDT
return false;
}
}
return super.transfer(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
// If we're configured to fail transfers to users but not to treasury
if (failTransfers && to != treasuryAddress) {
if (revertOnFail) {
revert("Transfer failed");
} else {
// Return false without reverting, emulating tokens like USDT
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; // 10%
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);
}
}
// This is the vulnerable part - unconditional state update after potentially failed transfer
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;
// Find and remove the deposit ID from the user's deposits
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 the deposit info
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");
// Create a mock failing token
token = new MockFailingToken("Mock USDC", "mUSDC");
token.setTreasury(treasury);
// Create our simplified vault implementation that focuses on the vulnerability
vault = new MockPerpetualVault(address(token), treasury);
payable(alice).transfer(1 ether);
token.mint(alice, 1e12); // Give Alice 1 million tokens
}
function test_Y2_WithdrawalAccountingWithFailedTransfers() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Prepare mock token and vault");
uint256 depositAmount = 1e10; // 10,000 USDC
console.log("\nStep 2: Alice deposits into the vault");
vm.startPrank(alice);
token.approve(address(vault), depositAmount);
vault.deposit(depositAmount);
vm.stopPrank();
// Print state after deposit
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)");
// Configure the token to fail on transfers without reverting (like USDT)
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);
// Expect the TokenTranferFailed event
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);
// Check deposit IDs - should be empty after withdrawal
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());
// Assertions to verify the vulnerability
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(); // Should revert because the deposit no longer exists
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());
}
}

LOGS:

Ran 1 test for test/WithdrawalAccountingPOC.t.sol:WithdrawalAccountingPOC
[PASS] test_Y2_WithdrawalAccountingWithFailedTransfers() (gas: 232001)
Logs:
=== INITIAL SETUP ===
Step 1: Prepare mock token and vault
Step 2: Alice deposits into the vault
Deposit ID: 61079894186316590624551406049834829489958829734151432750612893030303268872079
Vault collateral balance: 10000000000
Total deposit amount: 10000000000
=== DEMONSTRATE VULNERABILITY ===
Step 3: Configure token to fail silently on transfers (return false instead of reverting)
Step 4: Set lock time to 0 for immediate withdrawal
Step 5: Alice attempts to withdraw her deposit
=== VERIFY VULNERABILITY IMPACT ===
Step 6: Check the final state
Alice's balance change: 0
Vault collateral balance: 10000000000
Treasury balance: 0
Alice's deposit IDs count: 0
Total deposit amount: 0
Step 7: Demonstrate inability to recover funds
=== CONCLUSION ===
Vulnerability demonstrated: Withdrawal accounting completes even when token transfer fails
Result: Alice's tokens are permanently locked in the contract with no way to recover them
Vault collateral balance: 10000000000
Accounting total deposit amount: 0
Accounting discrepancy: 10000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.62ms (1.96ms CPU time)
Ran 1 test suite in 418.97ms (7.62ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

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

Give us feedback!