Skip to content

Commit

Permalink
Add DestinationSettler2 and DestinationSettler3 variants
Browse files Browse the repository at this point in the history
  • Loading branch information
nicholaspai committed Dec 5, 2024
1 parent 169fd19 commit 57e571b
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 2 deletions.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This repository contains contracts and scripts demonstrating this flow.
4. User sends `open` transaction on origin chain `OriginSettler`
5 Relayer sees 7683 order
6. Relayer sends fill on destination chain `DestinationSettler`
7. If fill requires user delegation to be set up, relayer must include this in their fill txn, which should be a type 4 txn.
7. (optional) If fill requires user delegation to be set up, relayer must include this in their fill txn, which should be a type 4 txn.
8. Fill sends the funds to the user’s EOA.
9. Fill calls `XAccount.xExecute` on user’s EOA with the UserOp
10. **User’s EOA performs UserOp** where the `msg.sender` is now set to the user's EOA and the `code` is set to the `XAccount`
Expand All @@ -35,6 +35,16 @@ This repository contains contracts and scripts demonstrating this flow.
- As stated above, the `OriginSettler#open` function can be used by the user to include a 7702 authorization to be submitted by the filler on the destination chain. This way the user can complete the prerequisite 7702 transaction and delegate the `calldata` execution in the same 7683 intent.
- `XAccount`: Destination chain proxy contract that users should set as their `code` via a 7702 type 4 transaction. Verifies that any calldata execution delegated to it was signed by the expected user.

## Differences between DestinationSettler.sol variants

This repository contains multiple DestinationSettler{2,3}.sol contracts that demonstrate different ways of setting up the `XAccount` smart contract wallet and how they interact with the `DestinationSettler` contract.

The `DestinationSettler.sol` contract delegates all signature and UserOp verification to the `XAccount` wallet so that users need to only trust the `XAccount` contract and can use this wallet with any settlement system.

The `DestinationSettler2.sol` performs all signature and UserOp verification and only uses the `XAccount` wallet as a Multicaller or Multisender contract to execute the calls. This reduces gas costs compared to `DestinationSettler.sol` but it means the user needs to trust the `DestinationSettler2.sol`. There are reasons not to combine the escrow logic with the verification logic so this is offered as an alternative to `DestinationSettler.sol` which separates the concerns.

The `DestinationSettler3.sol` offers similar UX to the `DestinationSettler2.sol` in that the user must trust the settlement contract to perform verification, but this contract also performs the Multicaller duties. Its even more gas efficient than `DestinationSettler2.sol` because there is no `XAccount` contract to call.

## Off-chain components

- Relayer that will pick up 7683 order and fill it on destination.
Expand All @@ -43,10 +53,12 @@ This repository contains contracts and scripts demonstrating this flow.

The main architecture decision we made was whether to place the destination chain signature verification logic in the `DestinationSettler` or the `XAccount` contract. By placing this in the latter, we are implicitly encouraging there to be many different types of destination chain settlement contracts, that offer different fulfillment guarantees and features to fillers, that all delegate UserOp execution to a singleton `XAccount` contract. The user needs to trust that `XAccount` will do what its supposed to do.

The alternative would be to instead encourage that the `DestinationSettler` contract is a singleton contract that should be trusted by users. Any fulfillment logic enforced in the settlement contract would be shared across all users. This would make the `XAccount` contract much simpler. We decided against this as we believe there are opinionated settlement contract features that would greatly improve user and filler UX but that we didn't want to include in a singleton contract.
The alternative would be to instead encourage that the `DestinationSettler` contract is a singleton contract that should be trusted by users. Any UserOp verification logic enforced in the settlement contract would be shared across all users. This would make the `XAccount` contract much simpler. We decided against this as the default option because we believe there are opinionated settlement contract features that would greatly improve user and filler UX but that we didn't want to include in a singleton contract.

For example, the settlement contract should ideally protect against duplicate fulfillment of the same 7683 order and simultaneously allow the user to protect fillers from colliding fill transactions. These features would require the `fill` function on the settlement contract to include parameters like `exclusiveRelayer` and enforce logic like checking if `fillStatuses[fillHash] = true`. But, we believe there are strong arguments for why these features are opinionated and do not belong in a generalized `DestinationSettler` contract.

We also think its better for users if they only need to designate a smart contract wallet they trust that can work with any settlement system, rather than have to trust each settlement system they interact with. This assumes there will be multiple settlement systems offered to users eventually.

## EIP7702 resources

[This best-practices document](https://hackmd.io/@rimeissner/eip7702-best-practices) was very useful in guiding design of `XAccount`
121 changes: 121 additions & 0 deletions src/DestinationSettler2.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
pragma solidity ^0.8.0;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {GaslessCrossChainOrder} from "./ERC7683.sol";
import {CallByUser, Call} from "./Structs.sol";

/**
* @notice Destination chain entrypoint contract for fillers relaying cross chain message containing delegated
* calldata.
* @dev The difference between this contract and the DestinationSettler.sol contract is that this contract
* performs all the CallByUser signature verification rather than having the XAccount contract do it. This reduces
* gas costs in the `fill()` function but it does mean that all users must trust the DestinationSettler
* contract since it performs the signature verification. Unlike this contract, the DestinationSettler.sol contract
* can be swapped out for another settlement contract because the XAccount provides the signature verification
* protection for the user. As a result, the XAccount smart contract wallet used by this settler is much more
* lightweight than the one used by the DestinationSettler.sol contract.
*/
contract DestinationSettler2 is ReentrancyGuard {
using SafeERC20 for IERC20;

mapping(bytes32 => bool) public fillStatuses;

// Called by filler, who sees ERC7683 intent emitted on origin chain
// containing the callsByUser data to be executed following a 7702 delegation.
// @dev We don't use the last parameter `fillerData` in this function.
function fill(bytes32 orderId, bytes calldata originData, bytes calldata) external nonReentrant {
(CallByUser memory callsByUser) = abi.decode(originData, (CallByUser));
// Verify orderId?
// require(orderId == keccak256(originData), "Wrong order data");

// Protect against duplicate fills.
require(!fillStatuses[orderId], "Already filled");
fillStatuses[orderId] = true;

// TODO: Protect fillers from collisions with other fillers. Requires letting user set an exclusive relayer.

// Pull funds into this settlement contract and perform any steps necessary to ensure that filler
// receives a refund of their assets.
_fundUser(callsByUser);

_verifyCalls(callsByUser);
_verify7702Delegation();

// The following call will only succeed if the user has set a 7702 authorization to set its code
// equal to the XAccount contract. The filler should have seen any auth data emitted in an OriginSettler
// event on the sending chain.
XAccount(payable(callsByUser.user)).xExecute(callsByUser);

// Perform any final steps required to prove that filler has successfully filled the ERC7683 intent.
// For example, we could emit an event containing a unique hash of the fill that could be proved
// on the origin chain via a receipt proof + RIP7755.
// e.g. emit Executed(orderId)
}

// Pull funds into this settlement contract as escrow and use to execute user's calldata. Escrowed
// funds will be paid back to filler after this contract successfully verifies the settled intent.
// This step could be skipped by lightweight escrow systems that don't need to perform additional
// validation on the filler's actions.
function _fundUser(CallByUser memory call) internal {
IERC20(call.asset.token).safeTransferFrom(msg.sender, call.user, call.asset.amount);
}

function _verifyCalls(CallByUser memory userCalls) internal view {
require(userCalls.chainId == block.chainid);
// TODO: Make the blob to sign EIP712-compatible (i.e. instead of keccak256(abi.encode(...)) set
// this to SigningLib.getTypedDataHash(...)
require(
SignatureChecker.isValidSignatureNow(
userCalls.user, keccak256(abi.encode(userCalls.calls, userCalls.nonce)), userCalls.signature
)
);
}

function _verify7702Delegation() internal {
// TODO: We might not need this function at all, because if the authorization data requires that this contract
// is set as the delegation code, then xExecute would fail if the auth data is not submitted by the filler.
// However, it might still be useful to verify that the delegate is set correctly, like checking EXTCODEHASH.
}
}

// TODO: Move to separate file once we are more confident in architecture. For now keep here for readability.

/**
* @notice Singleton contract used by all users who want to sign data on origin chain and delegate execution of
* their calldata on this chain to this contract.
*/
contract XAccount is ReentrancyGuard {
error CallReverted(uint256 index, Call[] calls);
error InvalidCall(uint256 index, Call[] calls);

address public constant DESTINATION_SETTLER = address(0xf00d);

// Entrypoint function to be called by DestinationSettler contract on this chain.
// Assume user has 7702-delegated code already to this contract.
// All calldata and 7702 authorization data is assumed to have been emitted on the origin chain in am ERC7683
// intent creation event.
function xExecute(CallByUser memory userCalls) external nonReentrant {
require(msg.sender == DESTINATION_SETTLER);
_attemptCalls(userCalls.calls);
}

function _attemptCalls(Call[] memory calls) internal {
for (uint256 i = 0; i < calls.length; ++i) {
Call memory call = calls[i];

// If we are calling an EOA with calldata, assume target was incorrectly specified and revert.
if (call.callData.length > 0 && call.target.code.length == 0) {
revert InvalidCall(i, calls);
}

(bool success,) = call.target.call{value: call.value}(call.callData);
if (!success) revert CallReverted(i, calls);
}
}

// Used if the caller is trying to unwrap the native token to this contract.
receive() external payable {}
}
100 changes: 100 additions & 0 deletions src/DestinationSettler3.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
pragma solidity ^0.8.0;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {GaslessCrossChainOrder} from "./ERC7683.sol";
import {CallByUser, Call} from "./Structs.sol";

/**
* @notice Destination chain entrypoint contract for fillers relaying cross chain message containing delegated
* calldata.
* @dev The difference between this contract and the DestinationSettler.sol contract is that this contract
* performs all the CallByUser signature verification AND executes the user's calldata, rather than having
* the XAccount contract do it. This reduces gas costs in the `fill()` function but it does mean that all users
* must trust the DestinationSettler contract since it performs the signature verification. This contract is different
* from DestinationSettler2.sol because it collapses the XAccount and the DestinationSettler2 contracts into one.
*/
contract DestinationSettler3 is ReentrancyGuard {
using SafeERC20 for IERC20;

mapping(bytes32 => bool) public fillStatuses;

error CallReverted(uint256 index, Call[] calls);
error InvalidCall(uint256 index, Call[] calls);

// Called by filler, who sees ERC7683 intent emitted on origin chain
// containing the callsByUser data to be executed following a 7702 delegation.
// @dev We don't use the last parameter `fillerData` in this function.
function fill(bytes32 orderId, bytes calldata originData, bytes calldata) external nonReentrant {
(CallByUser memory callsByUser) = abi.decode(originData, (CallByUser));
// Verify orderId?
// require(orderId == keccak256(originData), "Wrong order data");

// Protect against duplicate fills.
require(!fillStatuses[orderId], "Already filled");
fillStatuses[orderId] = true;

// TODO: Protect fillers from collisions with other fillers. Requires letting user set an exclusive relayer.

// Pull funds into this settlement contract and perform any steps necessary to ensure that filler
// receives a refund of their assets.
_fundUser(callsByUser);

_verifyCalls(callsByUser);
_verify7702Delegation();

// The following call might fail if the user has not set a 7702 authorization to set its code
// equal to this contract. Without this authorization, the msg.sender will not be the userCalls.user.
// The filler should have seen any auth data emitted in an OriginSettler event on the sending chain.
_attemptCalls(callsByUser.calls);

// Perform any final steps required to prove that filler has successfully filled the ERC7683 intent.
// For example, we could emit an event containing a unique hash of the fill that could be proved
// on the origin chain via a receipt proof + RIP7755.
// e.g. emit Executed(orderId)
}

// Pull funds into this settlement contract as escrow and use to execute user's calldata. Escrowed
// funds will be paid back to filler after this contract successfully verifies the settled intent.
// This step could be skipped by lightweight escrow systems that don't need to perform additional
// validation on the filler's actions.
function _fundUser(CallByUser memory call) internal {
IERC20(call.asset.token).safeTransferFrom(msg.sender, call.user, call.asset.amount);
}

function _verifyCalls(CallByUser memory userCalls) internal view {
require(userCalls.chainId == block.chainid);
// TODO: Make the blob to sign EIP712-compatible (i.e. instead of keccak256(abi.encode(...)) set
// this to SigningLib.getTypedDataHash(...)
require(
SignatureChecker.isValidSignatureNow(
userCalls.user, keccak256(abi.encode(userCalls.calls, userCalls.nonce)), userCalls.signature
)
);
}

function _verify7702Delegation() internal {
// TODO: We might not need this function at all, because if the authorization data requires that this contract
// is set as the delegation code, then xExecute would fail if the auth data is not submitted by the filler.
// However, it might still be useful to verify that the delegate is set correctly, like checking EXTCODEHASH.
}

function _attemptCalls(Call[] memory calls) internal {
for (uint256 i = 0; i < calls.length; ++i) {
Call memory call = calls[i];

// If we are calling an EOA with calldata, assume target was incorrectly specified and revert.
if (call.callData.length > 0 && call.target.code.length == 0) {
revert InvalidCall(i, calls);
}

(bool success,) = call.target.call{value: call.value}(call.callData);
if (!success) revert CallReverted(i, calls);
}
}

// Used if the caller is trying to unwrap the native token to this contract.
receive() external payable {}
}

0 comments on commit 57e571b

Please sign in to comment.