From 80909fb561eaace8263e54b84956dd1e69a5f310 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Fri, 1 May 2026 00:45:40 -0500 Subject: [PATCH 1/2] fix(perps): record native liquidation bad debt --- .../contracts/perps/AgentPerpEngineNative.sol | 47 ++++++++++-- .../test/perps/AgentPerpEngineNative.t.sol | 72 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/evm-contracts/test/perps/AgentPerpEngineNative.t.sol diff --git a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol index beabda64..155d19e5 100644 --- a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol +++ b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol @@ -20,6 +20,7 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { uint256 public constant DEFAULT_MAX_ORACLE_DELAY = 2 minutes; uint256 public constant PARTIAL_LIQUIDATION_TARGET_MARGIN_RATIO = 2_000; uint256 public constant MAX_SOCIALIZED_LOSS_BPS = 50; + uint256 public constant MAX_INSURANCE_DRAW_DIVISOR = 4; error InvalidAdmin(); error InvalidOperator(); @@ -377,6 +378,28 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { } } + function _collectLiquidationLoss(MarketState storage market, Position storage pos, uint256 amount) internal { + if (amount == 0) return; + + uint256 fromMargin = amount > pos.margin ? pos.margin : amount; + if (fromMargin != 0) { + pos.margin -= fromMargin; + } + + uint256 deficit = amount - fromMargin; + if (deficit == 0) return; + + uint256 fromInsurance = deficit > market.insuranceFund ? market.insuranceFund : deficit; + if (fromInsurance != 0) { + market.insuranceFund -= fromInsurance; + } + + uint256 residualBadDebt = deficit - fromInsurance; + if (residualBadDebt != 0) { + market.badDebt += residualBadDebt; + } + } + function _assertStatusAllowsTrade(MarketStatus status, int256 oldSize, int256 sizeDelta) internal pure { if (sizeDelta == 0) return; if (status == MarketStatus.ACTIVE) return; @@ -593,17 +616,33 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { uint256 closedSize = _abs(liquidationSizeDelta); uint256 liquidationPrice = _getExecutionPrice(agentId, liquidationSizeDelta); + uint256 startingMargin = pos.margin; + int256 realizedPnl = _realizePnl(pos.size, pos.entryPrice, liquidationPrice, closedSize); _removeOpenInterest(market, pos.size); - uint256 seizedMargin = pos.margin; + if (realizedPnl > 0) { + pos.margin += uint256(realizedPnl); + } else if (realizedPnl < 0) { + _collectLiquidationLoss(market, pos, uint256(-realizedPnl)); + } + uint256 reward = Math.mulDiv( - Math.mulDiv(seizedMargin, config.liquidationRewardBps, BPS), + Math.mulDiv(startingMargin, config.liquidationRewardBps, BPS), closedSize, absSize ); - if (reward > seizedMargin) reward = seizedMargin; - pos.margin -= reward; + uint256 maxInsuranceDraw = market.insuranceFund / MAX_INSURANCE_DRAW_DIVISOR; + uint256 maxReward = pos.margin + maxInsuranceDraw; + if (reward > maxReward) reward = maxReward; + + uint256 rewardFromMargin = reward > pos.margin ? pos.margin : reward; + pos.margin -= rewardFromMargin; + + uint256 rewardFromInsurance = reward - rewardFromMargin; + if (rewardFromInsurance != 0) { + market.insuranceFund -= rewardFromInsurance; + } if (isPartial) { pos.size += liquidationSizeDelta; diff --git a/packages/evm-contracts/test/perps/AgentPerpEngineNative.t.sol b/packages/evm-contracts/test/perps/AgentPerpEngineNative.t.sol new file mode 100644 index 00000000..2c2d6af9 --- /dev/null +++ b/packages/evm-contracts/test/perps/AgentPerpEngineNative.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../../contracts/perps/AgentPerpEngineNative.sol"; +import "../../contracts/perps/SkillOracle.sol"; + +contract AgentPerpEngineNativeTest is Test { + SkillOracle oracle; + AgentPerpEngineNative engine; + + address admin = address(1); + address operator = address(6); + address pauser = address(7); + address alice = address(2); + address bob = address(3); + + bytes32 agentId = keccak256("MODEL_A"); + + function setUp() public { + oracle = new SkillOracle(100 ether, 2 minutes, admin, admin, pauser); + engine = new AgentPerpEngineNative(oracle, 1_000_000 ether, admin, operator, pauser); + + vm.deal(alice, 1_000 ether); + vm.deal(bob, 1_000 ether); + vm.deal(admin, 1_000 ether); + + vm.prank(admin); + oracle.updateAgentSkill(agentId, 1500, 0); + + vm.prank(operator); + engine.createMarket(agentId); + } + + function testNativeLiquidationRecordsBadDebtAfterMarginAndInsuranceAreConsumed() public { + vm.prank(admin); + engine.depositInsuranceFund{value: 50 ether}(agentId); + + vm.prank(alice); + engine.modifyPosition{value: 200 ether}(agentId, 5 ether); + + vm.prank(admin); + oracle.updateAgentSkill(agentId, 1500, 100); + + vm.prank(bob); + engine.liquidate(agentId, alice); + + (int256 size, uint256 margin,,) = engine.positions(agentId, alice); + (,,,,,,, uint256 insuranceFund, uint256 badDebt,,,,,) = engine.markets(agentId); + + assertEq(size, 0, "liquidation should close insolvent position"); + assertEq(margin, 0, "realized loss should consume trader margin"); + assertEq(insuranceFund, 0, "insurance should be consumed before bad debt"); + assertGt(badDebt, 40 ether, "residual realized loss should be recorded as bad debt"); + } + + function testNativeLiquidationDoesNotCreditConsumedLossMarginToInsurance() public { + vm.prank(alice); + engine.modifyPosition{value: 200 ether}(agentId, 5 ether); + + vm.prank(admin); + oracle.updateAgentSkill(agentId, 1500, 100); + + vm.prank(bob); + engine.liquidate(agentId, alice); + + (,,,,,,, uint256 insuranceFund, uint256 badDebt,,,,,) = engine.markets(agentId); + + assertEq(insuranceFund, 0, "loss-absorbing margin must not become withdrawable insurance"); + assertGt(badDebt, 90 ether, "loss beyond margin should be visible as bad debt"); + } +} From 604df22e878148e03c4a4c61b0b1d366fb83928d Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Fri, 1 May 2026 03:56:18 -0500 Subject: [PATCH 2/2] chore(perps): annotate liquidation loss guard clauses --- .../evm-contracts/contracts/perps/AgentPerpEngineNative.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol index 155d19e5..115f80e2 100644 --- a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol +++ b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol @@ -379,6 +379,7 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { } function _collectLiquidationLoss(MarketState storage market, Position storage pos, uint256 amount) internal { + // slither-disable-next-line incorrect-equality if (amount == 0) return; uint256 fromMargin = amount > pos.margin ? pos.margin : amount; @@ -387,6 +388,7 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { } uint256 deficit = amount - fromMargin; + // slither-disable-next-line incorrect-equality if (deficit == 0) return; uint256 fromInsurance = deficit > market.insuranceFund ? market.insuranceFund : deficit;