diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 0000000..45eab8a --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,55 @@ +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/script/DeployWindmill.s.sol b/script/DeployWindmill.s.sol index c56815a..1413026 100644 --- a/script/DeployWindmill.s.sol +++ b/script/DeployWindmill.s.sol @@ -9,7 +9,9 @@ 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..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(); @@ -27,26 +28,50 @@ error PairMismatch(); error ZeroSettlementPrice(); error UnsupportedTokenBehavior(); +error NotOwner(); +error ExchangePaused(); +error InvalidProtocolFee(); +error MismatchedValue(); +error NativeEthNotSupported(); +error EthTransferFailed(); + +error OracleNotSet(); +error TriggerConditionNotMet(uint256 orderId); + +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; + address public override priceOracle; + 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 +82,40 @@ 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 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); + (bool success,) = to.call{ value: amount }(""); + if (!success) revert EthTransferFailed(); + } else { + TokenTransfer.safeTransfer(token, to, amount); + } + } + event OrderCreated( uint256 indexed orderId, address indexed maker, @@ -89,8 +148,10 @@ contract WindmillExchange is OrderStorage, PairStorage, IWindmillExchange, Reent uint256 minPrice, uint256 maxPrice, uint256 expiry, - bool isBuy - ) external 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(); @@ -99,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({ @@ -115,23 +177,57 @@ 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); _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 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); if (order.maker != msg.sender) revert NotMaker(); @@ -146,7 +242,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 +291,143 @@ 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"); + + Order memory primaryOrder = _getOrderMem(orderId); + if (!primaryOrder.active) revert OrderInactive(); + + uint256 startRemainingIn = primaryOrder.remainingIn; + + for (uint256 i = 0; i < len; i++) { + if (!primaryOrder.active) { + break; + } + + ( + , + uint256 executedQuantity, + uint256 notionalAmount, + bool buyFilled, + bool sellFilled + ) = _matchStep(primaryOrder, counterOrderIds[i], orderId); + + if (primaryOrder.isBuy) { + primaryOrder.remainingIn -= notionalAmount; + primaryOrder.active = !buyFilled; + } else { + primaryOrder.remainingIn -= executedQuantity; + primaryOrder.active = !sellFilled; + } + } + + if (primaryOrder.remainingIn != startRemainingIn) { + if (!primaryOrder.active) { + _deactivateOrder(orderId); + _removeOrderFromPair(primaryOrder.tokenIn, primaryOrder.tokenOut, orderId); + emit OrderFilled(orderId); + } else { + _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(counterOrderId); + _removeOrderFromPair(counterOrder.tokenIn, counterOrder.tokenOut, counterOrderId); + emit OrderFilled(counterOrderId); + } else { + uint256 rem = counterOrder.remainingIn - executedQuantity; + _updateRemainingIn(counterOrderId, rem); + emit OrderPartiallyFilled(counterOrderId, rem); + } + + uint256 keeperFee = notionalAmount / 1000; + uint256 protocolFee = (notionalAmount * protocolFeeBps) / 10000; + + _safeTransferTokenOrETH(counterOrder.tokenIn, primaryOrder.maker, executedQuantity); + _safeTransferTokenOrETH( + primaryOrder.tokenIn, counterOrder.maker, notionalAmount - keeperFee - protocolFee + ); + _safeTransferTokenOrETH(primaryOrder.tokenIn, msg.sender, keeperFee); + if (protocolFee > 0 && treasury != address(0)) { + _safeTransferTokenOrETH(primaryOrder.tokenIn, treasury, protocolFee); + } + + emit OrderMatched( + 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 + ); + } + } + function currentPrice(uint256 orderId, uint256 timestamp) external view @@ -248,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(); @@ -256,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 c31e4ae..7547f44 100644 --- a/src/interfaces/IWindmillExchange.sol +++ b/src/interfaces/IWindmillExchange.sol @@ -1,9 +1,27 @@ // 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, address tokenOut, @@ -14,12 +32,23 @@ 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 setProtocolFee(address _treasury, uint256 _protocolFeeBps) external; + + 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 98c64a7..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, @@ -19,7 +20,15 @@ import { OrdersNotMatchable, PairMismatch, ZeroSettlementPrice, - UnsupportedTokenBehavior + UnsupportedTokenBehavior, + NotOwner, + ExchangePaused, + InvalidProtocolFee, + MismatchedValue, + NativeEthNotSupported, + EthTransferFailed, + OracleNotSet, + TriggerConditionNotMet } from "../src/core/WindmillExchange.sol"; contract MockERC20 { @@ -82,32 +91,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 +550,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 +627,374 @@ 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); + } + + 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]; + } +}