diff --git a/packages/evm-contracts/contracts/GoldClob.sol b/packages/evm-contracts/contracts/GoldClob.sol index 8b89ee40..cbf1671e 100644 --- a/packages/evm-contracts/contracts/GoldClob.sol +++ b/packages/evm-contracts/contracts/GoldClob.sol @@ -35,6 +35,7 @@ contract GoldClob is AccessControl, ReentrancyGuard { uint256 public tradeTreasuryFeeBps; uint256 public tradeMarketMakerFeeBps; uint256 public winningsMarketMakerFeeBps; + uint256 public reservedNative; bool public marketCreationPaused; bool public orderPlacementPaused; @@ -390,6 +391,7 @@ contract GoldClob is AccessControl, ReentrancyGuard { (progress.executedCost * market.tradeMarketMakerFeeBpsSnapshot) / MAX_FEE_BPS; uint256 requiredValue = restingCost + progress.executedCost + tradeTreasuryFee + tradeMarketMakerFee; if (msg.value < requiredValue) revert InsufficientNativeValue(); + _reserveNative(restingCost + progress.executedCost); if (tradeTreasuryFee > 0) payable(treasury).sendValue(tradeTreasuryFee); if (tradeMarketMakerFee > 0) payable(marketMaker).sendValue(tradeMarketMakerFee); uint256 traderRefund = msg.value - requiredValue; @@ -420,6 +422,7 @@ contract GoldClob is AccessControl, ReentrancyGuard { order.prevOrderId = 0; order.nextOrderId = 0; + _releaseNative(refund); if (refund > 0) payable(msg.sender).sendValue(refund); emit OrderCancelled(key, orderId); @@ -452,6 +455,7 @@ contract GoldClob is AccessControl, ReentrancyGuard { order.nextOrderId = 0; emit OrderCancelled(key, orderId); + _releaseNative(refund); if (refund > 0) payable(msg.sender).sendValue(refund); } @@ -472,11 +476,13 @@ contract GoldClob is AccessControl, ReentrancyGuard { if (winningShares > 0) { uint256 fee = (winningShares * market.winningsMarketMakerFeeBpsSnapshot) / MAX_FEE_BPS; payout = winningShares - fee; + _releaseNative(winningShares); if (fee > 0) payable(marketMaker).sendValue(fee); } } else if (status == MarketStatus.CANCELLED) { payout = uint256(position.aStake) + uint256(position.bStake); _clearPosition(position); + _releaseNative(payout); } else { revert MarketNotSettled(); } @@ -893,6 +899,18 @@ contract GoldClob is AccessControl, ReentrancyGuard { return orderFlags == ORDER_FLAGS_GTC_POST_ONLY; } + function _reserveNative(uint256 amount) internal { + if (amount > 0) { + reservedNative += amount; + } + } + + function _releaseNative(uint256 amount) internal { + if (amount > 0) { + reservedNative -= amount; + } + } + function _wouldCrossRestingBook(Market storage market, uint8 side, uint16 price) internal view returns (bool) { if (side == BUY_SIDE) { return market.bestAsk < MAX_PRICE && market.bestAsk <= price; @@ -908,10 +926,11 @@ contract GoldClob is AccessControl, ReentrancyGuard { function sweepETH(address payable to) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant { uint256 bal = address(this).balance; - require(bal > 0, "NothingToSweep"); + require(bal > reservedNative, "NothingToSweep"); address payable treasuryRecipient = payable(treasury); if (to == address(0) || to != treasuryRecipient) revert InvalidSweepRecipient(); - treasuryRecipient.sendValue(bal); - emit SweepETH(treasuryRecipient, bal); + uint256 excess = bal - reservedNative; + treasuryRecipient.sendValue(excess); + emit SweepETH(treasuryRecipient, excess); } } diff --git a/packages/evm-contracts/test/GoldClobSettlement.t.sol b/packages/evm-contracts/test/GoldClobSettlement.t.sol index 8a901d10..05b32f4a 100644 --- a/packages/evm-contracts/test/GoldClobSettlement.t.sol +++ b/packages/evm-contracts/test/GoldClobSettlement.t.sol @@ -167,6 +167,71 @@ contract GoldClobSettlementTest is Test { clob.claim(duel, MARKET_KIND_DUEL_WINNER); } + function testSweepCannotDrainRestingOrderCollateral() public { + bytes32 duel = _createOpenMarket("sweep-resting-collateral"); + uint128 amount = 1_000; + uint16 price = 600; + uint256 orderValue = _totalOrderValue(SELL_SIDE, price, amount); + uint256 refund = _quoteCost(SELL_SIDE, price, amount); + + vm.prank(traderA); + clob.placeOrder{value: orderValue}(duel, MARKET_KIND_DUEL_WINNER, SELL_SIDE, price, amount, ORDER_FLAG_GTC); + + assertEq(address(clob).balance, refund, "resting order collateral should be retained"); + + vm.expectRevert(bytes("NothingToSweep")); + vm.prank(admin); + clob.sweepETH(payable(treasury)); + + uint256 traderBefore = traderA.balance; + vm.prank(traderA); + clob.cancelOrder(duel, MARKET_KIND_DUEL_WINNER, 1); + assertEq(traderA.balance - traderBefore, refund, "resting collateral must remain refundable"); + } + + function testSweepCannotDrainResolvedWinnerPayout() public { + bytes32 duel = _createOpenMarket("sweep-resolved-payout"); + uint128 amount = 1_000; + + _matchTrade(duel, 600, amount); + _resolveDuel(duel, DuelOutcomeOracle.Side.A); + + vm.expectRevert(bytes("NothingToSweep")); + vm.prank(admin); + clob.sweepETH(payable(treasury)); + + uint256 traderBefore = traderB.balance; + vm.prank(traderB); + clob.claim(duel, MARKET_KIND_DUEL_WINNER); + + uint256 expectedFee = (uint256(amount) * 200) / 10_000; + assertEq(traderB.balance - traderBefore, uint256(amount) - expectedFee, "winner payout must remain claimable"); + } + + function testSweepTransfersOnlyForcedExcess() public { + bytes32 duel = _createOpenMarket("sweep-excess-only"); + uint128 amount = 1_000; + uint16 price = 600; + uint256 orderValue = _totalOrderValue(SELL_SIDE, price, amount); + uint256 refund = _quoteCost(SELL_SIDE, price, amount); + + vm.prank(traderA); + clob.placeOrder{value: orderValue}(duel, MARKET_KIND_DUEL_WINNER, SELL_SIDE, price, amount, ORDER_FLAG_GTC); + + uint256 forcedExcess = 1 ether; + vm.deal(admin, forcedExcess); + vm.prank(admin); + (bool sent,) = payable(address(clob)).call{value: forcedExcess}(""); + assertTrue(sent, "forced excess transfer failed"); + + uint256 treasuryBefore = treasury.balance; + vm.prank(admin); + clob.sweepETH(payable(treasury)); + + assertEq(treasury.balance - treasuryBefore, forcedExcess, "sweep should transfer only excess"); + assertEq(address(clob).balance, refund, "escrowed collateral must remain in the CLOB"); + } + function _createOpenMarket(string memory label) private returns (bytes32 duel) { duel = _duelKey(label); bytes32 participantA = _hashLabel(string.concat(label, "-a"));