Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -377,6 +378,30 @@ 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;
if (fromMargin != 0) {
pos.margin -= fromMargin;
}

uint256 deficit = amount - fromMargin;
// slither-disable-next-line incorrect-equality
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;
Expand Down Expand Up @@ -593,17 +618,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;
Expand Down
72 changes: 72 additions & 0 deletions packages/evm-contracts/test/perps/AgentPerpEngineNative.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading