diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7cc88f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sol linguist-language=Solidity \ No newline at end of file diff --git a/contracts/SCBridgeWallet.sol b/contracts/SCBridgeWallet.sol index 623831c..8147427 100644 --- a/contracts/SCBridgeWallet.sol +++ b/contracts/SCBridgeWallet.sol @@ -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 { @@ -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"); @@ -125,7 +135,26 @@ contract SCBridgeWallet is IAccount { challengeExpiry = largestTimeLock + CHALLENGE_WAIT; } - 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"); + 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 @@ -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; + } // 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); } @@ -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, @@ -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)); +} diff --git a/contracts/state.sol b/contracts/state.sol index bfa4579..e825714 100644 --- a/contracts/state.sol +++ b/contracts/state.sol @@ -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; } diff --git a/docs/2-hop-rpc.png b/docs/2-hop-rpc.png new file mode 100644 index 0000000..b491712 Binary files /dev/null and b/docs/2-hop-rpc.png differ diff --git a/docs/cross-chain-payments-and-execution.md b/docs/cross-chain-payments-and-execution.md new file mode 100644 index 0000000..b62a0a7 --- /dev/null +++ b/docs/cross-chain-payments-and-execution.md @@ -0,0 +1,124 @@ +## 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 one of many adjudicator contracts (each on a different chain) to claim the funds on that chain based on the outcome of that state. + +However instead of implementing the state channel framework ourselves, we're making use of some of [ERC 4337](https://eips.ethereum.org/EIPS/eip-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 behavior of the `UserOp` will depend on the chain Id and the embedded payment or execution information. + +# Payments + +We embed payment information in the `UserOp` using a `PaymentChainInfo` + +```solidity +// 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 a chain, it forces a challenge on that chain if the `chainId` matches. 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, the `UserOp` need never be submitted on chain -- we would expect participants to gather signatures on the `UserOp` off chain, and then to progress their off chain state to absorb the effects of the `UserOp`. They can then discard the UserOp. + +## Cross-chain Payment Example + +Let's say we have Alice, Bob, and Irene. Alice has a BridgeWallet on chain A with Irene as the intermediary and Bob has a BridgeWallet on Chain B also with Irene as the intermediary. 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. + +1. Alice creates two new unsigned states `[A:4,I:6]` and `[B:6,I:4]` +2. Alice includes them in a UserOp, and signs the UserOp. She sends this to Bob and Irene. +3. Bob and Irene validate the UserOp and also sign it. Bob sends the signed state to Alice and Irene, Irene sends the signed state 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` + +```solidity +/// 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 for paid cross-chain execution. + +# 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. + +## Example with 2 hops +Alice has funds on chainA (blue) with intermediary Irene. Irene has funds on chainB (green) with intermediary Isaac. Isaac has funds on chainC (red). + +Alice wants to execute a transaction on chainC. She crafts the transaction but does not yet sign it. Instead, she routes it to Irene, who appends a payment for herself in the Alice-Irene wallet on chainA. Irene forwards it to Isaac, who appends a payment for himself on the Irene-Isaac wallet on chainB. This completes the chain. Isaac combines the two payments and the transaction into a `UserOp`. This is then countersigned by everyone, forming `UserOp*`. The operation is not valid unless it is completely countersigned. + +Now each party has an effect they want to happen. Isaac wants his payment from Irene. Irene wants her payment from Alice. Alice wants her Tx to be launched on chainC. Each party can force that through by submitting `UserOp*` to the relevant chain (which will cause the relevant wallet to check all the signatures, and slice into the relevant payment to check the chain id). + +As an optimization, the payments can be completed off chain so that the `UserOp*` is never actually submitted anywhere save for the (final) target chain. It can then be discarded. If any party refuses or fails to perform the offchain accounting, their counterparty can eject from the wallet and forcibly extract the payment due to them. + + +![](./2-hop-rpc.png) + +# 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. + +With cross-chain execution and payments we need a slightly more complicated check. To prevent replay attacks we ensure that at least one of the `ExecuteChainInfo` or `PaymentChainInfo` contains the network's chainId and entrypoint. This ensures that the `UserOp` will only be handled by the chain/entrypoint specified by the `ExecuteChainInfo/PaymentChainInfo`. + +# 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 `[AliceSig,IreneSig,IreneSig,BobSig]`. NOTE: This could also be optimized to remove duplicate signatures, but including duplicates keeps things simple and flexible. + +When validating signatures we iterate through the `ExecuteChainInfo` and `PaymentChainInfo` and check the signatures against the hash generated using the **first chain id and entrypoint**. This means all participants are signing the same `UserOpHash`. This is safe to do since the we validate the current chain and entrypoint against the `ExecuteChainInfo`s and `PaymentChainInfo`s in the callData. + +# 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. + +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. diff --git a/test/SCBridgeWallet.test.ts b/test/SCBridgeWallet.test.ts index 26a7819..4f84ae8 100644 --- a/test/SCBridgeWallet.test.ts +++ b/test/SCBridgeWallet.test.ts @@ -13,6 +13,8 @@ import { time } from "@nomicfoundation/hardhat-network-helpers"; import { type UserOperationStruct, type StateStruct, + type ExecuteChainInfoStruct, + type PaymentChainInfoStruct, } from "../typechain-types/contracts/SCBridgeWallet"; import { Participant } from "../clients/StateChannelWallet"; import { type Invoice, MessageType } from "../clients/Messages"; @@ -151,8 +153,6 @@ describe("SCBridgeWallet", function () { entrypoint, ); - // Generate a random payee address that we can use for the transfer. - // Encode calldata that calls the execute function to perform a simple transfer of ether to the payee. const callData = nitroSCW.interface.encodeFunctionData("execute", [ await payeeSCW.getAddress(), @@ -255,6 +255,7 @@ describe("SCBridgeWallet", function () { expect(await nitroSCW.getStatus()).to.equal(2); }); + it("Should handle a challenge and reclaim", async function () { const { nitroSCW, owner, intermediary } = await deploySCBridgeWallet(); const secret = ethers.toUtf8Bytes( @@ -300,7 +301,8 @@ describe("SCBridgeWallet", function () { describe("validateUserOp", function () { it("Should return success if the userOp is signed by the owner and the intermediary", async function () { - const { nitroSCW, owner, intermediary } = await deploySCBridgeWallet(); + const { nitroSCW, owner, intermediary, entrypointAddress } = + await deploySCBridgeWallet(); const n = await ethers.provider.getNetwork(); const userOp: UserOperationStruct = { sender: owner.address, @@ -319,16 +321,16 @@ describe("SCBridgeWallet", function () { const { signature: ownerSig } = signUserOp( userOp, owner, - ethers.ZeroAddress, + entrypointAddress, Number(n.chainId), ); const { signature: intermediarySig } = signUserOp( userOp, intermediary, - ethers.ZeroAddress, + entrypointAddress, Number(n.chainId), ); - const hash = getUserOpHash(userOp, ethers.ZeroAddress, Number(n.chainId)); + const hash = getUserOpHash(userOp, entrypointAddress, Number(n.chainId)); userOp.signature = ethers.concat([ownerSig, intermediarySig]); @@ -339,12 +341,13 @@ describe("SCBridgeWallet", function () { expect(result).to.equal(0); }); it("allows specific functions to be called when signed by one actor", async function () { - const { nitroSCW, owner } = await deploySCBridgeWallet(); + const { nitroSCW, owner, entrypointAddress } = + await deploySCBridgeWallet(); const n = await ethers.provider.getNetwork(); const userOp: UserOperationStruct = { sender: owner.address, - nonce: 0, + nonce: BigInt(1) << BigInt(64), initCode: hre.ethers.ZeroHash, callData: SCBridgeWallet__factory.createInterface().encodeFunctionData( @@ -362,11 +365,11 @@ describe("SCBridgeWallet", function () { const { signature: ownerSig } = signUserOp( userOp, owner, - ethers.ZeroAddress, + entrypointAddress, Number(n.chainId), ); - const hash = getUserOpHash(userOp, ethers.ZeroAddress, Number(n.chainId)); + const hash = getUserOpHash(userOp, entrypointAddress, Number(n.chainId)); userOp.signature = ethers.zeroPadBytes(ownerSig, 130); // staticCall forces an eth_call, allowing us to easily check the result @@ -376,6 +379,200 @@ describe("SCBridgeWallet", function () { expect(result).to.equal(0); }); }); + + describe("crossChain", function () { + it("Should raise a challenge via crossChain call", async function () { + const { owner, intermediary, nitroSCW, entrypoint, entrypointAddress } = + await deploySCBridgeWallet(); + + const n = await ethers.provider.getNetwork(); + + const deployer = await hre.ethers.getContractFactory("SCBridgeWallet"); + + const payeeSCW = await deployer.deploy( + ethers.Wallet.createRandom(), + intermediary, + entrypoint, + ); + + const state: StateStruct = { + owner: owner.address, + intermediary: intermediary.address, + turnNum: 1, + intermediaryBalance: 0, + htlcs: [ + { + amount: 0, + to: Participant.Intermediary, + hashLock: ethers.keccak256(ethers.toUtf8Bytes("secret")), + timelock: (await getBlockTimestamp()) + TIMELOCK_DELAY, + }, + ], + }; + + const executeInfo: ExecuteChainInfoStruct = { + chainId: 0, + entrypoint: entrypointAddress, + dest: await payeeSCW.getAddress(), + value: ethers.parseEther("0.5"), + callData: "0x", + owner: owner.address, + intermediary: intermediary.address, + }; + + const paymentInfo: PaymentChainInfoStruct = { + chainId: n.chainId, + entrypoint: entrypointAddress, + paymentState: state, + }; + + const callData = nitroSCW.interface.encodeFunctionData("crossChain", [ + [executeInfo], + [paymentInfo], + ]); + + const userOp: UserOperationStruct = { + sender: await nitroSCW.getAddress(), + nonce: 0, + initCode: "0x", + callData, + callGasLimit: 500_000, + verificationGasLimit: 150000, + preVerificationGas: 21000, + maxFeePerGas: 40_000, + maxPriorityFeePerGas: 40_000, + paymasterAndData: hre.ethers.ZeroHash, + signature: hre.ethers.ZeroHash, + }; + + const { signature: ownerSig } = signUserOp( + userOp, + owner, + await entrypoint.getAddress(), + Number(n.chainId), + ); + const { signature: intermediarySig } = signUserOp( + userOp, + intermediary, + await entrypoint.getAddress(), + Number(n.chainId), + ); + + // Cross chain execute requires that each userOp is signed by the owner and the intermediary on each chain + userOp.signature = ethers.concat([ + ownerSig, + intermediarySig, + ownerSig, + intermediarySig, + ]); + + // Submit the userOp to the entrypoint and wait for it to be mined. + const res = await entrypoint.handleOps([userOp], owner.address); + await res.wait(); + + // Check that the the status is now challenged + expect(await nitroSCW.getStatus()).to.equal(1); + }); + + it("Should execute a function call via crossChain call", async function () { + const { owner, intermediary, nitroSCW, entrypoint, entrypointAddress } = + await deploySCBridgeWallet(); + + const n = await ethers.provider.getNetwork(); + + const deployer = await hre.ethers.getContractFactory("SCBridgeWallet"); + + const payeeSCW = await deployer.deploy( + ethers.Wallet.createRandom(), + intermediary, + entrypoint, + ); + + const state: StateStruct = { + owner: owner.address, + intermediary: intermediary.address, + turnNum: 1, + intermediaryBalance: 0, + htlcs: [ + { + amount: 0, + to: Participant.Intermediary, + hashLock: ethers.keccak256(ethers.toUtf8Bytes("secret")), + timelock: (await getBlockTimestamp()) + TIMELOCK_DELAY, + }, + ], + }; + + const executeInfo: ExecuteChainInfoStruct = { + chainId: n.chainId, + entrypoint: entrypointAddress, + dest: await payeeSCW.getAddress(), + value: ethers.parseEther("0.5"), + callData: "0x", + owner: owner.address, + intermediary: intermediary.address, + }; + + const paymentInfo: PaymentChainInfoStruct = { + chainId: 0, + entrypoint: entrypointAddress, + paymentState: state, + }; + + const callData = nitroSCW.interface.encodeFunctionData("crossChain", [ + [executeInfo], + [paymentInfo], + ]); + + const userOp: UserOperationStruct = { + sender: await nitroSCW.getAddress(), + nonce: 0, + initCode: "0x", + callData, + callGasLimit: 500_000, + verificationGasLimit: 150000, + preVerificationGas: 21000, + maxFeePerGas: 40_000, + maxPriorityFeePerGas: 40_000, + paymasterAndData: hre.ethers.ZeroHash, + signature: hre.ethers.ZeroHash, + }; + + const { signature: ownerSig } = signUserOp( + userOp, + owner, + await entrypoint.getAddress(), + Number(n.chainId), + ); + const { signature: intermediarySig } = signUserOp( + userOp, + intermediary, + await entrypoint.getAddress(), + Number(n.chainId), + ); + + // Cross chain execute requires that each userOp is signed by the owner and the intermediary on each chain + userOp.signature = ethers.concat([ + ownerSig, + intermediarySig, + ownerSig, + intermediarySig, + ]); + + // Submit the userOp to the entrypoint and wait for it to be mined. + const res = await entrypoint.handleOps([userOp], owner.address); + await res.wait(); + + // Check that the the status still open + expect(await nitroSCW.getStatus()).to.equal(0); + + // Check that the transfer executed.. + const balance = await hre.ethers.provider.getBalance( + await payeeSCW.getAddress(), + ); + expect(balance).to.equal(ethers.parseEther("0.5")); + }); + }); }); describe("invoice conversions", () => {