Skip to content
This repository has been archived by the owner on Nov 29, 2023. It is now read-only.

Cross chain execution and payments via UserOps #1

Merged
merged 20 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 9 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
136 changes: 126 additions & 10 deletions contracts/SCBridgeWallet.sol
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
pragma solidity 0.8.19;

// SPDX-License-Identifier: MIT

import {IAccount} from "contracts/interfaces/IAccount.sol";
import {UserOperation} from "contracts/interfaces/UserOperation.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {HTLC, State, hashState, checkSignatures, Participant} from "./state.sol";
import {UserOperationLib} from "contracts/core/UserOperationLib.sol";
import {ExecuteChainInfo, PaymentChainInfo} from "./state.sol";

enum WalletStatus {
OPEN,
CHALLENGE_RAISED,
FINALIZED
}

enum NonceKey {
SHARED,
OWNER
}

uint constant CHALLENGE_WAIT = 1 days;

contract SCBridgeWallet is IAccount {
Expand Down Expand Up @@ -100,7 +107,10 @@ contract SCBridgeWallet is IAccount {
bytes calldata intermediarySignature
) external {
checkSignatures(state, ownerSignature, intermediarySignature);
internalChallenge(state);
}

function internalChallenge(State calldata state) internal {
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
WalletStatus status = getStatus();

require(status != WalletStatus.FINALIZED, "Wallet already finalized");
Expand All @@ -125,7 +135,26 @@ contract SCBridgeWallet is IAccount {
challengeExpiry = largestTimeLock + CHALLENGE_WAIT;
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
}

function execute(address dest, uint256 value, bytes calldata func) external {
/// crossChain is a special function that handles cross chain execution and payment
function crossChain(
ExecuteChainInfo[] calldata e,
PaymentChainInfo[] calldata p
) public {
// Only the entrypoint should trigger this by excecuting a UserOp
require(msg.sender == entrypoint, "account: not EntryPoint");
Comment on lines +143 to +144
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious if this is necessary -- should we have had this check before? Should all 4337 wallets have this check?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also seems to be replicated below?

Copy link
Contributor Author

@lalexgap lalexgap Nov 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed since we want to enforce that crossChain is only triggered through submitting a UserOp. Otherwise anyone could call with crossChain, using a transaction from an EoA, and whatever PaymentChainInfo or ExecutionChainInfo they wanted. Since challenge is allowed to be called at any time, someone could call crossChain with a bogus unsigned state and trigger a challenge using that.

Should all 4337 wallets have this check?

It depends on the function and the 4337 wallet! In a lot of cases there will be functions restricted to only some owner or entrypoint(IE: A function to send user funds)

 require(
        msg.sender == entrypoint || msg.sender == owner,
        "account: not Owner or EntryPoint"
      );

This allows the owner of the wallet to call the function using either an EoA account or through submitting a UserOp.

In our case we need to be a bit stricter, we only want to trigger crossChain if a UserOp has been submitted signed by all the participants. We're relying on the signature validation of the UserOp to protect the call to crossChain.

It also seems to be replicated below?

I think that's in a separate execute function, which can be triggered outside of crossChain. So we need to implement the check in both functions.

for (uint i = 0; i < e.length; i++) {
if (e[i].chainId == block.chainid) {
execute(e[i].dest, e[i].value, e[i].callData);
}
}
for (uint i = 0; i < p.length; i++) {
if (p[i].chainId == block.chainid) {
internalChallenge(p[i].paymentState);
}
}
}

function execute(address dest, uint256 value, bytes calldata func) public {
if (getStatus() == WalletStatus.FINALIZED && activeHTLCs.length == 0) {
// If the wallet has finalized and all the funds have been reclaimed then the owner can do whatever they want with the remaining funds
// The owner can call this function directly or the entrypoint can call it on their behalf
Expand Down Expand Up @@ -160,24 +189,34 @@ contract SCBridgeWallet is IAccount {
) external view returns (uint256 validationData) {
bytes memory ownerSig = userOp.signature[0:65];
// The owner of the wallet must always approve of any user operation to execute on it's behalf
require(
validateSignature(userOpHash, ownerSig, owner) ==
SIG_VALIDATION_SUCCEEDED,
"owner must sign"
);
if (
validateSignature(userOpHash, ownerSig, owner) != SIG_VALIDATION_SUCCEEDED
) {
return SIG_VALIDATION_FAILED;
Copy link
Contributor Author

@lalexgap lalexgap Nov 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ERC 4337 spec specifies that the wallet SHOULD return SIG_VALIDATION_FAILED (and not revert) on signature mismatch, so I've changed this from a revert to return SIG_VALIDATION_FAILED

}

// If the wallet is finalized then the owner can do whatever they want with the remaining funds
if (getStatus() == WalletStatus.FINALIZED) {
return SIG_VALIDATION_SUCCEEDED;
}

bytes4 functionSelector = bytes4(userOp.callData[0:4]);
NonceKey key = NonceKey(userOp.nonce >> 64);

// If the function is crossChain, we use validate using the chainids and entrypoints from the calldata
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
if (
functionSelector == this.crossChain.selector && key == NonceKey.SHARED
) {
validateCrossChain(userOp);
}

// If the function is permitted, it can be called at any time
// (including when the wallet is in CHALLENGE_RAISED) with no futher checks.
bytes4 functionSelector = bytes4(userOp.callData[0:4]);
if (permitted(functionSelector)) return SIG_VALIDATION_SUCCEEDED;
if (permitted(functionSelector) && key == NonceKey.OWNER)
return SIG_VALIDATION_SUCCEEDED;

// If the wallet is open, we need to apply extra conditions:
if (getStatus() == WalletStatus.OPEN) {
if (getStatus() == WalletStatus.OPEN && key == NonceKey.SHARED) {
bytes memory intermediarySig = userOp.signature[65:130];
return validateSignature(userOpHash, intermediarySig, intermediary);
}
Expand All @@ -194,6 +233,73 @@ contract SCBridgeWallet is IAccount {
uint256 internal constant SIG_VALIDATION_SUCCEEDED = 0;
uint256 internal constant SIG_VALIDATION_FAILED = 1;

/// This validates the crossChain UserOp
/// It ensures that it signed by the owner and intermediary on every chain
/// It also ensures that the UserOp targets the current chain and entrypoint
function validateCrossChain(UserOperation calldata userOp) private view {
(ExecuteChainInfo[] memory e, PaymentChainInfo[] memory p) = abi.decode(
userOp.callData[4:],
(ExecuteChainInfo[], PaymentChainInfo[])
);

bool foundExecute = false;
bool foundPayment = false;

// We expect every owner and intermediary on every chain to have signed the userOpHash
// For each chain we expect a signature from the owner and intermediary
require(
userOp.signature.length == (e.length + p.length) * 65 * 2,
"Invalid signature length"
);

for (uint i = 0; i < e.length; i++) {
if (e[i].chainId == block.chainid && e[i].entrypoint == entrypoint) {
foundExecute = true;
}

// TODO: I think we could just have everyone signed the UserOpHash for the first chain, instead of generating a new hash for each chain?
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
// Check that the owner and intermediary have signed the userOpHash on the execution chain
bytes32 userOpHash = generateUserOpHash(
userOp,
e[i].entrypoint,
e[i].chainId
);

uint offset = i * 65;
bytes memory ownerSig = userOp.signature[offset:offset + 65];
bytes memory intermediarySig = userOp.signature[offset + 65:offset + 130];
validateSignature(userOpHash, ownerSig, e[i].owner);
validateSignature(userOpHash, intermediarySig, e[i].intermediary);
}

for (uint i = 0; i < p.length; i++) {
if (p[i].chainId == block.chainid && p[i].entrypoint == entrypoint) {
foundPayment = true;
}

// Check that the owner and intermediary have signed the userOpHash on the payment chain
bytes32 userOpHash = generateUserOpHash(
userOp,
p[i].entrypoint,
p[i].chainId
);
uint offset = (e.length + i) * 65;
bytes memory ownerSig = userOp.signature[offset:offset + 65];
bytes memory intermediarySig = userOp.signature[offset + 65:offset + 130];
validateSignature(userOpHash, ownerSig, p[i].paymentState.owner);
validateSignature(
userOpHash,
intermediarySig,
p[i].paymentState.intermediary
);
}

require(
foundExecute || foundPayment,
"Must target execution or payment chain"
);
}

function validateSignature(
bytes32 userOpHash,
bytes memory signature,
Expand All @@ -215,3 +321,13 @@ contract SCBridgeWallet is IAccount {
return true;
}
}
using UserOperationLib for UserOperation;

/// @dev Based on the entrypoint implementation
function generateUserOpHash(
UserOperation calldata userOp,
address entrypoint,
uint chainId
) pure returns (bytes32) {
return keccak256(abi.encode(userOp.hash(), entrypoint, chainId));
}
73 changes: 45 additions & 28 deletions contracts/state.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,64 @@ pragma solidity 0.8.19;
// SPDX-License-Identifier: MIT
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
struct State {
address payable owner;
address payable intermediary;
uint turnNum;
uint intermediaryBalance;
HTLC[] htlcs;
address payable owner;
address payable intermediary;
uint turnNum;
uint intermediaryBalance;
HTLC[] htlcs;
}

enum Participant {
OWNER,
INTERMEDIARY
OWNER,
INTERMEDIARY
}

struct HTLC {
Participant to;
uint amount;
bytes32 hashLock;
uint timelock;
Participant to;
uint amount;
bytes32 hashLock;
uint timelock;
}

function hashState(State memory state) pure returns (bytes32) {
return keccak256(abi.encode(state));
return keccak256(abi.encode(state));
}

using ECDSA for bytes32;

function checkSignatures(
State calldata state,
bytes calldata ownerSignature,
bytes calldata intermediarySignature
State calldata state,
bytes calldata ownerSignature,
bytes calldata intermediarySignature
) pure {
bytes32 stateHash = hashState(state);
if (
state.owner !=
stateHash.toEthSignedMessageHash().recover(ownerSignature)
) {
revert("Invalid owner signature");
}
if (
state.intermediary !=
stateHash.toEthSignedMessageHash().recover(intermediarySignature)
) {
revert("Invalid intermediary signature");
}
bytes32 stateHash = hashState(state);
if (
state.owner != stateHash.toEthSignedMessageHash().recover(ownerSignature)
) {
revert("Invalid owner signature");
}
if (
state.intermediary !=
stateHash.toEthSignedMessageHash().recover(intermediarySignature)
) {
revert("Invalid intermediary signature");
}
}

/// Contains information to execute a function call
struct ExecuteChainInfo {
uint chainId;
address entrypoint;
address dest;
uint value;
bytes callData;
address owner;
address intermediary;
}

/// Contains information to execute a payment
struct PaymentChainInfo {
uint chainId;
address entrypoint;
State paymentState;
}
77 changes: 77 additions & 0 deletions docs/cross-chain-payments-and-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
## Cross chain payments and execution

This document suggests an approach of using `UserOp`s to perform cross-chain payments and execution. At a very high level this approach can be thought of as a cross-chain state channel, where we have some state, that when fully signed can be submitted to an adjudicator contract to claim the funds based on the outcome of that state.
lalexgap marked this conversation as resolved.
Show resolved Hide resolved

However instead of implementing the state channel framework ourselves, we're making use of some of ERC 4337 infrastructure. Instead of signing a state, participants sign a **UserOp** that contains the state information. Instead of having a specific "adjudicator" contract, we have the **entrypoint** and the **BridgeWallet SCW** to handle adjudicating funds. Instead of making a on-chain call to challenge on the adjudicator, we can submit the `UserOp` to an entrypoint to trigger a challenge.

Since we can specify the UserOp calldata, we can include whatever additional state information we want in that calldata. That lets us embed payment or execution information for different chains in one UserOp. When submitted to chain the UserOp will behave depending on the chain Id and the embedded payment or execution information.

# Payments

We embed payment information in the UserOp using a `PaymentChainInfo`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the structs be renamed to PaymentInfo and ExecuteInfo for simplicity? Or does the use of Chain in those names help clarify what they are?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the use of Chain helps clarify that info in these structs is specific to a given chain, and should only be honoured on that chain. I don't feel too strongly about it though so I'm happy to change it if others feel differently.


```sol
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
/// Contains information to execute a payment
struct PaymentChainInfo {
uint chainId;
address entrypoint;
State paymentState;
}

```

Whenever a fully signed UserOp containing a `PaymentChainInfo` is submitted to chain, it forces a challenge on that chain. This means that once you have a fully signed UserOp you know you can always use it to get your funds via challenge. In the happy path, we would expect participants to just exchange signed states afterwards, so they can discard the UserOp.
lalexgap marked this conversation as resolved.
Show resolved Hide resolved

## Cross-chain Payment Example

Let's say we have Alice, Bob,and Irene. Alice and Irene have a BridgeWallet on chain A, Bob and Irene have a BridgeWallet on Chain B. Alice and Irene have both signed a state with a balance of `[A:5,I:5]`, and bob and Irene have likewise have a signed state with a balance of`[B:5,I:5]`. Let's say Alice wants to pay Bob.
lalexgap marked this conversation as resolved.
Show resolved Hide resolved

1. Alice creates two new unsigned states `[A:4,I:6]` and `[B:6,I:4]`
2. Alice includes them in a UserOp(wrapped in a `PaymentChainInfo`), and signs the UserOp. She sends this to Bob and Irene.
lalexgap marked this conversation as resolved.
Show resolved Hide resolved
3. Bob and Irene validate the UserOp and also sign it, and broadcast it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
3. Bob and Irene validate the UserOp and also sign it, and broadcast it.
3. Bob and Irene validate the `UserOp` and also sign it, and broadcast it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you replacing the hashlock mechanism with signatures, or just adding to it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "broadcast it" in this context? Where are Bob and Irene sending the signed UserOp?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you replacing the hashlock mechanism with signatures, or just adding to it?

In this example we are replacing the hashlock mechanism, since we're making a cross-chain payment that doesn't use HTLCs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "broadcast it" in this context? Where are Bob and Irene sending the signed UserOp?

Broadcast in this case just means send it to the other parties. Bob is broadcasting the state to Alice and Irene, Irene is sending to Alice and Bob.

4. At this point the UserOp is fully signed and can be used to force a challenge on either chain, by submitting the signed UserOp to the entrypoint on either chain.

# Cross-chain Execution

We can also extend this idea to cross chain execution. Instead of embedding a payment state in the `UserOp` we embed information to make an on-chain function call. If the `UserOp` is submitted to the `entrypoint` the function will be executed **if the chainId matches the chain**.

We embed this information using a `ExecuteChainInfo`

```sol
/// Contains information to execute a function call
struct ExecuteChainInfo {
uint chainId;
address entrypoint;
address dest;
uint value;
bytes callData;
address owner;
address intermediary;
}
```

A UserOp can contain multiple `ExecuteChainInfo`s, meaning you can have "atomic" cross chain execution (once the UserOp is fully signed, you're guaranteed you can trigger the execution on either chain). A UserOp can also contain `PaymentChainInfo`s and `ExecuteChainInfos` allowing to pay for cross-chain execution.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand on the scare quotes around "atomic"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if atomic is the correct word, but I'm trying to describe that once the UserOp is fully signed, we can be guaranteed that the execution can be triggered on either chain.

lalexgap marked this conversation as resolved.
Show resolved Hide resolved

# Multihop

Since a UserOp can contain multiple `PaymentChainInfo`s and `ExecuteChainInfos`, this approach can be used for multi-hop execution or payments. We just require the `owner` and `intermediary` of every BridgeWallet involved to sign the UserOp.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we go beyond a single hop, we have "intermediary-intermediary" channels. Is this a bit awkward with the "owner" / "intermediary" roles of the bridge wallet? Will one of the parties just arbitrarily take the role of "owner"?

Copy link
Contributor Author

@lalexgap lalexgap Nov 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think right now it might be a bit awkward, as once intermediary would play the role of the owner and one intermediary would play the role of intermediary in the BridgeWallet.

To solve this we could implement a specific Intermediary<->Intermediary mode (or separate contract?) for the BridgeWallet that handles the "intermediary-intermediary" use case.


# Replay Attacks

It's important that we're not vulnerable to replay attacks, where a `UserOp` is submitted again using a different entrypoint or chain.

If we're working on one chain, it's fairly easy to prevent replay attacks. The `userOpHash` [provided by the Entrypoint](https://github.com/magmo/Bridge-Wallet/blob/ad6d24fa2435f449751d1b61e24d12faff1f83a9/contracts/core/EntryPoint.sol#L298) to `validateUserOp` is hashed against the current chain and entrypoint. This means that if the UserOp is run against a different chain or entrypoint, there will be a different `userOpHash` causing all the signature checks to fail.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a little suboptimal. I suppose the entrypoint has not been designed with cross chain applications in mind, meaning that we have to make multiple hashes and a signature on each one (and as you say validate all of those combinations).

A "cross chain entrypoint" would ease this, but would be a departure from 4337 norms (I guess?).

Copy link
Contributor Author

@lalexgap lalexgap Nov 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change I don't think it's too bad. Now instead of generating a unique hash for each chain, all participants just sign the hash based on the first chain. This means we can generate one hash and check all the signatures against that.

I think it's safe to have everyone sign the same UserOpHash since we perform a separate check on the chainId and entrypoint based on the calldata in the UserOp. If you try submitting the UserOp, it will only work if the chain and entrypoint match a ExecuteChainInfo or PaymentChainInfo.

So the cost of validateCrossChain would be generating one hash, and then checking 2 signatures for every BridgeWallet involved. IE: If we have a multi-hop UserOp with Alice,Irene,Iris,Bob we'd expect the UserOp to have the following signatures: [AliceSig,IreneSig,IreneSig,IrisSig,IrisSig,BobSig]. We'd generate one hash and check the6 signatures against that hash.

It would also be an easy optimization to omit or skip duplicate signatures so we could get the cost down to generating one hash and checking 1 signature per participant.


With cross-chain execution and payments we need a slightly more complicated check. To prevent replay attacks we ensure that one of the `ExecuteChainInfo` or `PaymentChainInfo` matches our chainId and entrypoint. This ensures that the `UserOp` will only be handled by the chain/entrypoint specified by the `ExecuteChainInfo/PaymentChainInfo`.
lalexgap marked this conversation as resolved.
Show resolved Hide resolved

# Signatures

We expect the UserOp to be signed by the `owner` and `intermediary` of every BridgeWallet involved. So based on the example above with Alice,Irene,Bob, we'd expect the signatures `[AliceSigChainA,IreneSigChainA,IreneSigChainB,BobSigChainB]`.

When validating signatures we iterate through the `ExecuteChainInfo` and `PaymentChainInfo` and check the signatures against the hash generated using the chain id/entrypoint from the ChainInfo. It's important that we validate the signature for other chains, because that guarantees the UserOp is "atomic". Otherwise a partially signed UserOp could be redeemed on one chain, while not being redeemable on the other chain.

# Nonces

We need to be careful with how we use nonces. The `owner` of a BridgeWallet can always submit a UserOp to [trigger a](https://github.com/magmo/Bridge-Wallet/blob/66dbb9c41ea8830218265b4def76824320df6bca/contracts/SCBridgeWallet.sol#L207) `Challenge` or `Reclaim` call. So if we have a UserOp signed by everyone with some `nonce`, the `owner` could submit a UserOp just signed by them burning the nonce, and preventing the UserOp signed by everyone from being handled.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job anticipating this 👏


Luckily ERC 4337 provides ["Semi-abstracted Nonce Support"](https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support) where the first 192 bits of the nonce are treated as a `key` and the last 64 bits are the `sequence`. This means we can use a separate nonce for calls to `crossChain`, preventing the `owner` from burning the nonce and preventing the `crossChain` call.
Loading