Skip to main content

Build a Limit Order contract

A Limit Order contract allows users to specify the exact price at which they are willing to buy or sell a particular asset. This contrasts with market orders, where trades are executed instantly at the current market price. Our example will focus on creating a Limit Order contract that enables users, like Eva, to place orders for trading Token C in exchange for Token D at her desired price point with limited price impact.


Getting Started

To begin development, run the following commands:

forge install
forge build
# Run test suite
forge test

The initial setup phase of the test involves deploying the necessary smart contracts for the operation:

  • A Limit Order Contract, which facilitates the creation and execution of limit orders.
  • A Mempool factory contract (Laminator) and a specific instance of LaminatedProxy (pusherLaminated) to manage transactions.
  • Additionally, a smart contract for Alice’s Self-Checkout, acting as the swap protocol, is deployed.

Deployments

The deployment process for the Limit Order scenario involves several key steps to ensure the system is ready for operation:

  1. Deploy the Limit Order Contract: This contract is the core of the system, enabling users to create and manage their limit orders for trading assets.

  2. Deploy the Mempool Factory Contract (Laminator): The Laminator contract is responsible for managing the transactions in the mempool. It creates instances of LaminatedProxy to handle individual transactions.

  3. Deploy an Instance of LaminatedProxy (pusherLaminated): This specific instance of LaminatedProxy is used to manage the transactions related to the limit orders. It acts as a proxy for executing transactions in a secure and efficient manner.

  4. Deploy Alice’s Self-Checkout Contract: Although primarily used in the self-checkout tutorial, a similar contract is deployed here to act as the swap protocol. This contract facilitates the swapping of assets according to the specified limit orders.

Calls Pushed by the User

After the deployment, the user can initialize a swap by queuing up a call to the swapDAIForWETH function. This function is part of the Alice's limit order contract and is responsible for executing the swap.

    function userLand() public returns (uint256) {
pusherLaminated.transfer(1 ether);

CallObject[] memory pusherCallObjs = new CallObject[](2);
pusherCallObjs[0] = CallObject({
amount: 0,
addr: address(limitOrder),
gas: 1000000,
callvalue: abi.encodeWithSignature("swapDAIForWETH(uint256,uint160)", 100, 1)
});

// Including a tip for the callbreaker as an incentive.
pusherCallObjs[1] = CallObject({amount: _tipWei, addr: address(callbreaker), gas: 10000000, callvalue: ""});

// Pushing the swap operation to the proxy.
return laminator.pushToProxy(abi.encode(pusherCallObjs), 1);
}

The limit order contract itself uses the TimeTurner to enforce slippage on the swap. The slippage is set to 1% in this example, but this can be changed based on the user's needs.

At the moment, the price is set to a static amount to demonstrate the slippage protection. In a real-world scenario, the price would be fetched from a MEV-time price oracle or similar source, and the TimeTurner would be similarly used to enforce price on the swap, similar to the tutorial using the self-checkout contract.

For now though, the swap calls are made but at the very end, the checkSlippage function is called. This function is part of the limit order contract and is responsible for checking if the slippage is within the specified limits and will revert at MEV-time if it is not.

    // use the timeturner to enforce slippage on a uniswap trade
// set slippage really high, let yourself slip, then use the timeturner to revert the trade if the price was above some number.
function swapDAIForWETH(uint256 _amountIn, uint256 slippagePercent) public {
uint256 amountIn = _amountIn * 1e18;
require(dai.transferFrom(msg.sender, address(this), amountIn), "transferFrom failed.");
require(dai.approve(address(router), amountIn), "approve failed.");

// perform the swap with no slippage limits
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: address(dai),
tokenOut: address(weth),
fee: poolFee,
recipient: msg.sender,
deadline: block.timestamp,
amountIn: amountIn,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
});

// The call to `exactInputSingle` executes the swap.
uint256 amountOut = router.exactInputSingle(params);
console.log("WETH", amountOut);

// check whether or not
CallObject memory callObj = CallObject({
amount: 0,
addr: address(this),
gas: 1000000,
callvalue: abi.encodeWithSignature("checkSlippage(uint256)", slippagePercent)
});

assertFutureCallTo(callObj, 1);
}

A solver will execute the call stack by calling pull, which executes the calls that the user has pushed to the LaminatedProxy. This is the same call that the user would make to execute the swap.

The solver finally calls the checkSlippage function, which will revert the transaction if the slippage is above the specified limits at the time that the solver is committing to the swap. This call has to be made by the solver because the user's call to swapDAIForWETH pushed a required call to checkSlippage to the proxy, as specified earlier.

    function solverLand(uint256 laminatorSequenceNumber, address filler) public {
CallObject[] memory callObjs = new CallObject[](2);
ReturnObject[] memory returnObjs = new ReturnObject[](2);

callObjs[0] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 10000000,
callvalue: abi.encodeWithSignature("pull(uint256)", laminatorSequenceNumber)
});

ReturnObject[] memory returnObjsFromPull = new ReturnObject[](2);
returnObjsFromPull[0] = ReturnObject({returnvalue: ""});
returnObjsFromPull[1] = ReturnObject({returnvalue: ""});

returnObjs[0] = ReturnObject({returnvalue: abi.encode(abi.encode(returnObjsFromPull))});

callObjs[1] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 10000000,
callvalue: abi.encodeWithSignature("checkSlippage(uint256)", laminatorSequenceNumber)
});

returnObjs[1] = ReturnObject({returnvalue: ""});

bytes32[] memory keys = new bytes32[](3);
keys[0] = keccak256(abi.encodePacked("tipYourBartender"));
keys[1] = keccak256(abi.encodePacked("pullIndex"));
keys[2] = keccak256(abi.encodePacked("hintdex"));
bytes[] memory values = new bytes[](3);
values[0] = abi.encodePacked(filler);
values[1] = abi.encode(laminatorSequenceNumber);
values[2] = abi.encode(2);
bytes memory encodedData = abi.encode(keys, values);

// In this specific test, we don't have to use hintdices because the call list is short.
// Hintdices will be used in longer call sequences.
bytes32[] memory hintdicesKeys = new bytes32[](2);
hintdicesKeys[0] = keccak256(abi.encode(callObjs[0]));
hintdicesKeys[1] = keccak256(abi.encode(callObjs[1]));
uint256[] memory hintindicesVals = new uint256[](2);
hintindicesVals[0] = 0;
hintindicesVals[1] = 1;
bytes memory hintdices = abi.encode(hintdicesKeys, hintindicesVals);
callbreaker.verify(abi.encode(callObjs), abi.encode(returnObjs), encodedData, hintdices);
}