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 all 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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sol linguist-language=Solidity
130 changes: 120 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);
_challenge(state);
}

function _challenge(State calldata state) internal {
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) {
_challenge(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 validate using the chainids and entrypoints from the calldata
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,67 @@ 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"
);
// We expect every participant to sign the UserOpHash generated against the first entrypoint and chainId
bytes32 userOpHash;
require(e.length + p.length > 0, "Must target at least one chain");
if (e.length > 0) {
userOpHash = generateUserOpHash(userOp, e[0].entrypoint, e[0].chainId);
} else {
userOpHash = generateUserOpHash(userOp, p[0].entrypoint, p[0].chainId);
}

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

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;
}

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 +315,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;
}
Binary file added docs/2-hop-rpc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading