From 129fac7c2264bf80f363e70c2b7eb05376c68cb1 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Fri, 1 May 2026 00:22:20 -0500 Subject: [PATCH 1/2] fix(perps): enforce native equity health on withdrawals --- .../contracts/perps/AgentPerpEngineNative.sol | 35 +++++++++++++--- .../test/perps/AgentPerpEngine.t.sol | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol index beabda64..f8d90fa2 100644 --- a/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol +++ b/packages/evm-contracts/contracts/perps/AgentPerpEngineNative.sol @@ -391,13 +391,18 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { revert CloseOnlyMode(); } - function _assertLeverage(bytes32 agentId, int256 size, uint256 margin) internal view { + function _assertPositionHealthy(bytes32 agentId, int256 size, uint256 margin, uint256 entryPrice) internal view { if (size == 0) return; if (margin == 0) revert Undercollateralized(); MarketConfig memory config = marketConfigs[agentId]; uint256 absSize = _abs(size); - uint256 execPrice = _getExecutionPrice(agentId, 0); - if (Math.mulDiv(absSize, execPrice, margin) > config.maxLeverage) revert MaxLeverageExceeded(); + uint256 markPrice = _getExecutionPrice(agentId, 0); + int256 unrealizedPnl = _realizePnl(size, entryPrice, markPrice, absSize); + int256 equity = int256(margin) + unrealizedPnl; + if (equity <= 0) revert Underwater(); + + uint256 notional = Math.mulDiv(absSize, markPrice, ONE); + if (Math.mulDiv(notional, ONE, uint256(equity)) > config.maxLeverage) revert MaxLeverageExceeded(); } // ── Position management ── @@ -502,7 +507,7 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { } } - _assertLeverage(agentId, pos.size, pos.margin); + _assertPositionHealthy(agentId, pos.size, pos.margin, pos.entryPrice); // Track open positions counter if (oldSize == 0 && pos.size != 0) { @@ -524,10 +529,30 @@ contract AgentPerpEngineNative is AccessControl, ReentrancyGuard { function withdrawMargin(bytes32 agentId, uint256 amount) external nonReentrant { if (tradingPaused) revert TradingPaused(); + if (!marketConfigs[agentId].exists) revert MarketNotFound(); + _syncOracle(agentId); + + MarketState storage market = markets[agentId]; Position storage pos = positions[agentId][msg.sender]; + + if (pos.size != 0) { + int256 rateDelta = market.cumulativeFundingRate - pos.lastCumulativeFundingRate; + if (rateDelta != 0) { + int256 fundingPayment = (pos.size * rateDelta) / int256(ONE); + if (fundingPayment > 0) { + uint256 loss = uint256(fundingPayment); + if (pos.margin < loss) revert Underwater(); + pos.margin -= loss; + } else { + pos.margin += uint256(-fundingPayment); + } + } + pos.lastCumulativeFundingRate = market.cumulativeFundingRate; + } + if (pos.margin < amount) revert InsufficientMargin(); - _assertLeverage(agentId, pos.size, pos.margin - amount); pos.margin -= amount; + _assertPositionHealthy(agentId, pos.size, pos.margin, pos.entryPrice); emit MarginWithdrawn(agentId, msg.sender, amount); Address.sendValue(payable(msg.sender), amount); } diff --git a/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol b/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol index dee412b1..da21f086 100644 --- a/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol +++ b/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../../contracts/perps/SkillOracle.sol"; import "../../contracts/perps/AgentPerpEngine.sol"; +import "../../contracts/perps/AgentPerpEngineNative.sol"; import "../../contracts/MockERC20.sol"; contract AgentPerpEngineTest is Test { @@ -750,3 +751,43 @@ contract AgentPerpEngineTest is Test { engine.createMarket(agentB, 1_000_000 * 1e18, 5 * 1e18, 1_000, 500, 2 minutes, 0, 500, 500); } } + +contract AgentPerpEngineNativeHealthTest is Test { + SkillOracle oracle; + AgentPerpEngineNative engine; + + address admin = address(1); + address operator = address(6); + address pauser = address(7); + address alice = address(2); + + bytes32 agentId = keccak256("MODEL_A"); + + function setUp() public { + vm.txGasPrice(0); + vm.warp(1_000); + + vm.startPrank(admin); + oracle = new SkillOracle(100 * 1e18, 2 minutes, admin, admin, pauser); + engine = new AgentPerpEngineNative(oracle, 1_000_000 * 1e18, admin, operator, pauser); + oracle.updateAgentSkill(agentId, 1500, 0); + vm.stopPrank(); + + vm.prank(operator); + engine.createMarket(agentId); + + vm.deal(alice, 1_000 ether); + } + + function testNativeWithdrawMarginRejectsNegativeEquityAfterOracleMove() public { + vm.prank(alice); + engine.modifyPosition{value: 200 ether}(agentId, int256(5 ether)); + + vm.prank(admin); + oracle.updateAgentSkill(agentId, 1500, 100); + + vm.expectRevert(AgentPerpEngineNative.Underwater.selector); + vm.prank(alice); + engine.withdrawMargin(agentId, 1 ether); + } +} From b1bc2377fa798e98e8e2a660804bf3382da50e48 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Fri, 1 May 2026 03:10:13 -0500 Subject: [PATCH 2/2] test(perps): cover native negative-equity modify --- .../evm-contracts/test/perps/AgentPerpEngine.t.sol | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol b/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol index da21f086..c7f868ec 100644 --- a/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol +++ b/packages/evm-contracts/test/perps/AgentPerpEngine.t.sol @@ -790,4 +790,16 @@ contract AgentPerpEngineNativeHealthTest is Test { vm.prank(alice); engine.withdrawMargin(agentId, 1 ether); } + + function testNativeModifyPositionRejectsNegativeEquityAfterOracleMove() public { + vm.prank(alice); + engine.modifyPosition{value: 200 ether}(agentId, int256(5 ether)); + + vm.prank(admin); + oracle.updateAgentSkill(agentId, 1500, 100); + + vm.expectRevert(AgentPerpEngineNative.Underwater.selector); + vm.prank(alice); + engine.modifyPosition{value: 1 ether}(agentId, int256(1 ether)); + } }