Build a Invariants-Enforced Swap
The Worked Example
demonstrates how to use smart transactions to exchange tokens at prices specified by the user, as opposed to the market rate offered by most DEX protocols. It serves as a simplified prototype, similar to a CoWswap-style exchange, where complex components are substituted with basic placeholders for ease of understanding.
Suppose Alice wants to exchange 10 Token A’s for 20 Token B’s. Alice writes a smart contract, SelfCheckout
, that enforces an invariant
; Alice’s 10 Token A’s must be exchanged for at least 20 token B. A solver, Bob, would fulfill the transaction at MEV-Time. The test is divided into 3 parts:
Deployment of the contracts
Creation of the smart transaction from the user
Execution of the smart transaction (and thereby the swapping of funds) by the solver
Getting Started
To begin development, run the following commands:
forge install
forge build
# Run test suite
forge test
The first part of the test deploys some ERC20 tokens and Alice’s smart transaction contract and mempool:
ERC20 token contracts (ERC20 A, ERC20 B)
Mempool factory contract (Laminator
) & an instance of LaminatedProxy (pusherLaminated
)
Alice’s Self-Checkout contract (this is the swap protocol)
To set up the test scenario, we mint 10 Token As to the user Alice and 20 Token Bs to Bob, the fulfiller of the trade (hereby referred to as the “solver”). After Alice queues up the series of calls she wants to be executed, Bob will pull
to execute them at MEV-Time along with some arbitrary calls of his own added to an on-chain bundle. The transaction will not revert as long as the end result is reached: Alice has swapped 20 Tokens with Bob. The solver knows the exact operations that Alice wants to perform: their responsibility is to fulfill said operations and prove that they were able to do what Alice has specified.
These setup steps ensure that the solver and the user both have the requisite liquidity to complete the swap to proceed with the demonstration.
Let’s dive deeper into how Alice creates smart transactions. Generally, users use their LaminatedProxy
to queue up calls they want executed in the future by pushing an abi.encoded
list of CallObjects
to the mempool.
push(bytes memory input, uint256 delay)
Pushing the series of transactions stores calls within the LaminatedProxy
’s storage for a solver, Bob, to pull
later. The sequence number that push
returns is what Bob will use to specify the series of transactions he wants to execute. When Bob calls pull(uint256 seqNumber)
on the transactions Alice has pushed, Bob executes Alice’s calls sequentially. In some cases, Alice may add copyCurrentJob
to conditionally repeat calls that she had pushed.
In order for Alice to enforce invariants on her own calls (e.g. for her to make sure that Bob gives her 20 Token B back in exchange for her 10 token A), she uses utilities from SmarterContract
to introspect on the context of her call. In this example, she uses the assertFutureCallTo
function.
assertFutureCallTo(CallObject memory callObj, uint256 hintdex)
In the case of the WorkedExample, when Alice queues up a transaction for the SelfCheckout to take her tokens, the SelfCheckout asserts that Bob makes a call to checkBalance
at a certain index. Without further assertions from Alice to prevent Bob from frontrunning or backrunning the transaction, Bob can make any calls of his own in any order as long as checkBalance
occurs after Alice’s assertion. The CallBreaker
takes Alice’s assertion and verifies it against Bob’s call trace. Without Alice’s SelfCheckout contract scheduling this call, Bob wouldn’t need to give Alice 20 Tokens!
When Alice schedules a call, the index that Alice asserts the call to be at is known as a hintdex
. Short for “hint-index” and inspired by ZK circuit hinting, this value is provided by the solver, Bob, to speed up verification of his call trace. Without the hintdex
, in order for the CallBreaker
to executeAndVerify that Bob’s call chain matches the assertion, Bob’s bundle would have to go through an O(n) search to find where he called checkBalance
. The hintdex allows the CallBreaker
to directly check where Bob made a specific call (i.e. at the asserted location). Note the hintdex- this is provided by Bob, and checked on chain at solve-time, in order to avoid a costly O(n) search through Bob’s bundle to find where he called checkBalance
. Hintdices and other similar tricks will prove to be a common STXN development pattern.
Context aside, the call flow on Alice’s end is simple. Alice queues up 3 calls:
- A call to
tip
the solver by some predetermined amount- Serves a similar role to block.coinbase
- Alice could leave this out and have a valid transaction, but a rational solver may not pick her transaction up.
- Alice has multiple tipping options (e.g. via. ERC20s/NFTs)
- A call to approve the SelfCheckout contract to take 10 Token A from Alice’s balance
- A call to transfer the 10 Token A from Alice to the SelfCheckout.
function userLand() public returns (uint256) {
// Userland operations
pusherLaminated.transfer(1 ether);
erc20a.transfer(pusherLaminated, 10);
CallObject[] memory pusherCallObjs = new CallObject[](3);
pusherCallObjs[0] = CallObject({amount: _tipWei, addr: address(callbreaker), gas: 10000000, callvalue: ""});
pusherCallObjs[1] = CallObject({
amount: 0,
addr: address(erc20a),
gas: 1000000,
callvalue: abi.encodeWithSignature("approve(address,uint256)", address(selfcheckout), 10)
});
pusherCallObjs[2] = CallObject({
amount: 0,
addr: address(selfcheckout),
gas: 1000000,
callvalue: abi.encodeWithSignature("takeSomeAtokenFromOwner(uint256)", 10)
});
SolverData[] memory dataValues = Constants.emptyDataValues();
laminator.pushToProxy(abi.encode(pusherCallObjs), 1, "0x00", dataValues);
return laminator.pushToProxy(abi.encode(pusherCallObjs), 1, "0x00", dataValues);
}
Note that this call will assert a future call to checkBalance()
.
If Alice no longer wants to execute the transactions she had previously pushed (or if it is not getting picked up by solvers), she can cancel them by using cancelPending(uint256 callSequenceNumber)
or cancelAllPending()
.
At a high level, the solver orchestrates a sequence of arbitrary contract calls within the bounds of Alice’s assertions, and ensures their validity by calling the executeAndVerify
function at the end. Here, Bob has knowledge of the transactions that Alice wants him to execute at MEV-Time. Bob can execute whatever calls he wants, as long as he fulfills Alice’s assertions.
In order to fulfill the swap, the solver constructs a list of CallObjects[]
and ReturnObjects[]
. The CallObjects
are what Bob will execute in executeAndVerify
– a function in the CallBreaker
that allows Bob to resolve Alice’s transaction. The CallBreaker uses Bob’s provided list of ReturnObjects
to assert that Bob’s calls reached a correct state.
The solver also provides a list of key-value pairs of data associated with the transaction (for example, the address of who Alice should tip, the amount of token B that Bob should give Alice, who Alice is swapping with, etc.). Lastly, the solver provides arbitrary data associated with the transaction and the hintdices
used to quickly executeAndVerify his call trace.
In the WorkedExample, Bob executes the following:
- A call to
pull
Alice’s series of transactions. (and execute them) - A call to
approve
the contract to takex
token Bs (in this case, 20) - A call to give
x
token Bs to Alice - A call to
checkBalance
(this call is asserted by Alice's aforementioned call to transfer 10 Token As) - Provision of data associated with the transaction (associatedData) and each call’s
hintdex
.
function solverLand(uint256 laminatorSequenceNumber, address filler, uint256 x) public {
erc20b.approve(address(selfcheckout), x);
CallObject[] memory callObjs = new CallObject[](3);
ReturnObject[] memory returnObjs = new ReturnObject[](3);
callObjs[0] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 1000000,
callvalue: abi.encodeWithSignature("pull(uint256)", laminatorSequenceNumber)
});
// should return a list of the return value of approve + takesomeatokenfrompusher in a list of returnobjects, abi packed, then stuck into another returnobject.
ReturnObject[] memory returnObjsFromPull = new ReturnObject[](3);
returnObjsFromPull[0] = ReturnObject({returnvalue: ""});
returnObjsFromPull[1] = ReturnObject({returnvalue: abi.encode(true)});
returnObjsFromPull[2] = ReturnObject({returnvalue: ""});
returnObjs[0] = ReturnObject({returnvalue: abi.encode(abi.encode(returnObjsFromPull))});
// then we'll call giveSomeBtokenToOwner and get the imbalance back to zero
callObjs[1] = CallObject({
amount: 0,
addr: address(selfcheckout),
gas: 1000000,
callvalue: abi.encodeWithSignature("giveSomeBtokenToOwner(uint256)", x)
});
// return object is still nothing
returnObjs[1] = ReturnObject({returnvalue: ""});
// then we'll call checkBalance
callObjs[2] = CallObject({
amount: 0,
addr: address(selfcheckout),
gas: 1000000,
callvalue: abi.encodeWithSignature("checkBalance()")
});
// log what this callobject looks like
// return object is still nothing
returnObjs[2] = ReturnObject({returnvalue: ""});
// Constructing something that'll decode happily
AdditionalData[] memory associatedData = new AdditionalData[](5);
associatedData[0] =
AdditionalData({key: keccak256(abi.encodePacked("tipYourBartender")), value: abi.encodePacked(filler)});
associatedData[1] = AdditionalData({key: keccak256(abi.encodePacked("swapPartner")), value: abi.encode(filler)});
associatedData[2] =
AdditionalData({key: keccak256(abi.encodePacked("pusherLaminated")), value: abi.encode(pusherLaminated)});
associatedData[3] = AdditionalData({key: keccak256(abi.encodePacked("x")), value: abi.encode(x)});
associatedData[4] =
AdditionalData({key: keccak256(abi.encodePacked("seqNum")), value: abi.encode(laminatorSequenceNumber)});
AdditionalData[] memory hintdices = new AdditionalData[](3);
hintdices[0] = AdditionalData({key: keccak256(abi.encode(callObjs[0])), value: abi.encode(0)});
hintdices[1] = AdditionalData({key: keccak256(abi.encode(callObjs[1])), value: abi.encode(1)});
hintdices[2] = AdditionalData({key: keccak256(abi.encode(callObjs[2])), value: abi.encode(2)});
callbreaker.executeAndVerify(
abi.encode(callObjs), abi.encode(returnObjs), abi.encode(associatedData), abi.encode(hintdices)
);
}
Finally, verification of the call execution takes place to conclude the transaction. To executeAndVerify that the calls were successfully executed with the intended return values, the CallBreaker
executes each individual call, and checks the calls’ return values against a provided list of return values. The executeAndVerify
checks will pass if the calls execute as stated.
The code inside the smart transaction that calls into Alice’s SmarterContract will, during the course of execution, check the correctness of the execution environment (e.g., that verification is taking place via. The CallBreaker
), and use SmarterContract or custom utilities to gas-efficiently investigate whether the bundle satisfies the user’s requests. If anything’s wrong, the bundle atomically reverts, because it is all enclosed into one transaction.
For the specific example, please check out the Example Implementation.