diff --git a/packages/contracts/evm-contracts/contracts/orderbook/IOrderbookDex.sol b/packages/contracts/evm-contracts/contracts/orderbook/IOrderbookDex.sol new file mode 100644 index 000000000..7fcaca795 --- /dev/null +++ b/packages/contracts/evm-contracts/contracts/orderbook/IOrderbookDex.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/// @notice Facilitates base-chain trading of an asset that is living on a different app-chain. +interface IOrderbookDex is IERC165 { + struct Order { + uint256 assetAmount; + uint256 price; + address payable seller; + bool active; + } + + /// @notice Returns the current index of orders (index that a new sell order will be mapped to). + function getOrdersIndex() external view returns (uint256); + + /// @notice Returns the Order struct information about order of specified `orderId`. + function getOrder(uint256 orderId) external view returns (Order memory); + + /// @notice Creates a sell order for the specified `assetAmount` at specified `price`. + /// @dev The order is saved in a mapping from incremental ID to Order struct. + function createSellOrder(uint256 assetAmount, uint256 price) external; + + /// @notice Fills an array of orders specified by `orderIds`. + /// @dev Reverts if msg.value is less than the sum of orders' prices. + /// If msg.value is more than the sum of orders' prices, it should refund the difference back to msg.sender. + function fillSellOrders(uint256[] memory orderIds) external payable; + + /// @notice Cancels the sell order specified by `orderId`, making it unfillable. + /// @dev Reverts if the msg.sender is not the order's seller. + function cancelSellOrder(uint256 orderId) external; +} diff --git a/packages/contracts/evm-contracts/contracts/orderbook/OrderbookDex.sol b/packages/contracts/evm-contracts/contracts/orderbook/OrderbookDex.sol new file mode 100644 index 000000000..857c2647d --- /dev/null +++ b/packages/contracts/evm-contracts/contracts/orderbook/OrderbookDex.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IOrderbookDex} from "./IOrderbookDex.sol"; + +/// @notice Facilitates trading an asset that is living on a different app-chain. +contract OrderbookDex is IOrderbookDex, ERC165, ReentrancyGuard { + using Address for address payable; + + mapping(uint256 => Order) public orders; + uint256 public ordersIndex; + + event OrderCreated( + uint256 indexed orderId, + address indexed seller, + uint256 assetAmount, + uint256 price + ); + event OrderFilled( + uint256 indexed orderId, + address indexed seller, + address indexed buyer, + uint256 assetAmount, + uint256 price + ); + event OrderCancelled(uint256 indexed orderId); + + error OrderIsInactive(uint256 orderId); + error Unauthorized(); + + /// @notice Returns the current index of orders (index that a new sell order will be mapped to). + function getOrdersIndex() public view returns (uint256) { + return ordersIndex; + } + + /// @notice Returns the Order struct information about order of specified `orderId`. + function getOrder(uint256 orderId) public view returns (Order memory) { + return orders[orderId]; + } + + /// @notice Creates a sell order for the specified `assetAmount` at specified `price`. + /// @dev The order is saved in a mapping from incremental ID to Order struct. + function createSellOrder(uint256 assetAmount, uint256 price) public { + Order memory newOrder = Order({ + seller: payable(msg.sender), + assetAmount: assetAmount, + price: price, + active: true + }); + orders[ordersIndex] = newOrder; + emit OrderCreated(ordersIndex, msg.sender, assetAmount, price); + ordersIndex++; + } + + /// @notice Fills an array of orders specified by `orderIds`. + /// @dev Reverts if msg.value is less than the sum of orders' prices. + /// If msg.value is more than the sum of orders' prices, it should refund the difference back to msg.sender. + function fillSellOrders(uint256[] memory orderIds) public payable nonReentrant { + uint256 length = orderIds.length; + uint256 totalPaid; + for (uint256 i = 0; i < length; ) { + uint256 orderId = orderIds[i]; + Order memory order = orders[orderId]; + if (!order.active) { + revert OrderIsInactive(orderId); + } + order.seller.sendValue(order.price); + totalPaid += order.price; + orders[orderId].active = false; + emit OrderFilled(orderId, order.seller, msg.sender, order.assetAmount, order.price); + unchecked { + i++; + } + } + if (msg.value > totalPaid) { + payable(msg.sender).sendValue(msg.value - totalPaid); + } + } + + /// @notice Cancels the sell order specified by `orderId`, making it unfillable. + /// @dev Reverts if the msg.sender is not the order's seller. + function cancelSellOrder(uint256 orderId) public { + if (msg.sender != orders[orderId].seller) { + revert Unauthorized(); + } + orders[orderId].active = false; + emit OrderCancelled(orderId); + } + + /// @dev Returns true if this contract implements the interface defined by `interfaceId`. See EIP165. + function supportsInterface( + bytes4 interfaceId + ) public view override(ERC165, IERC165) returns (bool) { + return + interfaceId == type(IOrderbookDex).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/packages/contracts/evm-contracts/test/OrderbookDex.t.sol b/packages/contracts/evm-contracts/test/OrderbookDex.t.sol new file mode 100644 index 000000000..4854399bb --- /dev/null +++ b/packages/contracts/evm-contracts/test/OrderbookDex.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.18; + +import {CheatCodes} from "../test-lib/cheatcodes.sol"; +import {CTest} from "../test-lib/ctest.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IOrderbookDex} from "../contracts/orderbook/IOrderbookDex.sol"; +import {OrderbookDex} from "../contracts/orderbook/OrderbookDex.sol"; + +contract OrderbookDexTest is CTest { + using Address for address payable; + + CheatCodes vm = CheatCodes(HEVM_ADDRESS); + OrderbookDex public dex; + address alice = vm.addr(uint256(keccak256(abi.encodePacked("alice")))); + address boris = vm.addr(uint256(keccak256(abi.encodePacked("boris")))); + + function tryToSendEthToDex() public { + payable(address(dex)).sendValue(1); + } + + function setUp() public { + dex = new OrderbookDex(); + vm.deal(alice, 100 ether); + vm.deal(boris, 100 ether); + } + + function test_SupportsInterface() public { + assertTrue(dex.supportsInterface(type(IERC165).interfaceId)); + assertTrue(dex.supportsInterface(type(IOrderbookDex).interfaceId)); + } + + function testFuzz_Fills(uint256 price) public { + uint256 ordersCount = 5; + vm.assume(price < type(uint256).max / ordersCount); + uint256 firstOrderId = dex.getOrdersIndex(); + for (uint256 i = 0; i < ordersCount; i++) { + uint256 orderId = dex.getOrdersIndex(); + address seller = vm.addr(uint256(keccak256(abi.encodePacked(i)))); + uint256 assetAmount = uint256(keccak256(abi.encodePacked(price))); + vm.prank(seller); + + vm.expectEmit(true, true, true, true); + emit OrderbookDex.OrderCreated(orderId, seller, assetAmount, price); + dex.createSellOrder(assetAmount, price); + + uint256 newOrderId = dex.getOrdersIndex(); + OrderbookDex.Order memory order = dex.getOrder(orderId); + assertEq(newOrderId, orderId + 1); + assertTrue(order.active); + assertEq(order.assetAmount, assetAmount); + assertEq(order.price, price); + assertEq(order.seller, seller); + } + + { + uint256 currentOrderId = dex.getOrdersIndex(); + uint256[] memory orderIds = new uint256[](ordersCount); + for (uint256 i = 0; i < ordersCount; i++) { + orderIds[i] = firstOrderId + i; + } + + address buyer = vm.addr( + uint256(keccak256(abi.encodePacked(uint256(type(uint256).max)))) + ); + vm.deal(buyer, type(uint256).max); + uint256[] memory sellersBalancesBefore = new uint256[](ordersCount); + for (uint256 i = 0; i < ordersCount; i++) { + OrderbookDex.Order memory order = dex.getOrder(firstOrderId + i); + sellersBalancesBefore[i] = order.seller.balance; + vm.expectEmit(true, true, true, true); + emit OrderbookDex.OrderFilled( + firstOrderId + i, + order.seller, + buyer, + order.assetAmount, + order.price + ); + } + vm.prank(buyer); + dex.fillSellOrders{value: price * ordersCount}(orderIds); + + assertEq(dex.getOrdersIndex(), currentOrderId); + for (uint256 i = 0; i < ordersCount; i++) { + OrderbookDex.Order memory order = dex.getOrder(firstOrderId + i); + assertTrue(!order.active); + assertEq(order.seller.balance, sellersBalancesBefore[i] + order.price); + } + } + } + + function test_ExcessValueIsRefunded() public { + uint256 price = 100; + uint256 orderId = dex.getOrdersIndex(); + dex.createSellOrder(10, price); + + vm.prank(alice); + uint256 aliceBalanceBefore = alice.balance; + uint256[] memory orderIds = new uint256[](1); + orderIds[0] = orderId; + dex.fillSellOrders{value: price * 5}(orderIds); + assertEq(alice.balance, aliceBalanceBefore - price); + } + + function test_CannotCancelOrderIfUnauthorized() public { + uint256 orderId = dex.getOrdersIndex(); + dex.createSellOrder(100, 200); + + vm.prank(alice); + vm.expectRevert(OrderbookDex.Unauthorized.selector); + dex.cancelSellOrder(orderId); + } + + function test_CannotFillOrderIfCancelled() public { + uint256 orderId = dex.getOrdersIndex(); + dex.createSellOrder(100, 200); + dex.cancelSellOrder(orderId); + + vm.prank(alice); + uint256[] memory orderIds = new uint256[](1); + orderIds[0] = orderId; + vm.expectRevert(abi.encodeWithSelector(OrderbookDex.OrderIsInactive.selector, orderId)); + dex.fillSellOrders(orderIds); + } + + function test_CannotFillOrderIfAlreadyFilled() public { + uint256 orderId = dex.getOrdersIndex(); + uint256 price = 1000; + dex.createSellOrder(100, price); + + vm.prank(alice); + uint256[] memory orderIds = new uint256[](1); + orderIds[0] = orderId; + dex.fillSellOrders{value: price}(orderIds); + + vm.prank(boris); + vm.expectRevert(abi.encodeWithSelector(OrderbookDex.OrderIsInactive.selector, orderId)); + dex.fillSellOrders{value: price}(orderIds); + } + + function test_CannotFillOrderIfInsufficientValue() public { + uint256 price = 100; + uint256 orderId = dex.getOrdersIndex(); + dex.createSellOrder(10, price); + + vm.prank(alice); + uint256[] memory orderIds = new uint256[](1); + orderIds[0] = orderId; + vm.expectRevert( + abi.encodeWithSelector(Address.AddressInsufficientBalance.selector, address(dex)) + ); + dex.fillSellOrders{value: price - 1}(orderIds); + } + + function test_CannotSendEtherToDex() public { + vm.expectRevert(Address.FailedInnerCall.selector); + this.tryToSendEthToDex(); + } + + receive() external payable {} +}