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

Position Health Check Gap

[HIGH-1] Position Health Check Gap

Location

PerpetualVault.sol -> willPositionCollateralBeInsufficient() function

Description

The vault lacks continuous health check mechanisms between GMX position updates, creating a vulnerability window where positions can become unsafe without detection. This issue exists because:

  1. Health checks only occur during explicit keeper actions

  2. No automatic monitoring between updates

  3. GMX price movements can make positions unsafe between checks

Impact

  • Positions can become liquidatable without timely intervention

  • Increased risk of forced liquidations

  • Potential losses due to delayed health checks

Proof of Concept

contract HealthCheckGapTest is Test {
PerpetualVault public vault;
MockPriceOracle public priceOracle;
address user = address(0x1);
function setUp() public {
vault = new PerpetualVault();
priceOracle = new MockPriceOracle();
vault.setPriceOracle(address(priceOracle));
vm.deal(user, 100 ether);
vm.prank(user);
vault.deposit{value: 10 ether}();
}
function testHealthCheckGap() public {
// 1. Create a position with 2x leverage
vm.prank(user);
bytes32 positionId = vault.createPosition({
size: 20 ether,
collateral: 10 ether,
isLong: true
});
// 2. Fast forward time between health checks
vm.warp(block.timestamp + 1 hours);
// 3. Simulate price drop of 40%
priceOracle.setPrice(initialPrice * 60 / 100);
// 4. Position is now unsafe but no health check triggered
assertTrue(vault.isPositionUnsafe(positionId));
assertFalse(vault.lastHealthCheck(positionId) > block.timestamp - 1 hours);
// 5. User can continue operating with unsafe position
vm.prank(user);
vault.increasePosition(positionId, 1 ether);
}
}

Recommendation

  1. Implement continuous health monitoring:

contract PerpetualVault {
uint256 public constant HEALTH_CHECK_INTERVAL = 5 minutes;
mapping(bytes32 => uint256) public lastHealthCheck;
modifier requireHealthCheck(bytes32 positionId) {
if (block.timestamp >= lastHealthCheck[positionId] + HEALTH_CHECK_INTERVAL) {
_performHealthCheck(positionId);
}
_;
}
function _performHealthCheck(bytes32 positionId) internal {
Position storage position = positions[positionId];
require(!_isPositionUnsafe(position), "Position unsafe");
lastHealthCheck[positionId] = block.timestamp;
}
// Apply to all position-modifying functions
function increasePosition(bytes32 positionId, uint256 amount)
external
requireHealthCheck(positionId)
{
// Existing logic
}
}
  1. Implement a keeper network for regular health checks

  2. Add incentives for external health check triggers

  3. Implement automatic position reduction when approaching unsafe levels

[HIGH-2] Token Balance Accounting Gap in Multi-Token Outputs

Location

PerpetualVault.sol -> _totalAmount() function

Description

The contract fails to properly account for multiple token outputs during position decreases, particularly when GMX decrease position swaps fail. This leads to incorrect withdrawal amounts and corrupted accounting states.

Impact

  • Incorrect withdrawal amounts leading to direct fund losses

  • Corrupted accounting states affecting all subsequent operations

  • Potential for exploitation through manipulated token outputs

Proof of Concept

contract TokenBalanceAccountingTest is Test {
PerpetualVault public vault;
MockERC20 public tokenA;
MockERC20 public tokenB;
function setUp() public {
vault = new PerpetualVault();
tokenA = new MockERC20("Token A", "TKNA");
tokenB = new MockERC20("Token B", "TKNB");
tokenA.mint(address(vault), 1000e18);
tokenB.mint(address(vault), 1000e18);
}
function testMultiTokenAccountingFailure() public {
// 1. Setup initial state
address user = address(0x1);
vm.prank(user);
vault.deposit(tokenA, 100e18);
// 2. Create position
bytes32 positionId = vault.createPosition({
token: address(tokenA),
size: 50e18,
isLong: true
});
// 3. Simulate failed swap returning both tokens
vm.mockCall(
address(gmx),
abi.encodeWithSelector(IGmx.decreasePosition.selector),
abi.encode(
tokenA, 25e18, // Primary token return
tokenB, 25e18 // Secondary token return
)
);
// 4. Close position
vm.prank(user);
vault.closePosition(positionId);
// 5. Verify accounting mismatch
assertEq(vault.userBalance(user, tokenA), 75e18); // Should be 100e18
assertEq(vault.userBalance(user, tokenB), 0); // Should be 25e18
}
}

Recommendation

  1. Implement proper multi-token return handling:

contract PerpetualVault {
struct TokenReturn {
address token;
uint256 amount;
}
function _handlePositionClose(bytes32 positionId) internal returns (TokenReturn[] memory) {
Position storage position = positions[positionId];
TokenReturn[] memory returns = new TokenReturn[](2);
// Handle primary token
(address token1, uint256 amount1) = _closePositionPrimary(position);
returns[0] = TokenReturn(token1, amount1);
// Handle secondary token if swap fails
(address token2, uint256 amount2) = _closePositionSecondary(position);
if (amount2 > 0) {
returns[1] = TokenReturn(token2, amount2);
}
// Update balances for all returned tokens
for (uint256 i = 0; i < returns.length; i++) {
if (returns[i].amount > 0) {
_updateUserBalance(
position.owner,
returns[i].token,
returns[i].amount
);
}
}
return returns;
}
}
  1. Add comprehensive token accounting tests

  2. Implement balance reconciliation mechanisms

  3. Add event emissions for all token movements

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

invalid_decreasePositionOrder_ouput_two_tokens_not_handled

Guardian’s audit H-05.

Support

FAQs

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