Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conditional Limit Order type #82

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/types/LimitOrder.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
259 changes: 259 additions & 0 deletions test/ComposableCoW.limit.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading