From ca0cd24777f642935c1c1941b38b19f4ce938546 Mon Sep 17 00:00:00 2001 From: aniket866 Date: Sat, 13 Jun 2026 23:54:43 +0530 Subject: [PATCH 1/3] week2 --- script/DeployWindmill.s.sol | 3 +- src/core/WindmillExchange.sol | 158 ++++++++++++++++-- src/interfaces/IWindmillExchange.sol | 13 ++ test/WindmillExchange.t.sol | 229 ++++++++++++++++++++++++++- 4 files changed, 386 insertions(+), 17 deletions(-) diff --git a/script/DeployWindmill.s.sol b/script/DeployWindmill.s.sol index c56815a..8db5fa1 100644 --- a/script/DeployWindmill.s.sol +++ b/script/DeployWindmill.s.sol @@ -9,7 +9,8 @@ contract DeployWindmill is Script { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(deployerKey); - exchange = new WindmillExchange(); + address wethAddress = vm.envOr("WETH_ADDRESS", address(0xC02aaA39b223FE8D0A0e5C4F27ead9083C756Cc2)); + exchange = new WindmillExchange(wethAddress); vm.stopBroadcast(); diff --git a/src/core/WindmillExchange.sol b/src/core/WindmillExchange.sol index 175731e..806d669 100644 --- a/src/core/WindmillExchange.sol +++ b/src/core/WindmillExchange.sol @@ -27,26 +27,46 @@ error PairMismatch(); error ZeroSettlementPrice(); error UnsupportedTokenBehavior(); +error NotOwner(); +error ExchangePaused(); +error InvalidProtocolFee(); +error MismatchedValue(); +error NativeEthNotSupported(); +error EthTransferFailed(); + +interface IWETH { + function deposit() external payable; + function withdraw(uint256) external; +} + contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, ReentrancyGuard { address public owner; bool public paused; + address public immutable WETH; + address public treasury; + uint256 public protocolFeeBps; + event Paused(address indexed by); event Unpaused(address indexed by); modifier onlyOwner() { - require(msg.sender == owner, "Not owner"); + if (msg.sender != owner) revert NotOwner(); _; } modifier whenNotPaused() { - require(!paused, "Exchange paused"); + if (paused) revert ExchangePaused(); _; } - constructor() { + constructor(address _weth) { + if (_weth == address(0)) revert ZeroAddress(); owner = msg.sender; + WETH = _weth; } + receive() external payable {} + function pause() external onlyOwner { paused = true; emit Paused(msg.sender); @@ -57,6 +77,31 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent emit Unpaused(msg.sender); } + function setProtocolFee(address _treasury, uint256 _protocolFeeBps) external override onlyOwner { + if (_protocolFeeBps > 500) revert InvalidProtocolFee(); + if (_protocolFeeBps > 0 && _treasury == address(0)) revert ZeroAddress(); + treasury = _treasury; + protocolFeeBps = _protocolFeeBps; + emit ProtocolFeeUpdated(_treasury, _protocolFeeBps); + } + + function transferOwnership(address newOwner) external override onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + address oldOwner = owner; + owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } + + function _safeTransferTokenOrETH(address token, address to, uint256 amount) internal { + if (token == WETH) { + IWETH(WETH).withdraw(amount); + (bool success, ) = to.call{value: amount}(""); + if (!success) revert EthTransferFailed(); + } else { + TokenTransfer.safeTransfer(token, to, amount); + } + } + event OrderCreated( uint256 indexed orderId, address indexed maker, @@ -122,16 +167,22 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent _addOrderToPair(tokenIn, tokenOut, orderId); // Interactions - uint256 balBefore = IERC20(tokenIn).balanceOf(address(this)); - TokenTransfer.safeTransferFrom(tokenIn, msg.sender, address(this), amountIn); - if (IERC20(tokenIn).balanceOf(address(this)) - balBefore != amountIn) { - revert UnsupportedTokenBehavior(); + if (tokenIn == WETH && msg.value > 0) { + if (msg.value != amountIn) revert MismatchedValue(); + IWETH(WETH).deposit{value: msg.value}(); + } else { + if (msg.value > 0) revert NativeEthNotSupported(); + uint256 balBefore = IERC20(tokenIn).balanceOf(address(this)); + TokenTransfer.safeTransferFrom(tokenIn, msg.sender, address(this), amountIn); + if (IERC20(tokenIn).balanceOf(address(this)) - balBefore != amountIn) { + revert UnsupportedTokenBehavior(); + } } emit OrderCreated(orderId, msg.sender, tokenIn, tokenOut, amountIn, isBuy); } - function cancelOrder(uint256 orderId) external override nonReentrant whenNotPaused { + function cancelOrder(uint256 orderId) external override nonReentrant { Order storage order = _getOrder(orderId); if (order.maker != msg.sender) revert NotMaker(); @@ -146,7 +197,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent _removeOrderFromPair(tokenIn, tokenOut, orderId); // Interaction - TokenTransfer.safeTransfer(tokenIn, msg.sender, refund); + _safeTransferTokenOrETH(tokenIn, msg.sender, refund); emit OrderCancelled(orderId, msg.sender, refund); } @@ -195,14 +246,97 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent // Interactions uint256 keeperFee = notionalAmount / 1000; // 0.1% + uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; - TokenTransfer.safeTransfer(sell.tokenIn, buy.maker, executedQuantity); - TokenTransfer.safeTransfer(buy.tokenIn, sell.maker, notionalAmount - keeperFee); - TokenTransfer.safeTransfer(buy.tokenIn, msg.sender, keeperFee); + _safeTransferTokenOrETH(sell.tokenIn, buy.maker, executedQuantity); + _safeTransferTokenOrETH(buy.tokenIn, sell.maker, notionalAmount - keeperFee - protocolFee); + _safeTransferTokenOrETH(buy.tokenIn, msg.sender, keeperFee); + if (protocolFee > 0 && treasury != address(0)) { + _safeTransferTokenOrETH(buy.tokenIn, treasury, protocolFee); + } emit OrderMatched(buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity); } + function matchOrdersBatch( + uint256 orderId, + uint256[] calldata counterOrderIds, + uint256 deadline + ) external override nonReentrant whenNotPaused { + require(block.timestamp <= deadline, "Keeper deadline expired"); + uint256 len = counterOrderIds.length; + require(len > 0, "Empty counter orders"); + + for (uint256 i = 0; i < len; i++) { + uint256 counterOrderId = counterOrderIds[i]; + + Order memory order = _getOrderMem(orderId); + Order memory counterOrder = _getOrderMem(counterOrderId); + + uint256 buyOrderId; + uint256 sellOrderId; + Order memory buy; + Order memory sell; + + if (order.isBuy) { + buyOrderId = orderId; + sellOrderId = counterOrderId; + buy = order; + sell = counterOrder; + } else { + buyOrderId = counterOrderId; + sellOrderId = orderId; + buy = counterOrder; + sell = order; + } + + _validateMatch(buy, sell, block.timestamp); + + ( + uint256 settlementPrice, + uint256 executedQuantity, + uint256 notionalAmount, + bool buyFilled, + bool sellFilled + ) = _computeSettlement(buy, sell, block.timestamp); + + uint256 newBuyRemaining = buy.remainingIn - notionalAmount; + uint256 newSellRemaining = sell.remainingIn - executedQuantity; + + // Effects + if (buyFilled) { + _deactivateOrder(buyOrderId); + _removeOrderFromPair(buy.tokenIn, buy.tokenOut, buyOrderId); + emit OrderFilled(buyOrderId); + } else { + _updateRemainingIn(buyOrderId, newBuyRemaining); + emit OrderPartiallyFilled(buyOrderId, newBuyRemaining); + } + + if (sellFilled) { + _deactivateOrder(sellOrderId); + _removeOrderFromPair(sell.tokenIn, sell.tokenOut, sellOrderId); + emit OrderFilled(sellOrderId); + } else { + _updateRemainingIn(sellOrderId, newSellRemaining); + emit OrderPartiallyFilled(sellOrderId, newSellRemaining); + } + + // Interactions + uint256 keeperFee = notionalAmount / 1000; // 0.1% + uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; + + _safeTransferTokenOrETH(sell.tokenIn, buy.maker, executedQuantity); + _safeTransferTokenOrETH(buy.tokenIn, sell.maker, notionalAmount - keeperFee - protocolFee); + _safeTransferTokenOrETH(buy.tokenIn, msg.sender, keeperFee); + if (protocolFee > 0 && treasury != address(0)) { + _safeTransferTokenOrETH(buy.tokenIn, treasury, protocolFee); + } + + emit OrderMatched(buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity); + } + } + function currentPrice(uint256 orderId, uint256 timestamp) external view diff --git a/src/interfaces/IWindmillExchange.sol b/src/interfaces/IWindmillExchange.sol index c31e4ae..a1012bf 100644 --- a/src/interfaces/IWindmillExchange.sol +++ b/src/interfaces/IWindmillExchange.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.23; import { Order } from "../types/OrderTypes.sol"; interface IWindmillExchange { + event ProtocolFeeUpdated(address indexed treasury, uint256 protocolFeeBps); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + function createOrder( address tokenIn, address tokenOut, @@ -20,6 +23,16 @@ interface IWindmillExchange { function matchOrders(uint256 buyOrderId, uint256 sellOrderId, uint256 deadline) external; + function matchOrdersBatch( + uint256 orderId, + uint256[] calldata counterOrderIds, + uint256 deadline + ) external; + + function setProtocolFee(address _treasury, uint256 _protocolFeeBps) external; + + function transferOwnership(address newOwner) external; + function currentPrice(uint256 orderId, uint256 timestamp) external view returns (uint256 price); function getOrder(uint256 orderId) external view returns (Order memory); diff --git a/test/WindmillExchange.t.sol b/test/WindmillExchange.t.sol index 98c64a7..00660a9 100644 --- a/test/WindmillExchange.t.sol +++ b/test/WindmillExchange.t.sol @@ -19,7 +19,13 @@ import { OrdersNotMatchable, PairMismatch, ZeroSettlementPrice, - UnsupportedTokenBehavior + UnsupportedTokenBehavior, + NotOwner, + ExchangePaused, + InvalidProtocolFee, + MismatchedValue, + NativeEthNotSupported, + EthTransferFailed } from "../src/core/WindmillExchange.sol"; contract MockERC20 { @@ -82,32 +88,68 @@ contract FeeToken { } } +contract MockWETH is MockERC20 { + constructor() MockERC20("Wrapped Ether", "WETH") {} + + fallback() external payable { + deposit(); + } + + receive() external payable { + deposit(); + } + + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + totalSupply += msg.value; + emit Transfer(address(0), msg.sender, msg.value); + } + + function withdraw(uint256 amount) public { + require(balanceOf[msg.sender] >= amount, "insufficient balance"); + balanceOf[msg.sender] -= amount; + totalSupply -= amount; + emit Transfer(msg.sender, address(0), amount); + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "ETH transfer failed"); + } +} + contract WindmillExchangeTest is Test { uint256 internal constant RAY = 1e27; WindmillExchange internal exchange; MockERC20 internal tokenA; MockERC20 internal tokenB; + MockWETH internal weth; address internal alice = makeAddr("alice"); address internal bob = makeAddr("bob"); function setUp() public { - exchange = new WindmillExchange(); + weth = new MockWETH(); + exchange = new WindmillExchange(address(weth)); tokenA = new MockERC20("TokenA", "TKNA"); tokenB = new MockERC20("TokenB", "TKNB"); tokenA.mint(alice, 1_000_000 ether); tokenB.mint(bob, 1_000_000 ether); + weth.mint(alice, 1_000_000 ether); + deal(alice, 1_000_000 ether); + deal(bob, 1_000_000 ether); vm.prank(alice); tokenA.approve(address(exchange), type(uint256).max); vm.prank(alice); tokenB.approve(address(exchange), type(uint256).max); + vm.prank(alice); + weth.approve(address(exchange), type(uint256).max); vm.prank(bob); tokenA.approve(address(exchange), type(uint256).max); vm.prank(bob); tokenB.approve(address(exchange), type(uint256).max); + vm.prank(bob); + weth.approve(address(exchange), type(uint256).max); } // Helpers @@ -505,13 +547,13 @@ contract WindmillExchangeTest is Test { function test_pause_unpause() public { vm.prank(bob); - vm.expectRevert("Not owner"); + vm.expectRevert(NotOwner.selector); exchange.pause(); exchange.pause(); assertTrue(exchange.paused()); - vm.expectRevert("Exchange paused"); + vm.expectRevert(ExchangePaused.selector); exchange.createOrder(address(tokenA), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true); exchange.unpause(); @@ -582,4 +624,183 @@ contract WindmillExchangeTest is Test { orders = exchange.getOrdersByPair(address(tokenA), address(tokenB), 5, 10); assertEq(orders.length, 0); } + + receive() external payable {} + + // ---------------------------------------------------- + // New Feature Tests: Batch Matching, Native ETH, Protocol Fees, Access & Pausing + // ---------------------------------------------------- + + function test_matchOrdersBatch_buyAgainstMultipleSells() public { + uint256 price = RAY; + uint256 buyId = _createBuyOrder(alice, 300 ether, price, 0, 0); + + uint256 sellId1 = _createSellOrder(bob, 100 ether, price, 0, 0); + uint256 sellId2 = _createSellOrder(bob, 100 ether, price, 0, 0); + uint256 sellId3 = _createSellOrder(bob, 100 ether, price, 0, 0); + + uint256[] memory counterOrderIds = new uint256[](3); + counterOrderIds[0] = sellId1; + counterOrderIds[1] = sellId2; + counterOrderIds[2] = sellId3; + + uint256 aliceTokenBBefore = tokenB.balanceOf(alice); + uint256 bobTokenABefore = tokenA.balanceOf(bob); + + exchange.matchOrdersBatch(buyId, counterOrderIds, block.timestamp + 1); + + assertFalse(exchange.getOrder(buyId).active); + assertFalse(exchange.getOrder(sellId1).active); + assertFalse(exchange.getOrder(sellId2).active); + assertFalse(exchange.getOrder(sellId3).active); + + assertEq(tokenB.balanceOf(alice) - aliceTokenBBefore, 300 ether); + assertEq(tokenA.balanceOf(bob) - bobTokenABefore, 300 ether - 0.3 ether); + } + + function test_matchOrdersBatch_sellAgainstMultipleBuys() public { + uint256 price = RAY; + uint256 sellId = _createSellOrder(bob, 250 ether, price, 0, 0); + + uint256 buyId1 = _createBuyOrder(alice, 100 ether, price, 0, 0); + uint256 buyId2 = _createBuyOrder(alice, 100 ether, price, 0, 0); + uint256 buyId3 = _createBuyOrder(alice, 100 ether, price, 0, 0); + + uint256[] memory counterOrderIds = new uint256[](3); + counterOrderIds[0] = buyId1; + counterOrderIds[1] = buyId2; + counterOrderIds[2] = buyId3; + + exchange.matchOrdersBatch(sellId, counterOrderIds, block.timestamp + 1); + + assertFalse(exchange.getOrder(sellId).active); + assertFalse(exchange.getOrder(buyId1).active); + assertFalse(exchange.getOrder(buyId2).active); + assertTrue(exchange.getOrder(buyId3).active); + assertEq(exchange.getOrder(buyId3).remainingIn, 50 ether); + } + + function test_nativeETH_wrapOnDeposit() public { + uint256 startBal = alice.balance; + vm.prank(alice); + uint256 id = exchange.createOrder{value: 100 ether}( + address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true + ); + + assertEq(alice.balance, startBal - 100 ether); + assertEq(weth.balanceOf(address(exchange)), 100 ether); + Order memory o = exchange.getOrder(id); + assertEq(o.tokenIn, address(weth)); + assertEq(o.remainingIn, 100 ether); + } + + function test_nativeETH_unwrapOnCancel() public { + vm.prank(alice); + uint256 id = exchange.createOrder{value: 100 ether}( + address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true + ); + + uint256 balBefore = alice.balance; + vm.prank(alice); + exchange.cancelOrder(id); + + assertEq(alice.balance, balBefore + 100 ether); + assertEq(weth.balanceOf(address(exchange)), 0); + } + + function test_nativeETH_unwrapOnSettlement() public { + vm.prank(alice); + uint256 buyId = exchange.createOrder{value: 100 ether}( + address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true + ); + + vm.prank(bob); + uint256 sellId = exchange.createOrder( + address(tokenB), address(weth), 100 ether, RAY, 0, 0, 0, 0, false + ); + + uint256 aliceBBefore = tokenB.balanceOf(alice); + uint256 bobETHBefore = bob.balance; + uint256 keeperETHBefore = address(this).balance; + + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + assertEq(tokenB.balanceOf(alice) - aliceBBefore, 100 ether); + assertEq(bob.balance - bobETHBefore, 99.9 ether); + assertEq(address(this).balance - keeperETHBefore, 0.1 ether); + } + + function test_nativeETH_revertOnNonWethValue() public { + vm.prank(alice); + vm.expectRevert(NativeEthNotSupported.selector); + exchange.createOrder{value: 10 ether}( + address(tokenA), address(tokenB), 10 ether, RAY, 0, 0, 0, 0, true + ); + } + + function test_nativeETH_revertOnMismatchedValue() public { + vm.prank(alice); + vm.expectRevert(MismatchedValue.selector); + exchange.createOrder{value: 5 ether}( + address(weth), address(tokenB), 10 ether, RAY, 0, 0, 0, 0, true + ); + } + + function test_protocolFees_setFeeAndRespectCap() public { + vm.prank(bob); + vm.expectRevert(NotOwner.selector); + exchange.setProtocolFee(bob, 100); + + vm.expectRevert(InvalidProtocolFee.selector); + exchange.setProtocolFee(bob, 501); + + vm.expectRevert(ZeroAddress.selector); + exchange.setProtocolFee(address(0), 100); + + exchange.setProtocolFee(bob, 200); + assertEq(exchange.treasury(), bob); + assertEq(exchange.protocolFeeBps(), 200); + } + + function test_protocolFees_collectionOnSettlement() public { + address treasuryAddr = makeAddr("treasury"); + exchange.setProtocolFee(treasuryAddr, 200); + + uint256 buyId = _createBuyOrder(alice, 100 ether, RAY, 0, 0); + uint256 sellId = _createSellOrder(bob, 100 ether, RAY, 0, 0); + + uint256 treasuryBefore = tokenA.balanceOf(treasuryAddr); + uint256 bobSellerBefore = tokenA.balanceOf(bob); + + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + assertEq(tokenA.balanceOf(bob) - bobSellerBefore, 97.9 ether); + assertEq(tokenA.balanceOf(treasuryAddr) - treasuryBefore, 2 ether); + } + + function test_cancelOrder_allowedDuringPause() public { + uint256 id = _createBuyOrder(alice, 100 ether, RAY, 0, 0); + + exchange.pause(); + assertTrue(exchange.paused()); + + uint256 balBefore = tokenA.balanceOf(alice); + vm.prank(alice); + exchange.cancelOrder(id); + + assertEq(tokenA.balanceOf(alice), balBefore + 100 ether); + assertFalse(exchange.getOrder(id).active); + } + + function test_transferOwnership() public { + vm.prank(bob); + vm.expectRevert(NotOwner.selector); + exchange.transferOwnership(bob); + + vm.expectRevert(ZeroAddress.selector); + exchange.transferOwnership(address(0)); + + exchange.transferOwnership(bob); + assertEq(exchange.owner(), bob); + } } From 1a1bc93d3e3059d7d3de2a00137edc4a8e395a7c Mon Sep 17 00:00:00 2001 From: aniket866 Date: Wed, 17 Jun 2026 01:20:24 +0530 Subject: [PATCH 2/3] week2 --- .gas-snapshot | 47 ++++++++++++++++++++++++++++ script/DeployWindmill.s.sol | 3 +- src/core/WindmillExchange.sol | 33 ++++++++++++------- src/interfaces/IWindmillExchange.sol | 9 ++---- test/WindmillExchange.t.sol | 16 +++++----- 5 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 .gas-snapshot diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 0000000..b41b80f --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,47 @@ +WindmillExchangeTest:testFuzz_escrow(uint96) (runs: 512, μ: 331572, ~: 331572) +WindmillExchangeTest:test_cancelOrder_allowedDuringPause() (gas: 276408) +WindmillExchangeTest:test_cancelOrder_revert_alreadyInactive() (gas: 270045) +WindmillExchangeTest:test_cancelOrder_revert_notMaker() (gas: 327828) +WindmillExchangeTest:test_cancelOrder_success() (gas: 275776) +WindmillExchangeTest:test_createBuyOrder_success() (gas: 332594) +WindmillExchangeTest:test_createOrder_revert_expiryInPast() (gas: 23785) +WindmillExchangeTest:test_createOrder_revert_invalidPriceBounds() (gas: 23575) +WindmillExchangeTest:test_createOrder_revert_sameToken() (gas: 21198) +WindmillExchangeTest:test_createOrder_revert_slopeOverflow() (gas: 23574) +WindmillExchangeTest:test_createOrder_revert_zeroAmount() (gas: 23327) +WindmillExchangeTest:test_createOrder_revert_zeroStartPrice() (gas: 23367) +WindmillExchangeTest:test_createOrder_revert_zeroTokenIn() (gas: 21169) +WindmillExchangeTest:test_createOrder_revert_zeroTokenOut() (gas: 21181) +WindmillExchangeTest:test_createOrder_unsupportedTokenBehavior() (gas: 374533) +WindmillExchangeTest:test_createOrder_withExpiry() (gas: 345551) +WindmillExchangeTest:test_createOrder_withSlope() (gas: 345621) +WindmillExchangeTest:test_createSellOrder_success() (gas: 327237) +WindmillExchangeTest:test_currentPrice_afterFullMatch_orderInactive() (gas: 570843) +WindmillExchangeTest:test_currentPrice_descendingSlope_decreasesOverTime() (gas: 348785) +WindmillExchangeTest:test_currentPrice_flatOrder_returnsStartPrice() (gas: 328115) +WindmillExchangeTest:test_currentPrice_maxPriceClamp() (gas: 364382) +WindmillExchangeTest:test_currentPrice_minPriceClamp() (gas: 364495) +WindmillExchangeTest:test_matchOrdersBatch_buyAgainstMultipleSells() (gas: 1064539) +WindmillExchangeTest:test_matchOrdersBatch_sellAgainstMultipleBuys() (gas: 1143131) +WindmillExchangeTest:test_matchOrders_partialFill_buySmaller() (gas: 653321) +WindmillExchangeTest:test_matchOrders_partialFill_sellSmaller() (gas: 649871) +WindmillExchangeTest:test_matchOrders_revert_expired() (gas: 629278) +WindmillExchangeTest:test_matchOrders_revert_noCross() (gas: 610061) +WindmillExchangeTest:test_matchOrders_revert_pairMismatch_differentTokens() (gas: 1148431) +WindmillExchangeTest:test_matchOrders_revert_pairMismatch_wrongIsBuy() (gas: 593558) +WindmillExchangeTest:test_matchOrders_revert_selfMatch() (gas: 616197) +WindmillExchangeTest:test_matchOrders_success_fullFill() (gas: 579890) +WindmillExchangeTest:test_nativeETH_revertOnMismatchedValue() (gas: 289196) +WindmillExchangeTest:test_nativeETH_revertOnNonWethValue() (gas: 289193) +WindmillExchangeTest:test_nativeETH_unwrapOnCancel() (gas: 283139) +WindmillExchangeTest:test_nativeETH_unwrapOnSettlement() (gas: 557453) +WindmillExchangeTest:test_nativeETH_wrapOnDeposit() (gas: 337323) +WindmillExchangeTest:test_ordersByPair_pagination() (gas: 818434) +WindmillExchangeTest:test_ordersByPair_registered() (gas: 603163) +WindmillExchangeTest:test_pause_unpause() (gas: 341182) +WindmillExchangeTest:test_protocolFees_collectionOnSettlement() (gas: 639979) +WindmillExchangeTest:test_protocolFees_setFeeAndRespectCap() (gas: 72086) +WindmillExchangeTest:test_totalOrders() (gas: 322601) +WindmillExchangeTest:test_transferFrom_allowance() (gas: 323011) +WindmillExchangeTest:test_transferFrom_insufficientAllowance() (gas: 301036) +WindmillExchangeTest:test_transferOwnership() (gas: 25910) \ No newline at end of file diff --git a/script/DeployWindmill.s.sol b/script/DeployWindmill.s.sol index 8db5fa1..1413026 100644 --- a/script/DeployWindmill.s.sol +++ b/script/DeployWindmill.s.sol @@ -9,7 +9,8 @@ contract DeployWindmill is Script { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(deployerKey); - address wethAddress = vm.envOr("WETH_ADDRESS", address(0xC02aaA39b223FE8D0A0e5C4F27ead9083C756Cc2)); + address wethAddress = + vm.envOr("WETH_ADDRESS", address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)); exchange = new WindmillExchange(wethAddress); vm.stopBroadcast(); diff --git a/src/core/WindmillExchange.sol b/src/core/WindmillExchange.sol index 806d669..d6edcd1 100644 --- a/src/core/WindmillExchange.sol +++ b/src/core/WindmillExchange.sol @@ -65,7 +65,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent WETH = _weth; } - receive() external payable {} + receive() external payable { } function pause() external onlyOwner { paused = true; @@ -77,7 +77,11 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent emit Unpaused(msg.sender); } - function setProtocolFee(address _treasury, uint256 _protocolFeeBps) external override onlyOwner { + function setProtocolFee(address _treasury, uint256 _protocolFeeBps) + external + override + onlyOwner + { if (_protocolFeeBps > 500) revert InvalidProtocolFee(); if (_protocolFeeBps > 0 && _treasury == address(0)) revert ZeroAddress(); treasury = _treasury; @@ -95,7 +99,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent function _safeTransferTokenOrETH(address token, address to, uint256 amount) internal { if (token == WETH) { IWETH(WETH).withdraw(amount); - (bool success, ) = to.call{value: amount}(""); + (bool success,) = to.call{ value: amount }(""); if (!success) revert EthTransferFailed(); } else { TokenTransfer.safeTransfer(token, to, amount); @@ -135,7 +139,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent uint256 maxPrice, uint256 expiry, bool isBuy - ) external override nonReentrant whenNotPaused returns (uint256 orderId) { + ) external payable override nonReentrant whenNotPaused returns (uint256 orderId) { // Checks if (tokenIn == address(0) || tokenOut == address(0)) revert ZeroAddress(); if (tokenIn == tokenOut) revert SameToken(); @@ -169,7 +173,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent // Interactions if (tokenIn == WETH && msg.value > 0) { if (msg.value != amountIn) revert MismatchedValue(); - IWETH(WETH).deposit{value: msg.value}(); + IWETH(WETH).deposit{ value: msg.value }(); } else { if (msg.value > 0) revert NativeEthNotSupported(); uint256 balBefore = IERC20(tokenIn).balanceOf(address(this)); @@ -258,11 +262,12 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent emit OrderMatched(buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity); } - function matchOrdersBatch( - uint256 orderId, - uint256[] calldata counterOrderIds, - uint256 deadline - ) external override nonReentrant whenNotPaused { + function matchOrdersBatch(uint256 orderId, uint256[] calldata counterOrderIds, uint256 deadline) + external + override + nonReentrant + whenNotPaused + { require(block.timestamp <= deadline, "Keeper deadline expired"); uint256 len = counterOrderIds.length; require(len > 0, "Empty counter orders"); @@ -327,13 +332,17 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; _safeTransferTokenOrETH(sell.tokenIn, buy.maker, executedQuantity); - _safeTransferTokenOrETH(buy.tokenIn, sell.maker, notionalAmount - keeperFee - protocolFee); + _safeTransferTokenOrETH( + buy.tokenIn, sell.maker, notionalAmount - keeperFee - protocolFee + ); _safeTransferTokenOrETH(buy.tokenIn, msg.sender, keeperFee); if (protocolFee > 0 && treasury != address(0)) { _safeTransferTokenOrETH(buy.tokenIn, treasury, protocolFee); } - emit OrderMatched(buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity); + emit OrderMatched( + buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity + ); } } diff --git a/src/interfaces/IWindmillExchange.sol b/src/interfaces/IWindmillExchange.sol index a1012bf..ba3e541 100644 --- a/src/interfaces/IWindmillExchange.sol +++ b/src/interfaces/IWindmillExchange.sol @@ -17,17 +17,14 @@ interface IWindmillExchange { uint256 maxPrice, uint256 expiry, bool isBuy - ) external returns (uint256 orderId); + ) external payable returns (uint256 orderId); function cancelOrder(uint256 orderId) external; function matchOrders(uint256 buyOrderId, uint256 sellOrderId, uint256 deadline) external; - function matchOrdersBatch( - uint256 orderId, - uint256[] calldata counterOrderIds, - uint256 deadline - ) external; + function matchOrdersBatch(uint256 orderId, uint256[] calldata counterOrderIds, uint256 deadline) + external; function setProtocolFee(address _treasury, uint256 _protocolFeeBps) external; diff --git a/test/WindmillExchange.t.sol b/test/WindmillExchange.t.sol index 00660a9..7992654 100644 --- a/test/WindmillExchange.t.sol +++ b/test/WindmillExchange.t.sol @@ -89,7 +89,7 @@ contract FeeToken { } contract MockWETH is MockERC20 { - constructor() MockERC20("Wrapped Ether", "WETH") {} + constructor() MockERC20("Wrapped Ether", "WETH") { } fallback() external payable { deposit(); @@ -110,7 +110,7 @@ contract MockWETH is MockERC20 { balanceOf[msg.sender] -= amount; totalSupply -= amount; emit Transfer(msg.sender, address(0), amount); - (bool success, ) = msg.sender.call{value: amount}(""); + (bool success,) = msg.sender.call{ value: amount }(""); require(success, "ETH transfer failed"); } } @@ -625,7 +625,7 @@ contract WindmillExchangeTest is Test { assertEq(orders.length, 0); } - receive() external payable {} + receive() external payable { } // ---------------------------------------------------- // New Feature Tests: Batch Matching, Native ETH, Protocol Fees, Access & Pausing @@ -683,7 +683,7 @@ contract WindmillExchangeTest is Test { function test_nativeETH_wrapOnDeposit() public { uint256 startBal = alice.balance; vm.prank(alice); - uint256 id = exchange.createOrder{value: 100 ether}( + uint256 id = exchange.createOrder{ value: 100 ether }( address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true ); @@ -696,7 +696,7 @@ contract WindmillExchangeTest is Test { function test_nativeETH_unwrapOnCancel() public { vm.prank(alice); - uint256 id = exchange.createOrder{value: 100 ether}( + uint256 id = exchange.createOrder{ value: 100 ether }( address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true ); @@ -710,7 +710,7 @@ contract WindmillExchangeTest is Test { function test_nativeETH_unwrapOnSettlement() public { vm.prank(alice); - uint256 buyId = exchange.createOrder{value: 100 ether}( + uint256 buyId = exchange.createOrder{ value: 100 ether }( address(weth), address(tokenB), 100 ether, RAY, 0, 0, 0, 0, true ); @@ -733,7 +733,7 @@ contract WindmillExchangeTest is Test { function test_nativeETH_revertOnNonWethValue() public { vm.prank(alice); vm.expectRevert(NativeEthNotSupported.selector); - exchange.createOrder{value: 10 ether}( + exchange.createOrder{ value: 10 ether }( address(tokenA), address(tokenB), 10 ether, RAY, 0, 0, 0, 0, true ); } @@ -741,7 +741,7 @@ contract WindmillExchangeTest is Test { function test_nativeETH_revertOnMismatchedValue() public { vm.prank(alice); vm.expectRevert(MismatchedValue.selector); - exchange.createOrder{value: 5 ether}( + exchange.createOrder{ value: 5 ether }( address(weth), address(tokenB), 10 ether, RAY, 0, 0, 0, 0, true ); } From 6392027f909e2f8ea56ac16de72d41f440b2a40f Mon Sep 17 00:00:00 2001 From: aniket866 Date: Wed, 17 Jun 2026 01:38:41 +0530 Subject: [PATCH 3/3] code-rabbit-suggestions --- .gas-snapshot | 102 +++++++------- src/core/WindmillExchange.sol | 198 ++++++++++++++++++++------- src/interfaces/IPriceOracle.sol | 6 + src/interfaces/IWindmillExchange.sol | 21 ++- src/types/OrderTypes.sol | 8 ++ test/WindmillExchange.t.sol | 198 ++++++++++++++++++++++++++- test/mocks/MockPriceOracle.sol | 16 +++ 7 files changed, 450 insertions(+), 99 deletions(-) create mode 100644 src/interfaces/IPriceOracle.sol create mode 100644 test/mocks/MockPriceOracle.sol diff --git a/.gas-snapshot b/.gas-snapshot index b41b80f..45eab8a 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,47 +1,55 @@ -WindmillExchangeTest:testFuzz_escrow(uint96) (runs: 512, μ: 331572, ~: 331572) -WindmillExchangeTest:test_cancelOrder_allowedDuringPause() (gas: 276408) -WindmillExchangeTest:test_cancelOrder_revert_alreadyInactive() (gas: 270045) -WindmillExchangeTest:test_cancelOrder_revert_notMaker() (gas: 327828) -WindmillExchangeTest:test_cancelOrder_success() (gas: 275776) -WindmillExchangeTest:test_createBuyOrder_success() (gas: 332594) -WindmillExchangeTest:test_createOrder_revert_expiryInPast() (gas: 23785) -WindmillExchangeTest:test_createOrder_revert_invalidPriceBounds() (gas: 23575) -WindmillExchangeTest:test_createOrder_revert_sameToken() (gas: 21198) -WindmillExchangeTest:test_createOrder_revert_slopeOverflow() (gas: 23574) -WindmillExchangeTest:test_createOrder_revert_zeroAmount() (gas: 23327) -WindmillExchangeTest:test_createOrder_revert_zeroStartPrice() (gas: 23367) -WindmillExchangeTest:test_createOrder_revert_zeroTokenIn() (gas: 21169) -WindmillExchangeTest:test_createOrder_revert_zeroTokenOut() (gas: 21181) -WindmillExchangeTest:test_createOrder_unsupportedTokenBehavior() (gas: 374533) -WindmillExchangeTest:test_createOrder_withExpiry() (gas: 345551) -WindmillExchangeTest:test_createOrder_withSlope() (gas: 345621) -WindmillExchangeTest:test_createSellOrder_success() (gas: 327237) -WindmillExchangeTest:test_currentPrice_afterFullMatch_orderInactive() (gas: 570843) -WindmillExchangeTest:test_currentPrice_descendingSlope_decreasesOverTime() (gas: 348785) -WindmillExchangeTest:test_currentPrice_flatOrder_returnsStartPrice() (gas: 328115) -WindmillExchangeTest:test_currentPrice_maxPriceClamp() (gas: 364382) -WindmillExchangeTest:test_currentPrice_minPriceClamp() (gas: 364495) -WindmillExchangeTest:test_matchOrdersBatch_buyAgainstMultipleSells() (gas: 1064539) -WindmillExchangeTest:test_matchOrdersBatch_sellAgainstMultipleBuys() (gas: 1143131) -WindmillExchangeTest:test_matchOrders_partialFill_buySmaller() (gas: 653321) -WindmillExchangeTest:test_matchOrders_partialFill_sellSmaller() (gas: 649871) -WindmillExchangeTest:test_matchOrders_revert_expired() (gas: 629278) -WindmillExchangeTest:test_matchOrders_revert_noCross() (gas: 610061) -WindmillExchangeTest:test_matchOrders_revert_pairMismatch_differentTokens() (gas: 1148431) -WindmillExchangeTest:test_matchOrders_revert_pairMismatch_wrongIsBuy() (gas: 593558) -WindmillExchangeTest:test_matchOrders_revert_selfMatch() (gas: 616197) -WindmillExchangeTest:test_matchOrders_success_fullFill() (gas: 579890) -WindmillExchangeTest:test_nativeETH_revertOnMismatchedValue() (gas: 289196) -WindmillExchangeTest:test_nativeETH_revertOnNonWethValue() (gas: 289193) -WindmillExchangeTest:test_nativeETH_unwrapOnCancel() (gas: 283139) -WindmillExchangeTest:test_nativeETH_unwrapOnSettlement() (gas: 557453) -WindmillExchangeTest:test_nativeETH_wrapOnDeposit() (gas: 337323) -WindmillExchangeTest:test_ordersByPair_pagination() (gas: 818434) -WindmillExchangeTest:test_ordersByPair_registered() (gas: 603163) -WindmillExchangeTest:test_pause_unpause() (gas: 341182) -WindmillExchangeTest:test_protocolFees_collectionOnSettlement() (gas: 639979) -WindmillExchangeTest:test_protocolFees_setFeeAndRespectCap() (gas: 72086) -WindmillExchangeTest:test_totalOrders() (gas: 322601) -WindmillExchangeTest:test_transferFrom_allowance() (gas: 323011) -WindmillExchangeTest:test_transferFrom_insufficientAllowance() (gas: 301036) -WindmillExchangeTest:test_transferOwnership() (gas: 25910) \ No newline at end of file +WindmillExchangeTest:testFuzz_escrow(uint96) (runs: 512, μ: 337099, ~: 337099) +WindmillExchangeTest:test_cancelOrder_allowedDuringPause() (gas: 280865) +WindmillExchangeTest:test_cancelOrder_revert_alreadyInactive() (gas: 273902) +WindmillExchangeTest:test_cancelOrder_revert_notMaker() (gas: 332714) +WindmillExchangeTest:test_cancelOrder_success() (gas: 280188) +WindmillExchangeTest:test_conditionalOrder_creation_revert_zeroTriggerPrice() (gas: 23863) +WindmillExchangeTest:test_conditionalOrder_revert_oracleNotSet() (gas: 661329) +WindmillExchangeTest:test_createBuyOrder_success() (gas: 338080) +WindmillExchangeTest:test_createOrder_revert_expiryInPast() (gas: 23902) +WindmillExchangeTest:test_createOrder_revert_invalidPriceBounds() (gas: 23648) +WindmillExchangeTest:test_createOrder_revert_sameToken() (gas: 21271) +WindmillExchangeTest:test_createOrder_revert_slopeOverflow() (gas: 23626) +WindmillExchangeTest:test_createOrder_revert_zeroAmount() (gas: 23400) +WindmillExchangeTest:test_createOrder_revert_zeroStartPrice() (gas: 23484) +WindmillExchangeTest:test_createOrder_revert_zeroTokenIn() (gas: 21264) +WindmillExchangeTest:test_createOrder_revert_zeroTokenOut() (gas: 21232) +WindmillExchangeTest:test_createOrder_unsupportedTokenBehavior() (gas: 379325) +WindmillExchangeTest:test_createOrder_withExpiry() (gas: 351078) +WindmillExchangeTest:test_createOrder_withSlope() (gas: 351214) +WindmillExchangeTest:test_createSellOrder_success() (gas: 332741) +WindmillExchangeTest:test_currentPrice_afterFullMatch_orderInactive() (gas: 582534) +WindmillExchangeTest:test_currentPrice_descendingSlope_decreasesOverTime() (gas: 354417) +WindmillExchangeTest:test_currentPrice_flatOrder_returnsStartPrice() (gas: 333814) +WindmillExchangeTest:test_currentPrice_maxPriceClamp() (gas: 369591) +WindmillExchangeTest:test_currentPrice_minPriceClamp() (gas: 369792) +WindmillExchangeTest:test_matchOrdersBatch_buyAgainstMultipleSells() (gas: 1076915) +WindmillExchangeTest:test_matchOrdersBatch_gasOptimized() (gas: 1157271) +WindmillExchangeTest:test_matchOrdersBatch_sellAgainstMultipleBuys() (gas: 1157202) +WindmillExchangeTest:test_matchOrders_partialFill_buySmaller() (gas: 665376) +WindmillExchangeTest:test_matchOrders_partialFill_sellSmaller() (gas: 661948) +WindmillExchangeTest:test_matchOrders_revert_expired() (gas: 639786) +WindmillExchangeTest:test_matchOrders_revert_noCross() (gas: 620675) +WindmillExchangeTest:test_matchOrders_revert_pairMismatch_differentTokens() (gas: 1158895) +WindmillExchangeTest:test_matchOrders_revert_pairMismatch_wrongIsBuy() (gas: 603977) +WindmillExchangeTest:test_matchOrders_revert_selfMatch() (gas: 626683) +WindmillExchangeTest:test_matchOrders_success_fullFill() (gas: 591922) +WindmillExchangeTest:test_nativeETH_revertOnMismatchedValue() (gas: 293901) +WindmillExchangeTest:test_nativeETH_revertOnNonWethValue() (gas: 293920) +WindmillExchangeTest:test_nativeETH_unwrapOnCancel() (gas: 286987) +WindmillExchangeTest:test_nativeETH_unwrapOnSettlement() (gas: 565892) +WindmillExchangeTest:test_nativeETH_wrapOnDeposit() (gas: 342852) +WindmillExchangeTest:test_ordersByPair_pagination() (gas: 832990) +WindmillExchangeTest:test_ordersByPair_registered() (gas: 612845) +WindmillExchangeTest:test_pause_unpause() (gas: 346151) +WindmillExchangeTest:test_payout_revert_EthTransferFailed() (gas: 713337) +WindmillExchangeTest:test_protocolFees_collectionOnSettlement() (gas: 650593) +WindmillExchangeTest:test_protocolFees_setFeeAndRespectCap() (gas: 72218) +WindmillExchangeTest:test_setPriceOracle_revert_notOwner() (gas: 132319) +WindmillExchangeTest:test_setPriceOracle_success() (gas: 151326) +WindmillExchangeTest:test_stopLoss_sell_trigger() (gas: 800347) +WindmillExchangeTest:test_takeProfit_sell_trigger() (gas: 800071) +WindmillExchangeTest:test_totalOrders() (gas: 327431) +WindmillExchangeTest:test_transferFrom_allowance() (gas: 327797) +WindmillExchangeTest:test_transferFrom_insufficientAllowance() (gas: 305720) +WindmillExchangeTest:test_transferOwnership() (gas: 25932) \ No newline at end of file diff --git a/src/core/WindmillExchange.sol b/src/core/WindmillExchange.sol index d6edcd1..5118cb9 100644 --- a/src/core/WindmillExchange.sol +++ b/src/core/WindmillExchange.sol @@ -2,13 +2,14 @@ pragma solidity ^0.8.23; import { IERC20 } from "../interfaces/IERC20.sol"; -import { Order } from "../types/OrderTypes.sol"; +import { Order, OrderType } from "../types/OrderTypes.sol"; import { OrderStorage } from "../storage/OrderStorage.sol"; import { PairStorage } from "../storage/PairStorage.sol"; import { PriceCurve } from "../libraries/PriceCurve.sol"; import { TokenTransfer } from "../libraries/TokenTransfer.sol"; import { MathUtils } from "../libraries/MathUtils.sol"; import { IWindmillExchange } from "../interfaces/IWindmillExchange.sol"; +import { IPriceOracle } from "../interfaces/IPriceOracle.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; error ZeroAddress(); @@ -34,6 +35,9 @@ error MismatchedValue(); error NativeEthNotSupported(); error EthTransferFailed(); +error OracleNotSet(); +error TriggerConditionNotMet(uint256 orderId); + interface IWETH { function deposit() external payable; function withdraw(uint256) external; @@ -45,6 +49,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent address public immutable WETH; address public treasury; uint256 public protocolFeeBps; + address public override priceOracle; event Paused(address indexed by); event Unpaused(address indexed by); @@ -96,6 +101,11 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent emit OwnershipTransferred(oldOwner, newOwner); } + function setPriceOracle(address _oracle) external override onlyOwner { + priceOracle = _oracle; + emit PriceOracleUpdated(_oracle); + } + function _safeTransferTokenOrETH(address token, address to, uint256 amount) internal { if (token == WETH) { IWETH(WETH).withdraw(amount); @@ -138,8 +148,10 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent uint256 minPrice, uint256 maxPrice, uint256 expiry, - bool isBuy - ) external payable override nonReentrant whenNotPaused returns (uint256 orderId) { + bool isBuy, + OrderType orderType, + uint256 triggerPrice + ) public payable override nonReentrant whenNotPaused returns (uint256 orderId) { // Checks if (tokenIn == address(0) || tokenOut == address(0)) revert ZeroAddress(); if (tokenIn == tokenOut) revert SameToken(); @@ -148,6 +160,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent if (expiry != 0 && expiry <= block.timestamp) revert InvalidExpiry(); if (maxPrice != 0 && maxPrice < minPrice) revert InvalidPriceBounds(); if (slope != 0 && MathUtils.abs(slope) > SLOPE_ABS_LIMIT) revert SlopeOverflow(); + if (orderType != OrderType.LIMIT && triggerPrice == 0) revert InvalidPriceBounds(); // Effects — store and register BEFORE the external transfer (CEI) Order memory order = Order({ @@ -164,7 +177,9 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent minPrice: minPrice, maxPrice: maxPrice, createdAt: block.timestamp, - expiry: expiry + expiry: expiry, + orderType: orderType, + triggerPrice: triggerPrice }); orderId = _storeOrder(order); @@ -186,6 +201,32 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent emit OrderCreated(orderId, msg.sender, tokenIn, tokenOut, amountIn, isBuy); } + function createOrder( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 startPrice, + int256 slope, + uint256 minPrice, + uint256 maxPrice, + uint256 expiry, + bool isBuy + ) external payable override returns (uint256 orderId) { + return createOrder( + tokenIn, + tokenOut, + amountIn, + startPrice, + slope, + minPrice, + maxPrice, + expiry, + isBuy, + OrderType.LIMIT, + 0 + ); + } + function cancelOrder(uint256 orderId) external override nonReentrant { Order storage order = _getOrder(orderId); @@ -272,76 +313,117 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent uint256 len = counterOrderIds.length; require(len > 0, "Empty counter orders"); - for (uint256 i = 0; i < len; i++) { - uint256 counterOrderId = counterOrderIds[i]; + Order memory primaryOrder = _getOrderMem(orderId); + if (!primaryOrder.active) revert OrderInactive(); - Order memory order = _getOrderMem(orderId); - Order memory counterOrder = _getOrderMem(counterOrderId); + uint256 startRemainingIn = primaryOrder.remainingIn; - uint256 buyOrderId; - uint256 sellOrderId; - Order memory buy; - Order memory sell; - - if (order.isBuy) { - buyOrderId = orderId; - sellOrderId = counterOrderId; - buy = order; - sell = counterOrder; - } else { - buyOrderId = counterOrderId; - sellOrderId = orderId; - buy = counterOrder; - sell = order; + for (uint256 i = 0; i < len; i++) { + if (!primaryOrder.active) { + break; } - _validateMatch(buy, sell, block.timestamp); - ( - uint256 settlementPrice, + , uint256 executedQuantity, uint256 notionalAmount, bool buyFilled, bool sellFilled - ) = _computeSettlement(buy, sell, block.timestamp); + ) = _matchStep(primaryOrder, counterOrderIds[i], orderId); - uint256 newBuyRemaining = buy.remainingIn - notionalAmount; - uint256 newSellRemaining = sell.remainingIn - executedQuantity; + if (primaryOrder.isBuy) { + primaryOrder.remainingIn -= notionalAmount; + primaryOrder.active = !buyFilled; + } else { + primaryOrder.remainingIn -= executedQuantity; + primaryOrder.active = !sellFilled; + } + } - // Effects - if (buyFilled) { - _deactivateOrder(buyOrderId); - _removeOrderFromPair(buy.tokenIn, buy.tokenOut, buyOrderId); - emit OrderFilled(buyOrderId); + if (primaryOrder.remainingIn != startRemainingIn) { + if (!primaryOrder.active) { + _deactivateOrder(orderId); + _removeOrderFromPair(primaryOrder.tokenIn, primaryOrder.tokenOut, orderId); + emit OrderFilled(orderId); } else { - _updateRemainingIn(buyOrderId, newBuyRemaining); - emit OrderPartiallyFilled(buyOrderId, newBuyRemaining); + _updateRemainingIn(orderId, primaryOrder.remainingIn); + emit OrderPartiallyFilled(orderId, primaryOrder.remainingIn); } + } + } + + function _matchStep( + Order memory primaryOrder, + uint256 counterOrderId, + uint256 orderId + ) private returns ( + uint256 settlementPrice, + uint256 executedQuantity, + uint256 notionalAmount, + bool buyFilled, + bool sellFilled + ) { + Order memory counterOrder = _getOrderMem(counterOrderId); + + if (primaryOrder.isBuy) { + _validateMatch(primaryOrder, counterOrder, block.timestamp); + (settlementPrice, executedQuantity, notionalAmount, buyFilled, sellFilled) = + _computeSettlement(primaryOrder, counterOrder, block.timestamp); if (sellFilled) { - _deactivateOrder(sellOrderId); - _removeOrderFromPair(sell.tokenIn, sell.tokenOut, sellOrderId); - emit OrderFilled(sellOrderId); + _deactivateOrder(counterOrderId); + _removeOrderFromPair(counterOrder.tokenIn, counterOrder.tokenOut, counterOrderId); + emit OrderFilled(counterOrderId); } else { - _updateRemainingIn(sellOrderId, newSellRemaining); - emit OrderPartiallyFilled(sellOrderId, newSellRemaining); + uint256 rem = counterOrder.remainingIn - executedQuantity; + _updateRemainingIn(counterOrderId, rem); + emit OrderPartiallyFilled(counterOrderId, rem); } - // Interactions - uint256 keeperFee = notionalAmount / 1000; // 0.1% + uint256 keeperFee = notionalAmount / 1000; uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; - _safeTransferTokenOrETH(sell.tokenIn, buy.maker, executedQuantity); + _safeTransferTokenOrETH(counterOrder.tokenIn, primaryOrder.maker, executedQuantity); _safeTransferTokenOrETH( - buy.tokenIn, sell.maker, notionalAmount - keeperFee - protocolFee + primaryOrder.tokenIn, counterOrder.maker, notionalAmount - keeperFee - protocolFee ); - _safeTransferTokenOrETH(buy.tokenIn, msg.sender, keeperFee); + _safeTransferTokenOrETH(primaryOrder.tokenIn, msg.sender, keeperFee); if (protocolFee > 0 && treasury != address(0)) { - _safeTransferTokenOrETH(buy.tokenIn, treasury, protocolFee); + _safeTransferTokenOrETH(primaryOrder.tokenIn, treasury, protocolFee); } emit OrderMatched( - buyOrderId, sellOrderId, msg.sender, settlementPrice, executedQuantity + orderId, counterOrderId, msg.sender, settlementPrice, executedQuantity + ); + } else { + _validateMatch(counterOrder, primaryOrder, block.timestamp); + (settlementPrice, executedQuantity, notionalAmount, buyFilled, sellFilled) = + _computeSettlement(counterOrder, primaryOrder, block.timestamp); + + if (buyFilled) { + _deactivateOrder(counterOrderId); + _removeOrderFromPair(counterOrder.tokenIn, counterOrder.tokenOut, counterOrderId); + emit OrderFilled(counterOrderId); + } else { + uint256 rem = counterOrder.remainingIn - notionalAmount; + _updateRemainingIn(counterOrderId, rem); + emit OrderPartiallyFilled(counterOrderId, rem); + } + + uint256 keeperFee = notionalAmount / 1000; + uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; + + _safeTransferTokenOrETH(primaryOrder.tokenIn, counterOrder.maker, executedQuantity); + _safeTransferTokenOrETH( + counterOrder.tokenIn, primaryOrder.maker, notionalAmount - keeperFee - protocolFee + ); + _safeTransferTokenOrETH(counterOrder.tokenIn, msg.sender, keeperFee); + if (protocolFee > 0 && treasury != address(0)) { + _safeTransferTokenOrETH(counterOrder.tokenIn, treasury, protocolFee); + } + + emit OrderMatched( + counterOrderId, orderId, msg.sender, settlementPrice, executedQuantity ); } } @@ -391,7 +473,7 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent return _totalOrders(); } - function _validateMatch(Order memory buy, Order memory sell, uint256 ts) private pure { + function _validateMatch(Order memory buy, Order memory sell, uint256 ts) private view { if (!buy.active) revert OrderInactive(); if (!sell.active) revert OrderInactive(); if (buy.expiry != 0 && ts > buy.expiry) revert OrderExpired(); @@ -399,9 +481,27 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent if (!buy.isBuy || sell.isBuy) revert PairMismatch(); if (buy.tokenOut != sell.tokenIn || buy.tokenIn != sell.tokenOut) revert PairMismatch(); if (buy.maker == sell.maker) revert SelfMatch(); + _checkConditionalTrigger(buy); + _checkConditionalTrigger(sell); if (!PriceCurve.isMatchable(buy, sell, ts)) revert OrdersNotMatchable(); } + function _checkConditionalTrigger(Order memory order) private view { + if (order.orderType == OrderType.LIMIT) { + return; + } + + if (priceOracle == address(0)) revert OracleNotSet(); + + uint256 marketPrice = IPriceOracle(priceOracle).getPrice(order.tokenIn, order.tokenOut); + + if (order.orderType == OrderType.STOP_LOSS) { + if (marketPrice > order.triggerPrice) revert TriggerConditionNotMet(order.id); + } else if (order.orderType == OrderType.TAKE_PROFIT) { + if (marketPrice < order.triggerPrice) revert TriggerConditionNotMet(order.id); + } + } + function _computeSettlement(Order memory buy, Order memory sell, uint256 ts) private pure diff --git a/src/interfaces/IPriceOracle.sol b/src/interfaces/IPriceOracle.sol new file mode 100644 index 0000000..07b044d --- /dev/null +++ b/src/interfaces/IPriceOracle.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +interface IPriceOracle { + function getPrice(address tokenIn, address tokenOut) external view returns (uint256); +} diff --git a/src/interfaces/IWindmillExchange.sol b/src/interfaces/IWindmillExchange.sol index ba3e541..7547f44 100644 --- a/src/interfaces/IWindmillExchange.sol +++ b/src/interfaces/IWindmillExchange.sol @@ -1,11 +1,26 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import { Order } from "../types/OrderTypes.sol"; +import { Order, OrderType } from "../types/OrderTypes.sol"; interface IWindmillExchange { event ProtocolFeeUpdated(address indexed treasury, uint256 protocolFeeBps); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event PriceOracleUpdated(address indexed oracle); + + function createOrder( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 startPrice, + int256 slope, + uint256 minPrice, + uint256 maxPrice, + uint256 expiry, + bool isBuy, + OrderType orderType, + uint256 triggerPrice + ) external payable returns (uint256 orderId); function createOrder( address tokenIn, @@ -30,6 +45,10 @@ interface IWindmillExchange { function transferOwnership(address newOwner) external; + function setPriceOracle(address _oracle) external; + + function priceOracle() external view returns (address); + function currentPrice(uint256 orderId, uint256 timestamp) external view returns (uint256 price); function getOrder(uint256 orderId) external view returns (Order memory); diff --git a/src/types/OrderTypes.sol b/src/types/OrderTypes.sol index 630d5f1..43644a3 100644 --- a/src/types/OrderTypes.sol +++ b/src/types/OrderTypes.sol @@ -1,6 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; +enum OrderType { + LIMIT, + STOP_LOSS, + TAKE_PROFIT +} + /// @notice Core order struct with tightly-packed fields. /// @dev `maker` (20 bytes) + `isBuy` (1 byte) + `active` (1 byte) = 22 bytes, /// which fits in a single 32-byte storage slot together. @@ -19,6 +25,8 @@ struct Order { uint256 maxPrice; uint256 createdAt; uint256 expiry; + OrderType orderType; + uint256 triggerPrice; } function pairKeyOf(address tokenA, address tokenB) pure returns (bytes32) { diff --git a/test/WindmillExchange.t.sol b/test/WindmillExchange.t.sol index 7992654..d216622 100644 --- a/test/WindmillExchange.t.sol +++ b/test/WindmillExchange.t.sol @@ -3,7 +3,8 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { WindmillExchange } from "../src/core/WindmillExchange.sol"; -import { Order } from "../src/types/OrderTypes.sol"; +import { Order, OrderType } from "../src/types/OrderTypes.sol"; +import { MockPriceOracle } from "./mocks/MockPriceOracle.sol"; import { ZeroAddress, SameToken, @@ -25,7 +26,9 @@ import { InvalidProtocolFee, MismatchedValue, NativeEthNotSupported, - EthTransferFailed + EthTransferFailed, + OracleNotSet, + TriggerConditionNotMet } from "../src/core/WindmillExchange.sol"; contract MockERC20 { @@ -803,4 +806,195 @@ contract WindmillExchangeTest is Test { exchange.transferOwnership(bob); assertEq(exchange.owner(), bob); } + + function test_setPriceOracle_success() public { + MockPriceOracle oracle = new MockPriceOracle(); + exchange.setPriceOracle(address(oracle)); + assertEq(exchange.priceOracle(), address(oracle)); + } + + function test_setPriceOracle_revert_notOwner() public { + MockPriceOracle oracle = new MockPriceOracle(); + vm.prank(alice); + vm.expectRevert(NotOwner.selector); + exchange.setPriceOracle(address(oracle)); + } + + function test_conditionalOrder_revert_oracleNotSet() public { + vm.prank(alice); + uint256 buyId = exchange.createOrder( + address(tokenA), + address(tokenB), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + true, + OrderType.STOP_LOSS, + RAY + ); + + vm.prank(bob); + uint256 sellId = exchange.createOrder( + address(tokenB), + address(tokenA), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + false + ); + + vm.expectRevert(OracleNotSet.selector); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + } + + function test_stopLoss_sell_trigger() public { + MockPriceOracle oracle = new MockPriceOracle(); + exchange.setPriceOracle(address(oracle)); + + vm.prank(bob); + uint256 sellId = exchange.createOrder( + address(tokenB), + address(tokenA), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + false, + OrderType.STOP_LOSS, + RAY * 8 / 10 + ); + + uint256 buyId = _createBuyOrder(alice, 100 ether, RAY, 0, 0); + + oracle.setPrice(address(tokenB), address(tokenA), RAY * 9 / 10); + vm.expectRevert(abi.encodeWithSelector(TriggerConditionNotMet.selector, sellId)); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + oracle.setPrice(address(tokenB), address(tokenA), RAY * 8 / 10); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + assertFalse(exchange.getOrder(sellId).active); + } + + function test_takeProfit_sell_trigger() public { + MockPriceOracle oracle = new MockPriceOracle(); + exchange.setPriceOracle(address(oracle)); + + vm.prank(bob); + uint256 sellId = exchange.createOrder( + address(tokenB), + address(tokenA), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + false, + OrderType.TAKE_PROFIT, + RAY + ); + + uint256 buyId = _createBuyOrder(alice, 100 ether, RAY, 0, 0); + + oracle.setPrice(address(tokenB), address(tokenA), RAY * 9 / 10); + vm.expectRevert(abi.encodeWithSelector(TriggerConditionNotMet.selector, sellId)); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + oracle.setPrice(address(tokenB), address(tokenA), RAY); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + + assertFalse(exchange.getOrder(sellId).active); + } + + function test_conditionalOrder_creation_revert_zeroTriggerPrice() public { + vm.prank(alice); + vm.expectRevert(InvalidPriceBounds.selector); + exchange.createOrder( + address(tokenA), + address(tokenB), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + false, + OrderType.STOP_LOSS, + 0 + ); + } + + function test_matchOrdersBatch_gasOptimized() public { + // Alice creates a large buy order for 100 ether of tokenB with tokenA + uint256 buyId = _createBuyOrder(alice, 100 ether, RAY, 0, 0); + + // Bob creates three separate sell orders for 30, 40, and 30 ether of tokenB for tokenA + uint256 sellId1 = _createSellOrder(bob, 30 ether, RAY, 0, 0); + uint256 sellId2 = _createSellOrder(bob, 40 ether, RAY, 0, 0); + uint256 sellId3 = _createSellOrder(bob, 50 ether, RAY, 0, 0); // Bob has extra remaining + + uint256[] memory counterOrderIds = new uint256[](3); + counterOrderIds[0] = sellId1; + counterOrderIds[1] = sellId2; + counterOrderIds[2] = sellId3; + + // Execute batch match against counter-orders + exchange.matchOrdersBatch(buyId, counterOrderIds, block.timestamp + 1); + + // Verify primary order is fully filled and deactivated + assertFalse(exchange.getOrder(buyId).active); + // Verify counter orders 1 and 2 are fully filled + assertFalse(exchange.getOrder(sellId1).active); + assertFalse(exchange.getOrder(sellId2).active); + // Verify counter order 3 is partially filled (remains active with 20 ether remaining) + assertTrue(exchange.getOrder(sellId3).active); + assertEq(exchange.getOrder(sellId3).remainingIn, 20 ether); + } + + function test_payout_revert_EthTransferFailed() public { + RejectETH rejector = new RejectETH(); + tokenA.mint(address(rejector), 100 ether); + + rejector.createBuyOrder(exchange, address(tokenA), address(weth), 100 ether); + uint256 buyId = exchange.totalOrders(); + + weth.mint(alice, 100 ether); + deal(address(weth), 100 ether); // Fund WETH with native ETH for withdraw + vm.prank(alice); + uint256 sellId = exchange.createOrder( + address(weth), + address(tokenA), + 100 ether, + RAY, + 0, + 0, + 0, + 0, + false + ); + + vm.expectRevert(EthTransferFailed.selector); + exchange.matchOrders(buyId, sellId, block.timestamp + 1); + } +} + +contract RejectETH { + function createBuyOrder( + WindmillExchange exchange, + address tokenA, + address weth, + uint256 amount + ) external { + MockERC20(tokenA).approve(address(exchange), type(uint256).max); + exchange.createOrder(tokenA, weth, amount, 1e27, 0, 0, 0, 0, true); + } } diff --git a/test/mocks/MockPriceOracle.sol b/test/mocks/MockPriceOracle.sol new file mode 100644 index 0000000..f99a1af --- /dev/null +++ b/test/mocks/MockPriceOracle.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { IPriceOracle } from "../../src/interfaces/IPriceOracle.sol"; + +contract MockPriceOracle is IPriceOracle { + mapping(address => mapping(address => uint256)) public prices; + + function setPrice(address tokenIn, address tokenOut, uint256 price) external { + prices[tokenIn][tokenOut] = price; + } + + function getPrice(address tokenIn, address tokenOut) external view override returns (uint256) { + return prices[tokenIn][tokenOut]; + } +}