diff --git a/src/types/LimitOrder.sol b/src/types/LimitOrder.sol new file mode 100644 index 0000000..46187aa --- /dev/null +++ b/src/types/LimitOrder.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IERC20 } from "@openzeppelin/interfaces/IERC20.sol"; +import "../interfaces/IConditionalOrder.sol"; +import "../BaseConditionalOrder.sol"; + +// --- error strings + +/// @dev Invalid sell token asset +string constant INVALID_SELL_TOKEN = "invalid sell token"; +/// @dev Invalid buy token asset +string constant INVALID_BUY_TOKEN = "invalid sell amount"; +/// @dev Invalid receiver +string constant INVALID_RECEIVER = "invalid receiver"; +/// @dev Invalid valid to timestamp +string constant INVALID_VALIDITY = "invalid validity"; +/// @dev Either a buy order was attempted to be matched with a sell order or vice versa +string constant INVALID_ORDER_KIND = "invalid order kind"; +/// @dev The limit price is not satisfied or the order is trying to be partially filled +string constant INVALID_LIMIT_AMOUNTS = "invalid limit amounts"; +/// @dev Only ERC20 balances are supported +string constant INVALID_BALANCE = "invalid balances"; + +/** + * @title Limit order + * Providing tokens, limit amounts and a recipient, this conditional order type will accept any fill-or-kill trade satisfying these parameters until a certain deadline. + * @dev This order type does not have any replay protection, meaning it may be triggered many times assuming the contract has sufficient funds. + */ +contract LimitOrder is IConditionalOrder { + /** + * Defines the parameters of the limit order + * @param sellToken: the token to be sold + * @param buyToken: the token to be bought + * @param sellAmount: In case of a sell order, the exact amount of tokens the order is willing to sell. In case of a buy order, the maximium amount of tokens it is willing to sell + * @param buyAmount: In case of a sell order, the min amount of tokens the order is wants to receive. In case of a buy order, the exact amount of tokens it is willing to receive + * @param receiver: The account that should receive the proceeds of the trade + * @param validTo: The timestamp (in unix epoch) until which the order is valid + * @param isSellOrder: Whether this is a sell or buy order + */ + struct Data { + IERC20 sellToken; + IERC20 buyToken; + uint256 sellAmount; + uint256 buyAmount; + address receiver; + uint32 validTo; + bool isSellOrder; + } + + /** + * @dev Check if the suggested order satisfies the limit order parameters. + */ + function verify( + address, + address, + bytes32 hash, + bytes32 domainSeparator, + bytes32, + bytes calldata staticInput, + bytes calldata, + GPv2Order.Data calldata suggestedOrder + ) external pure override { + /// @dev Verify that the *suggested* order matches the payload. + if (!(hash == GPv2Order.hash(suggestedOrder, domainSeparator))) { + revert IConditionalOrder.OrderNotValid(INVALID_HASH); + } + + Data memory limitOrder = abi.decode(staticInput, (Data)); + + /// Verify order parameters + if (suggestedOrder.sellToken != limitOrder.sellToken) { + revert IConditionalOrder.OrderNotValid(INVALID_SELL_TOKEN); + } + + if (suggestedOrder.buyToken != limitOrder.buyToken) { + revert IConditionalOrder.OrderNotValid(INVALID_BUY_TOKEN); + } + + if (suggestedOrder.receiver != limitOrder.receiver) { + revert IConditionalOrder.OrderNotValid(INVALID_RECEIVER); + } + + if (suggestedOrder.validTo > limitOrder.validTo) { + revert IConditionalOrder.OrderNotValid(INVALID_VALIDITY); + } + + if (suggestedOrder.kind == GPv2Order.KIND_SELL) { + if (!limitOrder.isSellOrder) { + revert IConditionalOrder.OrderNotValid(INVALID_ORDER_KIND); + } + if ( + (suggestedOrder.sellAmount + suggestedOrder.feeAmount) != + limitOrder.sellAmount + ) { + revert IConditionalOrder.OrderNotValid(INVALID_LIMIT_AMOUNTS); + } + if (suggestedOrder.buyAmount < limitOrder.buyAmount) { + revert IConditionalOrder.OrderNotValid(INVALID_LIMIT_AMOUNTS); + } + } else { + // BUY order + if (limitOrder.isSellOrder) { + revert IConditionalOrder.OrderNotValid(INVALID_ORDER_KIND); + } + if ( + (suggestedOrder.sellAmount + suggestedOrder.feeAmount) > + limitOrder.sellAmount + ) { + revert IConditionalOrder.OrderNotValid(INVALID_LIMIT_AMOUNTS); + } + if (suggestedOrder.buyAmount != limitOrder.buyAmount) { + revert IConditionalOrder.OrderNotValid(INVALID_LIMIT_AMOUNTS); + } + } + + if ( + suggestedOrder.buyTokenBalance != GPv2Order.BALANCE_ERC20 || + suggestedOrder.sellTokenBalance != GPv2Order.BALANCE_ERC20 + ) { + revert IConditionalOrder.OrderNotValid(INVALID_BALANCE); + } + } +} diff --git a/test/ComposableCoW.limit.t.sol b/test/ComposableCoW.limit.t.sol new file mode 100644 index 0000000..86b9b83 --- /dev/null +++ b/test/ComposableCoW.limit.t.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { IERC20 } from "@openzeppelin/interfaces/IERC20.sol"; +import "./ComposableCoW.base.t.sol"; +import "../src/types/LimitOrder.sol"; + +library LimitOrderTest { + bytes32 constant DOMAIN_SEPARATOR = + 0x3fd54831f488a22b28398de0c567a3b064b937f54f81739ae9bd545967f3abab; + + function fail( + LimitOrder order, + Vm vm, + LimitOrder.Data memory orderData, + GPv2Order.Data memory trade, + string memory reason + ) internal { + vm.expectRevert( + abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, reason) + ); + order.verify( + address(0), + address(0), + GPv2Order.hash(trade, DOMAIN_SEPARATOR), + DOMAIN_SEPARATOR, + bytes32(0), + abi.encode(orderData), + bytes(""), + trade + ); + } + + function pass( + LimitOrder order, + LimitOrder.Data memory orderData, + GPv2Order.Data memory trade + ) internal pure { + order.verify( + address(0), + address(0), + GPv2Order.hash(trade, DOMAIN_SEPARATOR), + DOMAIN_SEPARATOR, + bytes32(0), + abi.encode(orderData), + bytes(""), + trade + ); + } +} + +contract ComposableCoWLimitOrderTest is Test { + IERC20 immutable SELL_TOKEN = IERC20(address(0x1)); + IERC20 immutable BUY_TOKEN = IERC20(address(0x2)); + address constant RECEIVER = address(0x3); + uint32 constant VALID_TO = 1687718700; + + using LimitOrderTest for LimitOrder; + + LimitOrder.Data sell; + LimitOrder.Data buy; + + function setUp() public virtual { + sell = LimitOrder.Data({ + sellToken: SELL_TOKEN, + buyToken: BUY_TOKEN, + sellAmount: 1 ether, + buyAmount: 1 ether, + receiver: RECEIVER, + validTo: VALID_TO, + isSellOrder: true + }); + buy = LimitOrder.Data({ + sellToken: SELL_TOKEN, + buyToken: BUY_TOKEN, + sellAmount: 1 ether, + buyAmount: 1 ether, + receiver: RECEIVER, + validTo: VALID_TO, + isSellOrder: false + }); + } + + function valid_trade( + bytes32 kind + ) public view returns (GPv2Order.Data memory) { + return + GPv2Order.Data( + SELL_TOKEN, + BUY_TOKEN, + RECEIVER, + 1 ether, + 1 ether, + VALID_TO, + 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, + 0, // use zero fee for limit orders + kind, + false, + GPv2Order.BALANCE_ERC20, + GPv2Order.BALANCE_ERC20 + ); + } + + function test_valid_order() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_SELL); + order.pass(sell, valid); + + valid.kind = GPv2Order.KIND_BUY; + order.pass(buy, valid); + } + + function test_amounts_sell_order() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_SELL); + + // Higer buy amount allowed for sell orders + valid.buyAmount += 1; + order.pass(sell, valid); + + // Lower buy amount not allowed + valid.buyAmount -= 2; + order.fail(vm, sell, valid, INVALID_LIMIT_AMOUNTS); + + // Different sell amount not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.sellAmount += 1; + order.fail(vm, sell, valid, INVALID_LIMIT_AMOUNTS); + + valid.sellAmount -= 2; + order.fail(vm, sell, valid, INVALID_LIMIT_AMOUNTS); + } + + function test_amounts_buy_order() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_BUY); + + // Lower sell amount allowed for buy orders + valid.sellAmount -= 1; + order.pass(buy, valid); + + // Higher sell amount not allowed + valid.sellAmount += 2; + order.fail(vm, buy, valid, INVALID_LIMIT_AMOUNTS); + + // Different buy amount not allowed + valid = valid_trade(GPv2Order.KIND_BUY); + valid.buyAmount += 1; + order.fail(vm, buy, valid, INVALID_LIMIT_AMOUNTS); + + valid.buyAmount -= 2; + order.fail(vm, buy, valid, INVALID_LIMIT_AMOUNTS); + } + + function test_amounts_fee() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_SELL); + + // Fee exceeds amount + valid.feeAmount = 0.1 ether; + order.fail(vm, sell, valid, INVALID_LIMIT_AMOUNTS); + + // Can be taken from sell amount + valid.sellAmount -= 0.1 ether; + order.pass(sell, valid); + + // Same for buy orders + valid = valid_trade(GPv2Order.KIND_BUY); + valid.feeAmount = 0.1 ether; + order.fail(vm, buy, valid, INVALID_LIMIT_AMOUNTS); + + valid.sellAmount -= 0.1 ether; + order.pass(buy, valid); + + // Smaller fee is allowed for buy orders + valid.feeAmount = 0.01 ether; + order.pass(buy, valid); + } + + function test_invalid_preimage() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_SELL); + bytes32 invalid_hash = GPv2Order.hash( + valid, + LimitOrderTest.DOMAIN_SEPARATOR + ); + + // Changing something about the preimage makes it no longer correspond to the hash + valid.appData = keccak256("other data"); + + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.OrderNotValid.selector, + INVALID_HASH + ) + ); + order.verify( + address(0), + address(0), + invalid_hash, + LimitOrderTest.DOMAIN_SEPARATOR, + bytes32(0), + abi.encode(sell), + bytes(""), + valid + ); + } + + function test_params() public { + LimitOrder order = new LimitOrder(); + GPv2Order.Data memory valid = valid_trade(GPv2Order.KIND_SELL); + + // Any app data is allowed + valid.appData = keccak256("other data"); + order.pass(sell, valid); + + // Earlier validTo is allowed + valid.validTo -= 1; + order.pass(sell, valid); + + // Later validTo is not allowed + valid.validTo += 2; + order.fail(vm, sell, valid, INVALID_VALIDITY); + + // Different balance is not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.sellTokenBalance = GPv2Order.BALANCE_EXTERNAL; + order.fail(vm, sell, valid, INVALID_BALANCE); + + valid = valid_trade(GPv2Order.KIND_SELL); + valid.buyTokenBalance = GPv2Order.BALANCE_EXTERNAL; + order.fail(vm, sell, valid, INVALID_BALANCE); + + // Different kind is not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.kind = GPv2Order.KIND_BUY; + order.pass(buy, valid); + order.fail(vm, sell, valid, INVALID_ORDER_KIND); + + valid.kind = GPv2Order.KIND_SELL; + order.pass(sell, valid); + order.fail(vm, buy, valid, INVALID_ORDER_KIND); + + // Different receiver is not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.receiver = address(0xdeadbeef); + order.fail(vm, sell, valid, INVALID_RECEIVER); + + // Different sell token is not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.sellToken = IERC20(address(0xdeadbeef)); + order.fail(vm, sell, valid, INVALID_SELL_TOKEN); + + // Different buy token is not allowed + valid = valid_trade(GPv2Order.KIND_SELL); + valid.buyToken = IERC20(address(0xdeadbeef)); + order.fail(vm, sell, valid, INVALID_BUY_TOKEN); + } +}