diff --git a/examples/ethers/interop/contracts/Greeting.sol b/examples/ethers/interop/contracts/Greeting.sol
new file mode 100644
index 0000000..fba9e15
--- /dev/null
+++ b/examples/ethers/interop/contracts/Greeting.sol
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+contract Greeting {
+ string public message;
+ bytes public lastSender;
+
+ constructor() {
+ message = "initialized";
+ lastSender = "";
+ }
+
+ // ERC-7930 receiveMessage function
+ // Receive messages coming from other chains.
+ function receiveMessage(
+ bytes32, // Unique identifier
+ bytes calldata sender, // ERC-7930 address
+ bytes calldata payload
+ ) external payable returns (bytes4) {
+ // Check that it is coming from a trusted caller - interop handler.
+ require(
+ msg.sender == address(0x000000000000000000000000000000000001000E),
+ "message must come from interop handler"
+ );
+
+ // Decode the payload to extract the greeting message
+
+ string memory newMessage = abi.decode(payload, (string));
+ message = newMessage;
+ lastSender = sender;
+
+ // Return the function selector to acknowledge receipt
+ return this.receiveMessage.selector;
+ }
+}
diff --git a/examples/ethers/interop/erc20-transfer.ts b/examples/ethers/interop/erc20-transfer.ts
new file mode 100644
index 0000000..a41d230
--- /dev/null
+++ b/examples/ethers/interop/erc20-transfer.ts
@@ -0,0 +1,106 @@
+import { Contract, JsonRpcProvider, Wallet, parseUnits, formatUnits } from 'ethers';
+import { createEthersClient, createEthersSdk } from '../../../src/adapters/ethers';
+import { type Address, type Hex } from '../../../src/core';
+import { FORMAL_ETH_ADDRESS, L2_NATIVE_TOKEN_VAULT_ADDRESS } from '../../../src/core/constants';
+import { IERC20ABI, L2NativeTokenVaultABI } from '../../../src/core/abi';
+import { getErc20TokenAddress } from './utils';
+
+const L1_RPC = process.env.L1_RPC ?? 'http://127.0.0.1:8545';
+const SRC_L2_RPC = process.env.SRC_L2_RPC ?? 'http://127.0.0.1:3050';
+const DST_L2_RPC = process.env.DST_L2_RPC ?? 'http://127.0.0.1:3051';
+const PRIVATE_KEY = process.env.PRIVATE_KEY;
+const AMOUNT_RAW = process.env.AMOUNT ?? '100';
+
+async function main() {
+ if (!PRIVATE_KEY) throw new Error('Set PRIVATE_KEY in env');
+
+ const l1 = new JsonRpcProvider(L1_RPC);
+ const l2Source = new JsonRpcProvider(SRC_L2_RPC);
+ const l2Destination = new JsonRpcProvider(DST_L2_RPC);
+
+ const client = await createEthersClient({
+ l1,
+ l2: l2Source,
+ signer: new Wallet(PRIVATE_KEY),
+ });
+ const sdk = createEthersSdk(client);
+
+ const walletA = new Wallet(PRIVATE_KEY, l2Source);
+ const walletB = new Wallet(PRIVATE_KEY, l2Destination);
+ const me = (await walletA.getAddress()) as Address;
+ const recipientOnDst = walletB.address as Address;
+
+ const amountToSend = parseUnits(AMOUNT_RAW, 18);
+
+ console.log('Sender address:', me);
+
+ // ---- Deploy ERC20 token on source chain ----
+ console.log('=== DEPLOYING ERC20 TOKEN ===');
+ const tokenAAddress = await getErc20TokenAddress({ signer: walletA });
+ console.log('Token deployed at:', tokenAAddress);
+ console.log('Token registration will be handled by the SDK');
+
+ const tokenA = new Contract(tokenAAddress, IERC20ABI, l2Source);
+ const balanceA = await tokenA.balanceOf(walletA.address);
+ console.log('WalletA token balance:', formatUnits(balanceA, 18), 'TEST');
+
+ const params = {
+ dstChain: l2Destination,
+ actions: [
+ {
+ type: 'sendErc20' as const,
+ token: tokenAAddress,
+ to: recipientOnDst,
+ amount: amountToSend,
+ },
+ ],
+ unbundling: { by: recipientOnDst },
+ };
+
+ // QUOTE: Build and return the summary.
+ const quote = await sdk.interop.quote(params);
+ console.log('QUOTE:', quote);
+
+ // PREPARE: Build plan without executing.
+ const prepared = await sdk.interop.prepare(params);
+ console.log('PREPARE:', prepared);
+
+ // CREATE: Execute the source-chain step(s), wait for each tx receipt to confirm (status != 0).
+ const created = await sdk.interop.create(params);
+ console.log('CREATE:', created);
+
+ // WAIT: Waits until the L2->L1 proof is available on source and the interop root
+ // becomes available on the destination chain. It returns the proof payload needed
+ // to execute the bundle later.
+ const finalizationInfo = await sdk.interop.wait(created, {
+ pollMs: 5_000,
+ timeoutMs: 30 * 60 * 1_000,
+ });
+ console.log('Bundle is finalized on source; root available on destination.');
+
+ // FINALIZE: Execute on destination and block until done.
+ // finalize() calls executeBundle(...) on the destination chain,
+ // waits for the tx to mine, then returns { bundleHash, dstExecTxHash }.
+ const finalizationResult = await sdk.interop.finalize(finalizationInfo);
+ console.log('FINALIZE RESULT:', finalizationResult);
+
+ const assetId = (await sdk.tokens.assetIdOfL2(tokenAAddress)) as Hex;
+ console.log('Asset ID:', assetId);
+
+ const ntvDst = new Contract(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NativeTokenVaultABI, l2Destination);
+ const tokenBAddress = (await ntvDst.tokenAddress(assetId)) as Address;
+
+ if (tokenBAddress === FORMAL_ETH_ADDRESS) {
+ console.log('Token is not registered on destination yet.');
+ } else {
+ const tokenB = new Contract(tokenBAddress, IERC20ABI, l2Destination);
+ const balanceB = await tokenB.balanceOf(recipientOnDst);
+ console.log('Destination token address:', tokenBAddress);
+ console.log('Destination balance:', balanceB.toString());
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/examples/ethers/interop/remote-call.ts b/examples/ethers/interop/remote-call.ts
new file mode 100644
index 0000000..976bbd3
--- /dev/null
+++ b/examples/ethers/interop/remote-call.ts
@@ -0,0 +1,98 @@
+import { AbiCoder, Contract, JsonRpcProvider, Wallet } from 'ethers';
+import { createEthersClient, createEthersSdk } from '../../../src/adapters/ethers';
+import { getGreetingTokenAddress } from './utils';
+
+const L1_RPC = process.env.L1_RPC ?? 'http://127.0.0.1:8545';
+const SRC_L2_RPC = process.env.SRC_L2_RPC ?? 'http://127.0.0.1:3050';
+const DST_L2_RPC = process.env.DST_L2_RPC ?? 'http://127.0.0.1:3051';
+const PRIVATE_KEY = process.env.PRIVATE_KEY;
+
+const GREETING_ABI = ['function message() view returns (string)'] as const;
+
+async function main() {
+ if (!PRIVATE_KEY) throw new Error('Set your PRIVATE_KEY in env');
+
+ const l1 = new JsonRpcProvider(L1_RPC);
+ const l2Source = new JsonRpcProvider(SRC_L2_RPC);
+ const l2Destination = new JsonRpcProvider(DST_L2_RPC);
+
+ const signer = new Wallet(PRIVATE_KEY, l2Source);
+ const client = await createEthersClient({
+ l1,
+ l2: l2Source,
+ signer,
+ });
+ const sdk = createEthersSdk(client);
+ const dstSigner = new Wallet(PRIVATE_KEY, l2Destination);
+
+ // ---- Deploy Greeter on destination ----
+ console.log('=== DEPLOYING GREETER ON DESTINATION ===');
+ const initialGreeting = 'hello from destination';
+ const greeterAddress = await getGreetingTokenAddress({
+ signer: dstSigner,
+ greeting: initialGreeting,
+ });
+ console.log('Greeter deployed at:', greeterAddress);
+
+ const greeter = new Contract(greeterAddress, GREETING_ABI, l2Destination);
+ const greetingBefore = (await greeter.message()) as string;
+ console.log('Greeting before:', greetingBefore);
+ const newGreeting = 'hello from example!';
+ const data = AbiCoder.defaultAbiCoder().encode(['string'], [newGreeting]) as `0x${string}`;
+
+ const params = {
+ dstChain: l2Destination,
+ actions: [
+ {
+ type: 'call' as const,
+ to: greeterAddress,
+ data: data,
+ },
+ ],
+ // Optional bundle-level execution constraints:
+ // execution: { only: someExecAddress },
+ // unbundling: { by: someUnbundlerAddress },
+ };
+
+ // QUOTE: Build and return the summary.
+ const quote = await sdk.interop.quote(params);
+ console.log('QUOTE:', quote);
+
+ // PREPARE: Build plan without executing.
+ const prepared = await sdk.interop.prepare(params);
+ console.log('PREPARE:', prepared);
+
+ // CREATE: Execute the source-chain step(s), wait for each tx receipt to confirm (status != 0).
+ const created = await sdk.interop.create(params);
+ console.log('CREATE:', created);
+
+ // STATUS: Non-blocking lifecycle inspection.
+ const st0 = await sdk.interop.status(created);
+ console.log('STATUS after create:', st0);
+
+ // WAIT: waits until the L2->L1 proof is available on source and the interop root
+ // becomes available on the destination chain. It returns the proof payload needed
+ // to execute the bundle later.
+ const finalizationInfo = await sdk.interop.wait(created, {
+ pollMs: 5_000,
+ timeoutMs: 30 * 60 * 1_000,
+ });
+ console.log('Bundle is finalized on source; root available on destination.');
+ // FINALIZE: Execute on destination and block until done.
+ // finalize() calls executeBundle(...) on the destination chain,
+ // waits for the tx to mine, then returns { bundleHash, dstExecTxHash }.
+ const finalizationResult = await sdk.interop.finalize(finalizationInfo);
+ console.log('FINALIZE RESULT:', finalizationResult);
+
+ // STATUS: Terminal status (EXECUTED).
+ const st1 = await sdk.interop.status(created);
+ console.log('STATUS after finalize:', st1);
+
+ const greetingAfter = (await greeter.message()) as string;
+ console.log('Greeting after:', greetingAfter);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/ethers/interop/utils.ts b/examples/ethers/interop/utils.ts
new file mode 100644
index 0000000..d0dfecb
--- /dev/null
+++ b/examples/ethers/interop/utils.ts
@@ -0,0 +1,38 @@
+import { AbiCoder, Wallet, parseUnits } from 'ethers';
+import type { Address } from '../../../src/core';
+import { ERC20_BYTECODE, GREETING_BYTECODE } from '../../interop/constants';
+
+export async function getGreetingTokenAddress(args: {
+ signer: Wallet;
+ greeting?: string;
+}): Promise
{
+ const greeting = args.greeting ?? 'hello from destination';
+ const constructorArgs = AbiCoder.defaultAbiCoder().encode(['string'], [greeting]);
+ const deployData = GREETING_BYTECODE + constructorArgs.substring(2);
+
+ const deployTx = await args.signer.sendTransaction({ data: deployData });
+ const deployReceipt = await deployTx.wait();
+ if (!deployReceipt?.contractAddress) {
+ throw new Error('Greeting contract deployment failed: missing contract address.');
+ }
+
+ return deployReceipt.contractAddress as Address;
+}
+
+export async function getErc20TokenAddress(args: {
+ signer: Wallet;
+ initialSupply?: bigint;
+}): Promise {
+ const initialSupply = args.initialSupply ?? parseUnits('1000000', 18);
+ const constructorArgs = AbiCoder.defaultAbiCoder().encode(['uint256'], [initialSupply]);
+ const deployData = ERC20_BYTECODE + constructorArgs.substring(2);
+
+ const deployTx = await args.signer.sendTransaction({ data: deployData });
+ const deployReceipt = await deployTx.wait();
+ if (!deployReceipt?.contractAddress) {
+ throw new Error('ERC20 deployment failed: missing contract address.');
+ }
+ const tokenAddress = deployReceipt.contractAddress as Address;
+
+ return tokenAddress;
+}
diff --git a/examples/interop/constants.ts b/examples/interop/constants.ts
new file mode 100644
index 0000000..1e04890
--- /dev/null
+++ b/examples/interop/constants.ts
@@ -0,0 +1,7 @@
+// SimpleERC20.sol bytecode
+export const ERC20_BYTECODE =
+ '0x60806040526040518060400160405280600a81526020017f5465737420546f6b656e000000000000000000000000000000000000000000008152505f9081620000499190620003f9565b506040518060400160405280600481526020017f544553540000000000000000000000000000000000000000000000000000000081525060019081620000909190620003f9565b50601260025f6101000a81548160ff021916908360ff160217905550348015620000b8575f80fd5b50604051620012f5380380620012f58339818101604052810190620000de919062000510565b806003819055508060045f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20819055503373ffffffffffffffffffffffffffffffffffffffff165f73ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405162000186919062000551565b60405180910390a3506200056c565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f60028204905060018216806200021157607f821691505b602082108103620002275762000226620001cc565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026200028b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff826200024e565b6200029786836200024e565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f620002e1620002db620002d584620002af565b620002b8565b620002af565b9050919050565b5f819050919050565b620002fc83620002c1565b620003146200030b82620002e8565b8484546200025a565b825550505050565b5f90565b6200032a6200031c565b62000337818484620002f1565b505050565b5b818110156200035e57620003525f8262000320565b6001810190506200033d565b5050565b601f821115620003ad5762000377816200022d565b62000382846200023f565b8101602085101562000392578190505b620003aa620003a1856200023f565b8301826200033c565b50505b505050565b5f82821c905092915050565b5f620003cf5f1984600802620003b2565b1980831691505092915050565b5f620003e98383620003be565b9150826002028217905092915050565b620004048262000195565b67ffffffffffffffff81111562000420576200041f6200019f565b5b6200042c8254620001f9565b6200043982828562000362565b5f60209050601f8311600181146200046f575f84156200045a578287015190505b620004668582620003dc565b865550620004d5565b601f1984166200047f866200022d565b5f5b82811015620004a85784890151825560018201915060208501945060208101905062000481565b86831015620004c85784890151620004c4601f891682620003be565b8355505b6001600288020188555050505b505050505050565b5f80fd5b620004ec81620002af565b8114620004f7575f80fd5b50565b5f815190506200050a81620004e1565b92915050565b5f60208284031215620005285762000527620004dd565b5b5f6200053784828501620004fa565b91505092915050565b6200054b81620002af565b82525050565b5f602082019050620005665f83018462000540565b92915050565b610d7b806200057a5f395ff3fe608060405234801561000f575f80fd5b5060043610610091575f3560e01c8063313ce56711610064578063313ce5671461013157806370a082311461014f57806395d89b411461017f578063a9059cbb1461019d578063dd62ed3e146101cd57610091565b806306fdde0314610095578063095ea7b3146100b357806318160ddd146100e357806323b872dd14610101575b5f80fd5b61009d6101fd565b6040516100aa919061094e565b60405180910390f35b6100cd60048036038101906100c891906109ff565b610288565b6040516100da9190610a57565b60405180910390f35b6100eb610375565b6040516100f89190610a7f565b60405180910390f35b61011b60048036038101906101169190610a98565b61037b565b6040516101289190610a57565b60405180910390f35b61013961065b565b6040516101469190610b03565b60405180910390f35b61016960048036038101906101649190610b1c565b61066d565b6040516101769190610a7f565b60405180910390f35b610187610682565b604051610194919061094e565b60405180910390f35b6101b760048036038101906101b291906109ff565b61070e565b6040516101c49190610a57565b60405180910390f35b6101e760048036038101906101e29190610b47565b6108a4565b6040516101f49190610a7f565b60405180910390f35b5f805461020990610bb2565b80601f016020809104026020016040519081016040528092919081815260200182805461023590610bb2565b80156102805780601f1061025757610100808354040283529160200191610280565b820191905f5260205f20905b81548152906001019060200180831161026357829003601f168201915b505050505081565b5f8160055f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040516103639190610a7f565b60405180910390a36001905092915050565b60035481565b5f8160045f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205410156103fc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016103f390610c2c565b60405180910390fd5b8160055f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205410156104b7576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016104ae90610c94565b60405180910390fd5b8160045f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546105039190610cdf565b925050819055508160045f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546105569190610d12565b925050819055508160055f8673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546105e49190610cdf565b925050819055508273ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040516106489190610a7f565b60405180910390a3600190509392505050565b60025f9054906101000a900460ff1681565b6004602052805f5260405f205f915090505481565b6001805461068f90610bb2565b80601f01602080910402602001604051908101604052809291908181526020018280546106bb90610bb2565b80156107065780601f106106dd57610100808354040283529160200191610706565b820191905f5260205f20905b8154815290600101906020018083116106e957829003601f168201915b505050505081565b5f8160045f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2054101561078f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161078690610c2c565b60405180910390fd5b8160045f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546107db9190610cdf565b925050819055508160045f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f82825461082e9190610d12565b925050819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040516108929190610a7f565b60405180910390a36001905092915050565b6005602052815f5260405f20602052805f5260405f205f91509150505481565b5f81519050919050565b5f82825260208201905092915050565b5f5b838110156108fb5780820151818401526020810190506108e0565b5f8484015250505050565b5f601f19601f8301169050919050565b5f610920826108c4565b61092a81856108ce565b935061093a8185602086016108de565b61094381610906565b840191505092915050565b5f6020820190508181035f8301526109668184610916565b905092915050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61099b82610972565b9050919050565b6109ab81610991565b81146109b5575f80fd5b50565b5f813590506109c6816109a2565b92915050565b5f819050919050565b6109de816109cc565b81146109e8575f80fd5b50565b5f813590506109f9816109d5565b92915050565b5f8060408385031215610a1557610a1461096e565b5b5f610a22858286016109b8565b9250506020610a33858286016109eb565b9150509250929050565b5f8115159050919050565b610a5181610a3d565b82525050565b5f602082019050610a6a5f830184610a48565b92915050565b610a79816109cc565b82525050565b5f602082019050610a925f830184610a70565b92915050565b5f805f60608486031215610aaf57610aae61096e565b5b5f610abc868287016109b8565b9350506020610acd868287016109b8565b9250506040610ade868287016109eb565b9150509250925092565b5f60ff82169050919050565b610afd81610ae8565b82525050565b5f602082019050610b165f830184610af4565b92915050565b5f60208284031215610b3157610b3061096e565b5b5f610b3e848285016109b8565b91505092915050565b5f8060408385031215610b5d57610b5c61096e565b5b5f610b6a858286016109b8565b9250506020610b7b858286016109b8565b9150509250929050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680610bc957607f821691505b602082108103610bdc57610bdc610b85565b5b50919050565b7f496e73756666696369656e742062616c616e63650000000000000000000000005f82015250565b5f610c166014836108ce565b9150610c2182610be2565b602082019050919050565b5f6020820190508181035f830152610c4381610c0a565b9050919050565b7f496e73756666696369656e7420616c6c6f77616e6365000000000000000000005f82015250565b5f610c7e6016836108ce565b9150610c8982610c4a565b602082019050919050565b5f6020820190508181035f830152610cab81610c72565b9050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610ce9826109cc565b9150610cf4836109cc565b9250828203905081811115610d0c57610d0b610cb2565b5b92915050565b5f610d1c826109cc565b9150610d27836109cc565b9250828201905080821115610d3f57610d3e610cb2565b5b9291505056fea26469706673582212208b562ac4f0f974b2ee612ecf1be3e3c4caa136b06cc2b96ce39f3a0a66c1b9b664736f6c63430008140033';
+
+// contracts/Greeting.sol bytecode
+export const GREETING_BYTECODE =
+ '0x608060405234801562000010575f80fd5b506040518060400160405280600b81526020017f696e697469616c697a65640000000000000000000000000000000000000000008152505f9081620000569190620002e1565b5060405180602001604052805f8152506001908162000076919062000427565b506200050b565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680620000f957607f821691505b6020821081036200010f576200010e620000b4565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f60088302620001737fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8262000136565b6200017f868362000136565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f620001c9620001c3620001bd8462000197565b620001a0565b62000197565b9050919050565b5f819050919050565b620001e483620001a9565b620001fc620001f382620001d0565b84845462000142565b825550505050565b5f90565b6200021262000204565b6200021f818484620001d9565b505050565b5b8181101562000246576200023a5f8262000208565b60018101905062000225565b5050565b601f82111562000295576200025f8162000115565b6200026a8462000127565b810160208510156200027a578190505b62000292620002898562000127565b83018262000224565b50505b505050565b5f82821c905092915050565b5f620002b75f19846008026200029a565b1980831691505092915050565b5f620002d18383620002a6565b9150826002028217905092915050565b620002ec826200007d565b67ffffffffffffffff81111562000308576200030762000087565b5b620003148254620000e1565b620003218282856200024a565b5f60209050601f83116001811462000357575f841562000342578287015190505b6200034e8582620002c4565b865550620003bd565b601f198416620003678662000115565b5f5b82811015620003905784890151825560018201915060208501945060208101905062000369565b86831015620003b05784890151620003ac601f891682620002a6565b8355505b6001600288020188555050505b505050505050565b5f819050815f5260205f209050919050565b601f8211156200042257620003ec81620003c5565b620003f78462000127565b8101602085101562000407578190505b6200041f620004168562000127565b83018262000224565b50505b505050565b62000432826200007d565b67ffffffffffffffff8111156200044e576200044d62000087565b5b6200045a8254620000e1565b62000467828285620003d7565b5f60209050601f8311600181146200049d575f841562000488578287015190505b620004948582620002c4565b86555062000503565b601f198416620004ad86620003c5565b5f5b82811015620004d657848901518255600182019150602085019450602081019050620004af565b86831015620004f65784890151620004f2601f891682620002a6565b8355505b6001600288020188555050505b505050505050565b610b6480620005195f395ff3fe608060405260043610610033575f3560e01c80632432ef2614610037578063256fec8814610067578063e21f37ce14610091575b5f80fd5b610051600480360381019061004c9190610330565b6100bb565b60405161005e91906103fb565b60405180910390f35b348015610072575f80fd5b5061007b610174565b604051610088919061049e565b60405180910390f35b34801561009c575f80fd5b506100a5610200565b6040516100b29190610510565b60405180910390f35b5f6201000e73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161461012d576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610124906105a0565b60405180910390fd5b5f838381019061013d91906106e6565b9050805f908161014d9190610930565b5085856001918261015f929190610a61565b50632432ef2660e01b91505095945050505050565b600180546101819061075a565b80601f01602080910402602001604051908101604052809291908181526020018280546101ad9061075a565b80156101f85780601f106101cf576101008083540402835291602001916101f8565b820191905f5260205f20905b8154815290600101906020018083116101db57829003601f168201915b505050505081565b5f805461020c9061075a565b80601f01602080910402602001604051908101604052809291908181526020018280546102389061075a565b80156102835780601f1061025a57610100808354040283529160200191610283565b820191905f5260205f20905b81548152906001019060200180831161026657829003601f168201915b505050505081565b5f604051905090565b5f80fd5b5f80fd5b5f819050919050565b6102ae8161029c565b81146102b8575f80fd5b50565b5f813590506102c9816102a5565b92915050565b5f80fd5b5f80fd5b5f80fd5b5f8083601f8401126102f0576102ef6102cf565b5b8235905067ffffffffffffffff81111561030d5761030c6102d3565b5b602083019150836001820283011115610329576103286102d7565b5b9250929050565b5f805f805f6060868803121561034957610348610294565b5b5f610356888289016102bb565b955050602086013567ffffffffffffffff81111561037757610376610298565b5b610383888289016102db565b9450945050604086013567ffffffffffffffff8111156103a6576103a5610298565b5b6103b2888289016102db565b92509250509295509295909350565b5f7fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6103f5816103c1565b82525050565b5f60208201905061040e5f8301846103ec565b92915050565b5f81519050919050565b5f82825260208201905092915050565b5f5b8381101561044b578082015181840152602081019050610430565b5f8484015250505050565b5f601f19601f8301169050919050565b5f61047082610414565b61047a818561041e565b935061048a81856020860161042e565b61049381610456565b840191505092915050565b5f6020820190508181035f8301526104b68184610466565b905092915050565b5f81519050919050565b5f82825260208201905092915050565b5f6104e2826104be565b6104ec81856104c8565b93506104fc81856020860161042e565b61050581610456565b840191505092915050565b5f6020820190508181035f83015261052881846104d8565b905092915050565b7f6d657373616765206d75737420636f6d652066726f6d20696e7465726f7020685f8201527f616e646c65720000000000000000000000000000000000000000000000000000602082015250565b5f61058a6026836104c8565b915061059582610530565b604082019050919050565b5f6020820190508181035f8301526105b78161057e565b9050919050565b5f80fd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b6105f882610456565b810181811067ffffffffffffffff82111715610617576106166105c2565b5b80604052505050565b5f61062961028b565b905061063582826105ef565b919050565b5f67ffffffffffffffff821115610654576106536105c2565b5b61065d82610456565b9050602081019050919050565b828183375f83830152505050565b5f61068a6106858461063a565b610620565b9050828152602081018484840111156106a6576106a56105be565b5b6106b184828561066a565b509392505050565b5f82601f8301126106cd576106cc6102cf565b5b81356106dd848260208601610678565b91505092915050565b5f602082840312156106fb576106fa610294565b5b5f82013567ffffffffffffffff81111561071857610717610298565b5b610724848285016106b9565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f600282049050600182168061077157607f821691505b6020821081036107845761078361072d565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026107e67fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff826107ab565b6107f086836107ab565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f61083461082f61082a84610808565b610811565b610808565b9050919050565b5f819050919050565b61084d8361081a565b6108616108598261083b565b8484546107b7565b825550505050565b5f90565b610875610869565b610880818484610844565b505050565b5b818110156108a3576108985f8261086d565b600181019050610886565b5050565b601f8211156108e8576108b98161078a565b6108c28461079c565b810160208510156108d1578190505b6108e56108dd8561079c565b830182610885565b50505b505050565b5f82821c905092915050565b5f6109085f19846008026108ed565b1980831691505092915050565b5f61092083836108f9565b9150826002028217905092915050565b610939826104be565b67ffffffffffffffff811115610952576109516105c2565b5b61095c825461075a565b6109678282856108a7565b5f60209050601f831160018114610998575f8415610986578287015190505b6109908582610915565b8655506109f7565b601f1984166109a68661078a565b5f5b828110156109cd578489015182556001820191506020850194506020810190506109a8565b868310156109ea57848901516109e6601f8916826108f9565b8355505b6001600288020188555050505b505050505050565b5f82905092915050565b5f819050815f5260205f209050919050565b601f821115610a5c57610a2d81610a09565b610a368461079c565b81016020851015610a45578190505b610a59610a518561079c565b830182610885565b50505b505050565b610a6b83836109ff565b67ffffffffffffffff811115610a8457610a836105c2565b5b610a8e825461075a565b610a99828285610a1b565b5f601f831160018114610ac6575f8415610ab4578287013590505b610abe8582610915565b865550610b25565b601f198416610ad486610a09565b5f5b82811015610afb57848901358255600182019150602085019450602081019050610ad6565b86831015610b185784890135610b14601f8916826108f9565b8355505b6001600288020188555050505b5050505050505056fea2646970667358221220793eb0c3b200d08e80eb160085028c79a9562e7c82a9b8bfcf8d76983380865c64736f6c63430008180033';
diff --git a/src/adapters/__tests__/adapter-harness.ts b/src/adapters/__tests__/adapter-harness.ts
index 2da79bf..bf2f2b3 100644
--- a/src/adapters/__tests__/adapter-harness.ts
+++ b/src/adapters/__tests__/adapter-harness.ts
@@ -27,6 +27,9 @@ import {
L2_ASSET_ROUTER_ADDRESS,
L2_NATIVE_TOKEN_VAULT_ADDRESS,
L2_BASE_TOKEN_ADDRESS,
+ L2_INTEROP_CENTER_ADDRESS,
+ L2_INTEROP_HANDLER_ADDRESS,
+ L2_MESSAGE_VERIFICATION_ADDRESS,
} from '../../core/constants';
import { isBigint } from '../../core/utils';
@@ -35,6 +38,9 @@ const IL1AssetRouter = new Interface(IL1AssetRouterABI as any);
const IL1Nullifier = new Interface(IL1NullifierABI as any);
const IERC20 = new Interface(IERC20ABI as any);
const L2NativeTokenVault = new Interface(L2NativeTokenVaultABI as any);
+const IChainTypeManager = new Interface([
+ 'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
+]);
const lower = (value: string) => value.toLowerCase();
type ResultValue = unknown | unknown[];
@@ -74,6 +80,7 @@ export const ADAPTER_TEST_ADDRESSES = {
l1Nullifier: '0xc000000000000000000000000000000000000000' as Address,
l1NativeTokenVault: '0xd000000000000000000000000000000000000000' as Address,
baseTokenFor324: '0xbee0000000000000000000000000000000000000' as Address,
+ chainTypeManager: '0xe000000000000000000000000000000000000000' as Address,
signer: '0x1111111111111111111111111111111111111111' as Address,
} as const;
@@ -362,6 +369,26 @@ function seedDefaults(registry: CallRegistry, baseToken: Address) {
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
);
registry.set(ADAPTER_TEST_ADDRESSES.bridgehub, IBridgehub, 'baseToken', baseToken, [324n]);
+ registry.set(
+ ADAPTER_TEST_ADDRESSES.bridgehub,
+ IBridgehub,
+ 'chainTypeManager',
+ ADAPTER_TEST_ADDRESSES.chainTypeManager,
+ [324n],
+ );
+ registry.set(
+ ADAPTER_TEST_ADDRESSES.bridgehub,
+ IBridgehub,
+ 'chainTypeManager',
+ ADAPTER_TEST_ADDRESSES.chainTypeManager,
+ [325n],
+ );
+ registry.set(
+ ADAPTER_TEST_ADDRESSES.chainTypeManager,
+ IChainTypeManager,
+ 'getSemverProtocolVersion',
+ [0n, 31n, 0n],
+ );
}
export function createEthersHarness(opts: BaseOpts = {}): EthersHarness {
@@ -571,6 +598,56 @@ export function makeWithdrawalContext(
} as WithdrawalTestContext;
}
+export type InteropTestContext = {
+ client: T['client'];
+ contracts: T extends { kind: 'ethers' } ? EthersContractsResource : ViemContractsResource;
+ sender: Address;
+ chainId: bigint;
+ dstChainId: bigint;
+ bridgehub: Address;
+ interopCenter: Address;
+ interopHandler: Address;
+ l2MessageVerification: Address;
+ l2AssetRouter: Address;
+ l2NativeTokenVault: Address;
+ baseTokens: { src: Address; dst: Address; matches: boolean };
+ gasOverrides?: Record;
+} & Record;
+
+export function makeInteropContext(
+ harness: T,
+ extras: Partial> = {},
+): InteropTestContext {
+ const contracts =
+ harness.kind === 'ethers'
+ ? createEthersContractsResource(harness.client)
+ : createViemContractsResource(harness.client);
+
+ const baseCtx: InteropTestContext = {
+ client: harness.client as InteropTestContext['client'],
+ contracts: contracts as InteropTestContext['contracts'],
+ sender: ADAPTER_TEST_ADDRESSES.signer,
+ chainId: 324n,
+ dstChainId: 325n,
+ bridgehub: ADAPTER_TEST_ADDRESSES.bridgehub,
+ interopCenter: L2_INTEROP_CENTER_ADDRESS,
+ interopHandler: L2_INTEROP_HANDLER_ADDRESS,
+ l2MessageVerification: L2_MESSAGE_VERIFICATION_ADDRESS,
+ l2AssetRouter: L2_ASSET_ROUTER_ADDRESS,
+ l2NativeTokenVault: L2_NATIVE_TOKEN_VAULT_ADDRESS,
+ baseTokens: {
+ src: ADAPTER_TEST_ADDRESSES.baseTokenFor324,
+ dst: ADAPTER_TEST_ADDRESSES.baseTokenFor324,
+ matches: true,
+ },
+ };
+
+ return {
+ ...baseCtx,
+ ...extras,
+ } as InteropTestContext;
+}
+
type BaseCostCtx = Pick<
DepositTestContext,
'chainIdL2' | 'fee' | 'l2GasLimit' | 'gasPerPubdata'
diff --git a/src/adapters/__tests__/client.test.ts b/src/adapters/__tests__/client.test.ts
index d6b951b..e0502ec 100644
--- a/src/adapters/__tests__/client.test.ts
+++ b/src/adapters/__tests__/client.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'bun:test';
+import { Interface } from 'ethers';
import {
ADAPTER_TEST_ADDRESSES,
@@ -6,8 +7,11 @@ import {
describeForAdapters,
} from './adapter-harness';
import type { Address } from '../../core/types/primitives';
+import { IBridgehubABI } from '../../core/abi';
const toLower = (value: Address) => value.toLowerCase();
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
+const IBridgehub = new Interface(IBridgehubABI as any);
function assertContractAddress(harness: AdapterHarness, contract: any, expected: Address) {
if (harness.kind === 'ethers') {
@@ -66,6 +70,37 @@ describeForAdapters('adapters client', (kind, factory) => {
expect(toLower(baseToken)).toBe(toLower(ADAPTER_TEST_ADDRESSES.baseTokenFor324));
});
+ it('getProtocolVersion resolves the registered CTM semver', async () => {
+ const harness = factory();
+ if (harness.kind !== 'ethers') {
+ expect('getProtocolVersion' in harness.client).toBe(false);
+ return;
+ }
+
+ const semver = await harness.client.getProtocolVersion();
+ expect(semver).toEqual([0n, 31n, 0n]);
+ });
+
+ it('getProtocolVersion throws when chain CTM is not registered', async () => {
+ const harness = factory();
+ if (harness.kind !== 'ethers') {
+ expect('getProtocolVersion' in harness.client).toBe(false);
+ return;
+ }
+
+ harness.registry.set(
+ ADAPTER_TEST_ADDRESSES.bridgehub,
+ IBridgehub,
+ 'chainTypeManager',
+ ZERO_ADDRESS,
+ [324n],
+ );
+
+ await expect(harness.client.getProtocolVersion()).rejects.toThrow(
+ /no registered chain type manager/i,
+ );
+ });
+
it('respects manual overrides without hitting discovery calls', async () => {
const overrides = {
bridgehub: '0x1000000000000000000000000000000000000001',
diff --git a/src/adapters/__tests__/decode-helpers.ts b/src/adapters/__tests__/decode-helpers.ts
index badfe18..9f9244b 100644
--- a/src/adapters/__tests__/decode-helpers.ts
+++ b/src/adapters/__tests__/decode-helpers.ts
@@ -1,11 +1,18 @@
import { Interface, AbiCoder } from 'ethers';
-import { IBridgehubABI, IL2AssetRouterABI, IBaseTokenABI, IERC20ABI } from '../../core/abi.ts';
+import {
+ IBridgehubABI,
+ IL2AssetRouterABI,
+ IBaseTokenABI,
+ IERC20ABI,
+ InteropCenterABI,
+} from '../../core/abi.ts';
const Bridgehub = new Interface(IBridgehubABI as any);
const L2AssetRouter = new Interface(IL2AssetRouterABI as any);
const BaseToken = new Interface(IBaseTokenABI as any);
const IERC20 = new Interface(IERC20ABI as any);
+const InteropCenter = new Interface(InteropCenterABI as any);
const coder = new AbiCoder();
export function decodeTwoBridgeOuter(data: string) {
@@ -92,3 +99,43 @@ export function parseApproveTx(kind: AdapterKind, tx: any) {
amount: BigInt((args[1] as bigint | undefined) ?? 0n),
};
}
+
+export interface SendBundleDecoded {
+ to: string | undefined;
+ value: bigint;
+ destinationChainId: string;
+ callStarters: Array<{
+ to: string;
+ data: string;
+ callAttributes: string[];
+ }>;
+ bundleAttributes: string[];
+}
+
+export function decodeSendBundle(data: string): SendBundleDecoded {
+ const [destinationChainId, callStarters, bundleAttributes] = InteropCenter.decodeFunctionData(
+ 'sendBundle',
+ data,
+ ) as [string, Array<[string, string, string[]]>, string[]];
+
+ return {
+ to: undefined,
+ value: 0n,
+ destinationChainId,
+ callStarters: callStarters.map(([to, callData, attrs]) => ({
+ to,
+ data: callData,
+ callAttributes: attrs,
+ })),
+ bundleAttributes,
+ };
+}
+
+export function parseSendBundleTx(tx: any): SendBundleDecoded {
+ const decoded = decodeSendBundle(tx.data as string);
+ return {
+ ...decoded,
+ to: (tx.to as string | undefined)?.toLowerCase(),
+ value: BigInt((tx.value as bigint | undefined) ?? 0n),
+ };
+}
diff --git a/src/adapters/__tests__/interop/direct.test.ts b/src/adapters/__tests__/interop/direct.test.ts
new file mode 100644
index 0000000..09df91b
--- /dev/null
+++ b/src/adapters/__tests__/interop/direct.test.ts
@@ -0,0 +1,254 @@
+import { describe, it, expect } from 'bun:test';
+import { Interface } from 'ethers';
+
+import { routeDirect } from '../../ethers/resources/interop/routes/direct.ts';
+import { createEthersHarness, makeInteropContext } from '../adapter-harness.ts';
+import { parseSendBundleTx } from '../decode-helpers.ts';
+import { createEthersAttributesResource } from '../../ethers/resources/interop/attributes/resource.ts';
+import { interopCodec } from '../../ethers/resources/interop/address.ts';
+import { InteropCenterABI, IInteropHandlerABI } from '../../../core/abi.ts';
+import type { BuildCtx } from '../../ethers/resources/interop/context.ts';
+import type { Hex, Address } from '../../../core/types/primitives.ts';
+
+const route = routeDirect();
+
+function makeTestBuildCtx(
+ harness: ReturnType,
+ overrides: Partial = {},
+): BuildCtx {
+ const ctx = makeInteropContext(harness);
+ const attributes = createEthersAttributesResource();
+
+ const interopCenterIface = new Interface(InteropCenterABI);
+ const interopHandlerIface = new Interface(IInteropHandlerABI);
+
+ return {
+ client: harness.client,
+ tokens: {} as any,
+ contracts: ctx.contracts as any,
+ sender: ctx.sender,
+ chainIdL2: ctx.chainId,
+ chainId: ctx.chainId,
+ bridgehub: ctx.bridgehub,
+ dstChainId: ctx.dstChainId,
+ dstProvider: harness.l2 as any,
+ interopCenter: ctx.interopCenter,
+ interopHandler: ctx.interopHandler,
+ l2MessageVerification: ctx.l2MessageVerification,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ baseTokens: ctx.baseTokens,
+ ifaces: { interopCenter: interopCenterIface, interopHandler: interopHandlerIface },
+ attributes,
+ ...overrides,
+ };
+}
+
+describe('adapters/interop/routeDirect', () => {
+ it('builds a sendBundle step for a single sendNative action', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const recipient = '0x2222222222222222222222222222222222222222' as Address;
+ const amount = 1_000_000n;
+
+ const params = {
+ actions: [{ type: 'sendNative' as const, to: recipient, amount }],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ expect(result.approvals.length).toBe(0);
+ expect(result.quoteExtras.totalActionValue).toBe(amount);
+ expect(result.quoteExtras.bridgedTokenTotal).toBe(0n);
+
+ const step = result.steps[0];
+ expect(step.key).toBe('sendBundle');
+ expect(step.kind).toBe('interop.center');
+
+ const decoded = parseSendBundleTx(step.tx);
+ expect(decoded.to).toBe(buildCtx.interopCenter.toLowerCase());
+ expect(decoded.value).toBe(amount);
+ expect(decoded.callStarters.length).toBe(1);
+
+ const starter = decoded.callStarters[0];
+ expect(starter.to).toBe(interopCodec.formatAddress(recipient));
+ expect(starter.data).toBe('0x');
+ });
+
+ it('builds a sendBundle step for multiple sendNative actions', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const recipient1 = '0x1111111111111111111111111111111111111111' as Address;
+ const recipient2 = '0x2222222222222222222222222222222222222222' as Address;
+ const amount1 = 500_000n;
+ const amount2 = 300_000n;
+
+ const params = {
+ actions: [
+ { type: 'sendNative' as const, to: recipient1, amount: amount1 },
+ { type: 'sendNative' as const, to: recipient2, amount: amount2 },
+ ],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ expect(result.quoteExtras.totalActionValue).toBe(amount1 + amount2);
+
+ const decoded = parseSendBundleTx(result.steps[0].tx);
+ expect(decoded.value).toBe(amount1 + amount2);
+ expect(decoded.callStarters.length).toBe(2);
+
+ expect(decoded.callStarters[0].to).toBe(interopCodec.formatAddress(recipient1));
+ expect(decoded.callStarters[1].to).toBe(interopCodec.formatAddress(recipient2));
+ });
+
+ it('builds a sendBundle step for a call action with value', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const target = '0x3333333333333333333333333333333333333333' as Address;
+ const callData = '0xabcdef12' as Hex;
+ const value = 100_000n;
+
+ const params = {
+ actions: [{ type: 'call' as const, to: target, data: callData, value }],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ expect(result.quoteExtras.totalActionValue).toBe(value);
+
+ const decoded = parseSendBundleTx(result.steps[0].tx);
+ expect(decoded.value).toBe(value);
+ expect(decoded.callStarters.length).toBe(1);
+
+ const starter = decoded.callStarters[0];
+ expect(starter.to).toBe(interopCodec.formatAddress(target));
+ expect(starter.data).toBe(callData);
+ });
+
+ it('builds a sendBundle step for a call action without value', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const target = '0x4444444444444444444444444444444444444444' as Address;
+ const callData = '0x12345678' as Hex;
+
+ const params = {
+ actions: [{ type: 'call' as const, to: target, data: callData }],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ expect(result.quoteExtras.totalActionValue).toBe(0n);
+
+ const decoded = parseSendBundleTx(result.steps[0].tx);
+ expect(decoded.value).toBe(0n);
+
+ const starter = decoded.callStarters[0];
+ expect(starter.to).toBe(interopCodec.formatAddress(target));
+ expect(starter.data).toBe(callData);
+ });
+
+ it('throws on sendErc20 action (unsupported in direct route)', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const params = {
+ actions: [
+ {
+ type: 'sendErc20' as const,
+ token: '0x5555555555555555555555555555555555555555' as Address,
+ to: '0x6666666666666666666666666666666666666666' as Address,
+ amount: 100n,
+ },
+ ],
+ };
+
+ let caught: unknown;
+ try {
+ await route.build(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ });
+
+ it('preflight throws when no actions are provided', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const params = {
+ actions: [],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/at least one action/);
+ });
+
+ it('preflight throws when base tokens do not match', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness, {
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: 100n,
+ },
+ ],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/matching base tokens/);
+ });
+
+ it('encodes destination chain ID correctly in the bundle', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness, { dstChainId: 999n });
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: 100n,
+ },
+ ],
+ };
+
+ const result = await route.build(params, buildCtx);
+ const decoded = parseSendBundleTx(result.steps[0].tx);
+
+ const expectedDstChain = interopCodec.formatChain(999n);
+ expect(decoded.destinationChainId).toBe(expectedDstChain);
+ });
+});
diff --git a/src/adapters/__tests__/interop/indirect.test.ts b/src/adapters/__tests__/interop/indirect.test.ts
new file mode 100644
index 0000000..b93c375
--- /dev/null
+++ b/src/adapters/__tests__/interop/indirect.test.ts
@@ -0,0 +1,355 @@
+import { describe, it, expect } from 'bun:test';
+import { Interface } from 'ethers';
+
+import { routeIndirect } from '../../ethers/resources/interop/routes/indirect.ts';
+import {
+ createEthersHarness,
+ makeInteropContext,
+ setErc20Allowance,
+ setL2TokenRegistration,
+} from '../adapter-harness.ts';
+import { parseSendBundleTx } from '../decode-helpers.ts';
+import { createEthersAttributesResource } from '../../ethers/resources/interop/attributes/resource.ts';
+import { interopCodec } from '../../ethers/resources/interop/address.ts';
+import {
+ InteropCenterABI,
+ IInteropHandlerABI,
+ IERC20ABI,
+ L2NativeTokenVaultABI,
+} from '../../../core/abi.ts';
+import { createTokensResource } from '../../ethers/resources/tokens/index.ts';
+import type { BuildCtx } from '../../ethers/resources/interop/context.ts';
+import type { Hex, Address } from '../../../core/types/primitives.ts';
+
+const route = routeIndirect();
+
+const TEST_ASSET_ID = '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' as Hex;
+
+function makeTestBuildCtx(
+ harness: ReturnType,
+ overrides: Partial = {},
+): BuildCtx {
+ const ctx = makeInteropContext(harness);
+ const attributes = createEthersAttributesResource();
+ const tokens = createTokensResource(harness.client);
+
+ const interopCenterIface = new Interface(InteropCenterABI);
+ const interopHandlerIface = new Interface(IInteropHandlerABI);
+
+ return {
+ client: harness.client,
+ tokens,
+ contracts: ctx.contracts as any,
+ sender: ctx.sender,
+ chainIdL2: ctx.chainId,
+ chainId: ctx.chainId,
+ bridgehub: ctx.bridgehub,
+ dstChainId: ctx.dstChainId,
+ dstProvider: harness.l2 as any,
+ interopCenter: ctx.interopCenter,
+ interopHandler: ctx.interopHandler,
+ l2MessageVerification: ctx.l2MessageVerification,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ baseTokens: ctx.baseTokens,
+ ifaces: { interopCenter: interopCenterIface, interopHandler: interopHandlerIface },
+ attributes,
+ ...overrides,
+ };
+}
+
+describe('adapters/interop/routeIndirect', () => {
+ it('preflight throws when no actions are provided', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const params = {
+ actions: [],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/at least one action/);
+ });
+
+ it('preflight throws when no ERC-20 and base tokens match (should use direct)', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: 100n,
+ },
+ ],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/ERC-20|direct/i);
+ });
+
+ it('builds a sendBundle step for sendNative with mismatched base tokens', async () => {
+ const harness = createEthersHarness();
+
+ // Set up mismatched base tokens to enable indirect route for sendNative
+ const buildCtx = makeTestBuildCtx(harness, {
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ // Mock the token resource methods
+ const baseAssetId = '0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210' as Hex;
+ buildCtx.tokens = {
+ ...buildCtx.tokens,
+ baseTokenAssetId: async () => baseAssetId,
+ } as any;
+
+ const recipient = '0x2222222222222222222222222222222222222222' as Address;
+ const amount = 1_000_000n;
+
+ const params = {
+ actions: [{ type: 'sendNative' as const, to: recipient, amount }],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ // totalActionValue includes the sendNative amount as it will be bridged
+ expect(result.quoteExtras.totalActionValue).toBe(amount);
+ expect(result.quoteExtras.bridgedTokenTotal).toBe(0n);
+
+ const step = result.steps[0];
+ expect(step.key).toBe('sendBundle');
+ expect(step.kind).toBe('interop.center');
+
+ const decoded = parseSendBundleTx(step.tx);
+ expect(decoded.to).toBe(buildCtx.interopCenter.toLowerCase());
+ expect(decoded.callStarters.length).toBe(1);
+ });
+
+ it('preflight throws when call.value is used with mismatched base tokens', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness, {
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ const params = {
+ actions: [
+ {
+ type: 'call' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ data: '0x1234' as Hex,
+ value: 100n,
+ },
+ ],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/call\.value|base tokens/i);
+ });
+
+ it('preflight passes for sendNative with negative amount validation', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness, {
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: -1n,
+ },
+ ],
+ };
+
+ let caught: unknown;
+ try {
+ await route.preflight?.(params, buildCtx);
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/amount must be >= 0/);
+ });
+
+ it('encodes destination chain ID correctly in the bundle', async () => {
+ const harness = createEthersHarness();
+ const dstChainId = 999n;
+
+ const buildCtx = makeTestBuildCtx(harness, {
+ dstChainId,
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ buildCtx.tokens = {
+ ...buildCtx.tokens,
+ baseTokenAssetId: async () => TEST_ASSET_ID,
+ } as any;
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: 100n,
+ },
+ ],
+ };
+
+ const result = await route.build(params, buildCtx);
+ const sendBundleStep = result.steps.find((s) => s.key === 'sendBundle');
+ const decoded = parseSendBundleTx(sendBundleStep!.tx);
+
+ const expectedDstChain = interopCodec.formatChain(dstChainId);
+ expect(decoded.destinationChainId).toBe(expectedDstChain);
+ });
+
+ it('handles call action without value in indirect route', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness, {
+ baseTokens: {
+ src: '0xaaaa000000000000000000000000000000000000' as Address,
+ dst: '0xbbbb000000000000000000000000000000000000' as Address,
+ matches: false,
+ },
+ });
+
+ buildCtx.tokens = {
+ ...buildCtx.tokens,
+ baseTokenAssetId: async () => TEST_ASSET_ID,
+ } as any;
+
+ const target = '0x3333333333333333333333333333333333333333' as Address;
+ const callData = '0xabcdef12' as Hex;
+
+ const params = {
+ actions: [
+ {
+ type: 'sendNative' as const,
+ to: '0x1111111111111111111111111111111111111111' as Address,
+ amount: 100n,
+ },
+ { type: 'call' as const, to: target, data: callData },
+ ],
+ };
+
+ const result = await route.build(params, buildCtx);
+
+ expect(result.steps.length).toBe(1);
+ const decoded = parseSendBundleTx(result.steps[0].tx);
+ expect(decoded.callStarters.length).toBe(2);
+
+ // Second starter should be the call action
+ const callStarter = decoded.callStarters[1];
+ expect(callStarter.to).toBe(interopCodec.formatAddress(target));
+ expect(callStarter.data).toBe(callData);
+ });
+
+ it('builds ensure-token and approve steps for ERC-20 actions', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const token = '0x7777777777777777777777777777777777777777' as Address;
+ const amount = 100n;
+
+ setL2TokenRegistration(harness, buildCtx.l2NativeTokenVault, token, TEST_ASSET_ID);
+ setErc20Allowance(harness, token, buildCtx.sender, buildCtx.l2NativeTokenVault, 0n);
+
+ const params = {
+ actions: [{ type: 'sendErc20' as const, token, to: buildCtx.sender, amount }],
+ };
+
+ const result = await route.build(params, buildCtx);
+ expect(result.steps.map((s) => s.kind)).toEqual([
+ 'interop.ntv.ensure-token',
+ 'approve',
+ 'interop.center',
+ ]);
+
+ const ensureStep = result.steps[0];
+ const ntvIface = new Interface(L2NativeTokenVaultABI);
+ const ensureArgs = ntvIface.decodeFunctionData(
+ 'ensureTokenIsRegistered',
+ ensureStep.tx.data as Hex,
+ );
+ expect((ensureArgs[0] as string).toLowerCase()).toBe(token.toLowerCase());
+
+ const approveStep = result.steps[1];
+ const erc20Iface = new Interface(IERC20ABI);
+ const approveArgs = erc20Iface.decodeFunctionData('approve', approveStep.tx.data as Hex);
+ expect((approveArgs[0] as string).toLowerCase()).toBe(
+ buildCtx.l2NativeTokenVault.toLowerCase(),
+ );
+ expect(approveArgs[1]).toBe(amount);
+ });
+
+ it('approves the target amount (not allowance delta)', async () => {
+ const harness = createEthersHarness();
+ const buildCtx = makeTestBuildCtx(harness);
+
+ const token = '0x8888888888888888888888888888888888888888' as Address;
+ const amount = 100n;
+ const currentAllowance = 40n;
+
+ setL2TokenRegistration(harness, buildCtx.l2NativeTokenVault, token, TEST_ASSET_ID);
+ setErc20Allowance(
+ harness,
+ token,
+ buildCtx.sender,
+ buildCtx.l2NativeTokenVault,
+ currentAllowance,
+ );
+
+ const params = {
+ actions: [{ type: 'sendErc20' as const, token, to: buildCtx.sender, amount }],
+ };
+
+ const result = await route.build(params, buildCtx);
+ const approveStep = result.steps.find((s) => s.kind === 'approve');
+ expect(approveStep).toBeDefined();
+
+ const erc20Iface = new Interface(IERC20ABI);
+ const approveArgs = erc20Iface.decodeFunctionData('approve', approveStep!.tx.data as Hex);
+ expect(approveArgs[1]).toBe(amount);
+ });
+});
diff --git a/src/adapters/__tests__/interop/resource.test.ts b/src/adapters/__tests__/interop/resource.test.ts
new file mode 100644
index 0000000..928e248
--- /dev/null
+++ b/src/adapters/__tests__/interop/resource.test.ts
@@ -0,0 +1,158 @@
+import { describe, it, expect } from 'bun:test';
+import { Interface } from 'ethers';
+
+import { createInteropResource } from '../../ethers/resources/interop/index.ts';
+import {
+ ADAPTER_TEST_ADDRESSES,
+ createEthersHarness,
+ setErc20Allowance,
+ setL2TokenRegistration,
+} from '../adapter-harness.ts';
+import type { Address, Hex } from '../../../core/types/primitives.ts';
+
+const RECIPIENT = '0x2222222222222222222222222222222222222222' as Address;
+const TX_HASH = `0x${'aa'.repeat(32)}` as Hex;
+const ERC20_TOKEN = '0x3333333333333333333333333333333333333333' as Address;
+const ASSET_ID = `0x${'11'.repeat(32)}` as Hex;
+const IChainTypeManager = new Interface([
+ 'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
+]);
+
+describe('adapters/interop/resource', () => {
+ it('status returns SENT when source receipt is not yet available', async () => {
+ const harness = createEthersHarness();
+ const interop = createInteropResource(harness.client);
+
+ (harness.l2 as any).getTransactionReceipt = async () => null;
+
+ const status = await interop.status({
+ dstChain: harness.l2 as any,
+ waitable: TX_HASH,
+ });
+ expect(status.phase).toBe('SENT');
+ expect(status.l2SrcTxHash).toBe(TX_HASH);
+ });
+
+ it('create fetches nonce from pending transaction count by default', async () => {
+ const harness = createEthersHarness();
+ const interop = createInteropResource(harness.client);
+
+ let requestedBlockTag: string | undefined;
+ (harness.l2 as any).getTransactionCount = async (_from: string, blockTag: string) => {
+ requestedBlockTag = blockTag;
+ return 7;
+ };
+
+ (harness.signer as any).sendTransaction = async () => ({
+ hash: TX_HASH,
+ wait: async () => ({ status: 1 }),
+ });
+
+ const handle = await interop.create({
+ dstChain: harness.l2 as any,
+ actions: [{ type: 'sendNative', to: RECIPIENT, amount: 1n }],
+ });
+
+ expect(requestedBlockTag).toBe('pending');
+ expect(handle.l2SrcTxHash).toBe(TX_HASH);
+ });
+
+ it('create fetches nonce using txOverrides block tag', async () => {
+ const harness = createEthersHarness();
+ const interop = createInteropResource(harness.client);
+
+ let requestedBlockTag: string | undefined;
+ (harness.l2 as any).getTransactionCount = async (_from: string, blockTag: string) => {
+ requestedBlockTag = blockTag;
+ return 7;
+ };
+
+ (harness.signer as any).sendTransaction = async () => ({
+ hash: TX_HASH,
+ wait: async () => ({ status: 1 }),
+ });
+
+ const handle = await interop.create({
+ dstChain: harness.l2 as any,
+ actions: [{ type: 'sendNative', to: RECIPIENT, amount: 1n }],
+ txOverrides: {
+ nonce: 'latest',
+ gasLimit: 200_000n,
+ maxFeePerGas: 10n,
+ },
+ });
+
+ expect(requestedBlockTag).toBe('latest');
+ expect(handle.l2SrcTxHash).toBe(TX_HASH);
+ });
+
+ it('create uses numeric txOverrides nonce as starting nonce', async () => {
+ const harness = createEthersHarness();
+ const interop = createInteropResource(harness.client);
+
+ const sender = (await harness.signer.getAddress()) as Address;
+ const { l2NativeTokenVault } = await harness.client.ensureAddresses();
+ setL2TokenRegistration(harness, l2NativeTokenVault, ERC20_TOKEN, ASSET_ID);
+ setErc20Allowance(harness, ERC20_TOKEN, sender, l2NativeTokenVault, 0n);
+
+ let txCountCalls = 0;
+ (harness.l2 as any).getTransactionCount = async () => {
+ txCountCalls += 1;
+ return 999;
+ };
+
+ const sentNonces: Array = [];
+ (harness.signer as any).sendTransaction = async (tx: { nonce?: number }) => {
+ sentNonces.push(tx.nonce);
+ return {
+ hash: TX_HASH,
+ wait: async () => ({ status: 1 }),
+ };
+ };
+
+ await interop.create({
+ dstChain: harness.l2 as any,
+ actions: [
+ {
+ type: 'sendErc20',
+ token: ERC20_TOKEN,
+ to: RECIPIENT,
+ amount: 2n,
+ },
+ ],
+ txOverrides: {
+ nonce: 42,
+ gasLimit: 200_000n,
+ maxFeePerGas: 10n,
+ },
+ });
+
+ expect(txCountCalls).toBe(0);
+ expect(sentNonces).toEqual([42, 43, 44]);
+ });
+
+ it('prepare fails when protocol minor version is below 31', async () => {
+ const harness = createEthersHarness();
+ const interop = createInteropResource(harness.client);
+
+ harness.registry.set(
+ ADAPTER_TEST_ADDRESSES.chainTypeManager,
+ IChainTypeManager,
+ 'getSemverProtocolVersion',
+ [0n, 30n, 0n],
+ );
+
+ let caught: unknown;
+ try {
+ await interop.prepare({
+ dstChain: harness.l2 as any,
+ actions: [{ type: 'sendNative', to: RECIPIENT, amount: 1n }],
+ });
+ } catch (err) {
+ caught = err;
+ }
+
+ expect(caught).toBeDefined();
+ expect(String(caught)).toMatch(/interop requires protocol version 31\.0\+/i);
+ });
+});
diff --git a/src/adapters/ethers/client.ts b/src/adapters/ethers/client.ts
index 19c231c..10dbb2b 100644
--- a/src/adapters/ethers/client.ts
+++ b/src/adapters/ethers/client.ts
@@ -8,6 +8,9 @@ import {
L2_ASSET_ROUTER_ADDRESS,
L2_NATIVE_TOKEN_VAULT_ADDRESS,
L2_BASE_TOKEN_ADDRESS,
+ L2_INTEROP_CENTER_ADDRESS,
+ L2_INTEROP_HANDLER_ADDRESS,
+ L2_MESSAGE_VERIFICATION_ADDRESS,
} from '../../core/constants';
import {
@@ -18,13 +21,23 @@ import {
L2NativeTokenVaultABI,
L1NativeTokenVaultABI,
IBaseTokenABI,
+ InteropCenterABI,
+ IInteropHandlerABI,
+ L2MessageVerificationABI,
} from '../../core/abi';
import { createError } from '../../core/errors/factory';
-import { OP_DEPOSITS } from '../../core/types';
+import { OP_DEPOSITS, OP_CLIENT } from '../../core/types';
import { createErrorHandlers } from './errors/error-ops';
+import { FORMAL_ETH_ADDRESS } from '../../core/constants';
// error handling
-const { wrapAs } = createErrorHandlers('client');
+const { wrapAs, wrap } = createErrorHandlers('client');
+// Single function, instead of the whole ABI. If more fns are needed, consider adding the whole ABI.
+const ChainTypeManagerABI = [
+ 'function getSemverProtocolVersion() view returns (uint32,uint32,uint32)',
+] as const;
+
+export type ProtocolVersion = readonly [number, number, number];
export interface ResolvedAddresses {
bridgehub: Address;
@@ -34,6 +47,9 @@ export interface ResolvedAddresses {
l2AssetRouter: Address;
l2NativeTokenVault: Address;
l2BaseTokenSystem: Address;
+ interopCenter: Address;
+ interopHandler: Address;
+ l2MessageVerification: Address;
}
export interface EthersClient {
@@ -64,6 +80,9 @@ export interface EthersClient {
l2AssetRouter: Contract;
l2NativeTokenVault: Contract;
l2BaseTokenSystem: Contract;
+ interopCenter: Contract;
+ interopHandler: Contract;
+ l2MessageVerification: Contract;
}>;
/** Clear all cached addresses/contracts. */
@@ -71,6 +90,12 @@ export interface EthersClient {
/** Lookup the base token for a given chain ID via Bridgehub.baseToken(chainId) */
baseToken(chainId: bigint): Promise;
+
+ /** Read semver protocol version for the CTM of a chain. */
+ getProtocolVersion(chainId?: bigint): Promise;
+
+ /** Get a signer connected to a specific provider */
+ signerFor(target?: 'l1' | AbstractProvider): Signer;
}
type InitArgs = {
@@ -80,6 +105,7 @@ type InitArgs = {
l2: AbstractProvider;
/** Signer for sending txs. */
signer: Signer;
+
/** Optional manual overrides */
overrides?: Partial;
};
@@ -150,50 +176,77 @@ export function createEthersClient(args: InitArgs): EthersClient {
l2AssetRouter: Contract;
l2NativeTokenVault: Contract;
l2BaseTokenSystem: Contract;
+ interopCenter: Contract;
+ interopHandler: Contract;
+ l2MessageVerification: Contract;
}
| undefined;
async function ensureAddresses(): Promise {
if (addrCache) return addrCache;
- // Bridgehub
- const bridgehub = args.overrides?.bridgehub ?? (await zks.getBridgehubAddress());
-
- // L1 AssetRouter via Bridgehub.assetRouter()
- const IBridgehub = new Interface(IBridgehubABI);
- const bh = new Contract(bridgehub, IBridgehub, l1);
- const l1AssetRouter = args.overrides?.l1AssetRouter ?? ((await bh.assetRouter()) as Address);
-
- // L1Nullifier via L1AssetRouter.L1_NULLIFIER()
- const IL1AssetRouter = new Interface(IL1AssetRouterABI);
- const ar = new Contract(l1AssetRouter, IL1AssetRouter, l1);
- const l1Nullifier = args.overrides?.l1Nullifier ?? ((await ar.L1_NULLIFIER()) as Address);
-
- // L1NativeTokenVault via L1Nullifier.l1NativeTokenVault()
- const IL1Nullifier = new Interface(IL1NullifierABI);
- const nf = new Contract(l1Nullifier, IL1Nullifier, l1);
- const l1NativeTokenVault =
- args.overrides?.l1NativeTokenVault ?? ((await nf.l1NativeTokenVault()) as Address);
-
- // L2AssetRouter
- const l2AssetRouter = args.overrides?.l2AssetRouter ?? L2_ASSET_ROUTER_ADDRESS;
-
- // L2NativeTokenVault
- const l2NativeTokenVault = args.overrides?.l2NativeTokenVault ?? L2_NATIVE_TOKEN_VAULT_ADDRESS;
-
- // L2BaseToken
- const l2BaseTokenSystem = args.overrides?.l2BaseTokenSystem ?? L2_BASE_TOKEN_ADDRESS;
-
- addrCache = {
- bridgehub,
- l1AssetRouter,
- l1Nullifier,
- l1NativeTokenVault,
- l2AssetRouter,
- l2NativeTokenVault,
- l2BaseTokenSystem,
- };
- return addrCache;
+ return await wrap(
+ OP_CLIENT.ensureAddresses,
+ async () => {
+ // Bridgehub
+ const bridgehub = args.overrides?.bridgehub ?? (await zks.getBridgehubAddress());
+
+ // L1 AssetRouter via Bridgehub.assetRouter()
+ const IBridgehub = new Interface(IBridgehubABI);
+ const bh = new Contract(bridgehub, IBridgehub, l1);
+ const l1AssetRouter =
+ args.overrides?.l1AssetRouter ?? ((await bh.assetRouter()) as Address);
+
+ // L1Nullifier via L1AssetRouter.L1_NULLIFIER()
+ const IL1AssetRouter = new Interface(IL1AssetRouterABI);
+ const ar = new Contract(l1AssetRouter, IL1AssetRouter, l1);
+ const l1Nullifier = args.overrides?.l1Nullifier ?? ((await ar.L1_NULLIFIER()) as Address);
+
+ // L1NativeTokenVault via L1Nullifier.l1NativeTokenVault()
+ const IL1Nullifier = new Interface(IL1NullifierABI);
+ const nf = new Contract(l1Nullifier, IL1Nullifier, l1);
+ const l1NativeTokenVault =
+ args.overrides?.l1NativeTokenVault ?? ((await nf.l1NativeTokenVault()) as Address);
+
+ // L2AssetRouter
+ const l2AssetRouter = args.overrides?.l2AssetRouter ?? L2_ASSET_ROUTER_ADDRESS;
+
+ // L2NativeTokenVault
+ const l2NativeTokenVault =
+ args.overrides?.l2NativeTokenVault ?? L2_NATIVE_TOKEN_VAULT_ADDRESS;
+
+ // L2BaseToken
+ const l2BaseTokenSystem = args.overrides?.l2BaseTokenSystem ?? L2_BASE_TOKEN_ADDRESS;
+
+ // InteropCenter
+ const interopCenter = args.overrides?.interopCenter ?? L2_INTEROP_CENTER_ADDRESS;
+
+ // InteropHandler
+ const interopHandler = args.overrides?.interopHandler ?? L2_INTEROP_HANDLER_ADDRESS;
+
+ // L2MessageVerification
+ const l2MessageVerification =
+ args.overrides?.l2MessageVerification ?? L2_MESSAGE_VERIFICATION_ADDRESS;
+
+ addrCache = {
+ bridgehub,
+ l1AssetRouter,
+ l1Nullifier,
+ l1NativeTokenVault,
+ l2AssetRouter,
+ l2NativeTokenVault,
+ l2BaseTokenSystem,
+ interopCenter,
+ interopHandler,
+ l2MessageVerification,
+ };
+ return addrCache;
+ },
+ {
+ ctx: { where: 'ensureAddresses' },
+ message: 'Failed to ensure contract addresses.',
+ },
+ );
}
// lazily create connected contract instances for convenience
@@ -209,6 +262,9 @@ export function createEthersClient(args: InitArgs): EthersClient {
l2AssetRouter: new Contract(a.l2AssetRouter, IL2AssetRouterABI, l2),
l2NativeTokenVault: new Contract(a.l2NativeTokenVault, L2NativeTokenVaultABI, l2),
l2BaseTokenSystem: new Contract(a.l2BaseTokenSystem, IBaseTokenABI, l2),
+ interopCenter: new Contract(a.interopCenter, InteropCenterABI, l2),
+ interopHandler: new Contract(a.interopHandler, IInteropHandlerABI, l2),
+ l2MessageVerification: new Contract(a.l2MessageVerification, L2MessageVerificationABI, l2),
};
return cCache;
}
@@ -247,11 +303,62 @@ export function createEthersClient(args: InitArgs): EthersClient {
const bh = new Contract(bridgehub, IBridgehubABI, l1);
return (await wrapAs('CONTRACT', OP_DEPOSITS.base.baseToken, () => bh.baseToken(chainId), {
- ctx: { where: 'bridgehub.baseToken', chainIdL2: chainId },
+ ctx: { where: 'bridgehub.baseToken', chainId: chainId },
message: 'Failed to read base token.',
})) as Address;
}
+ async function getProtocolVersion(chainId?: bigint): Promise {
+ const targetChainId = chainId ?? (await l2.getNetwork()).chainId;
+ const { bridgehub } = await ensureAddresses();
+ const bh = new Contract(bridgehub, IBridgehubABI, l1);
+
+ const chainTypeManager = (await wrapAs(
+ 'CONTRACT',
+ OP_CLIENT.getSemverProtocolVersion,
+ () => bh.chainTypeManager(targetChainId),
+ {
+ ctx: { where: 'bridgehub.chainTypeManager', bridgehub, chainId: targetChainId },
+ message: 'Failed to read chain type manager.',
+ },
+ )) as Address;
+
+ if (chainTypeManager.toLowerCase() === FORMAL_ETH_ADDRESS) {
+ throw createError('STATE', {
+ resource: 'client',
+ operation: OP_CLIENT.getSemverProtocolVersion,
+ message: 'No registered chain type manager for the chain.',
+ context: { chainId },
+ });
+ }
+
+ const ctm = new Contract(chainTypeManager, ChainTypeManagerABI, l1);
+ const semver = (await wrapAs(
+ 'CONTRACT',
+ OP_CLIENT.getSemverProtocolVersion,
+ () => ctm.getSemverProtocolVersion(),
+ {
+ ctx: {
+ where: 'chainTypeManager.getSemverProtocolVersion',
+ chainId: targetChainId,
+ chainTypeManager,
+ },
+ message: 'Failed to read semver protocol version.',
+ },
+ )) as [number, number, number];
+
+ return semver;
+ }
+
+ /** Signer helpers */
+ function signerFor(target?: 'l1' | AbstractProvider): Signer {
+ if (target === 'l1') {
+ return boundSigner.provider === l1 ? boundSigner : boundSigner.connect(l1);
+ }
+ const provider = target ?? l2; // default to current/source L2
+ return boundSigner.provider === provider ? boundSigner : boundSigner.connect(provider);
+ }
+
const client: EthersClient = {
kind: 'ethers',
l1,
@@ -268,6 +375,8 @@ export function createEthersClient(args: InitArgs): EthersClient {
contracts,
refresh,
baseToken,
+ getProtocolVersion,
+ signerFor,
};
return client;
diff --git a/src/adapters/ethers/index.ts b/src/adapters/ethers/index.ts
index 68d78ec..992e933 100644
--- a/src/adapters/ethers/index.ts
+++ b/src/adapters/ethers/index.ts
@@ -11,6 +11,9 @@ export { createWithdrawalsResource } from './resources/withdrawals';
export { createFinalizationServices } from './resources/withdrawals';
export type { WithdrawalsResource, FinalizationServices } from './resources/withdrawals';
export { createTokensResource } from './resources/tokens';
+export { createInteropResource } from './resources/interop';
+export { createInteropFinalizationServices } from './resources/interop';
+export type { InteropResource, InteropFinalizationServices } from './resources/interop';
// Errors adapted for ethers
export * from './errors/error-ops';
diff --git a/src/adapters/ethers/resources/contracts/contracts.ts b/src/adapters/ethers/resources/contracts/contracts.ts
index 9d85ff1..3ba4573 100644
--- a/src/adapters/ethers/resources/contracts/contracts.ts
+++ b/src/adapters/ethers/resources/contracts/contracts.ts
@@ -57,6 +57,21 @@ export function createContractsResource(client: EthersClient): ContractsResource
return l2BaseTokenSystem;
}
+ async function interopCenter() {
+ const { interopCenter } = await instances();
+ return interopCenter;
+ }
+
+ async function interopHandler() {
+ const { interopHandler } = await instances();
+ return interopHandler;
+ }
+
+ async function l2MessageVerification() {
+ const { l2MessageVerification } = await instances();
+ return l2MessageVerification;
+ }
+
return {
addresses,
instances,
@@ -67,5 +82,8 @@ export function createContractsResource(client: EthersClient): ContractsResource
l2AssetRouter,
l2NativeTokenVault,
l2BaseTokenSystem,
+ interopCenter,
+ interopHandler,
+ l2MessageVerification,
};
}
diff --git a/src/adapters/ethers/resources/contracts/types.ts b/src/adapters/ethers/resources/contracts/types.ts
index 9d957a8..97285ba 100644
--- a/src/adapters/ethers/resources/contracts/types.ts
+++ b/src/adapters/ethers/resources/contracts/types.ts
@@ -14,6 +14,9 @@ export interface ContractInstances {
l2AssetRouter: Contract;
l2NativeTokenVault: Contract;
l2BaseTokenSystem: Contract;
+ interopCenter: Contract;
+ interopHandler: Contract;
+ l2MessageVerification: Contract;
}
/**
@@ -77,4 +80,19 @@ export interface ContractsResource {
* Returns the L2 Base Token System contract instance.
*/
l2BaseTokenSystem(): Promise;
+
+ /**
+ * Returns the Interop Center contract instance.
+ */
+ interopCenter(): Promise;
+
+ /**
+ * Returns the Interop Handler contract instance.
+ */
+ interopHandler(): Promise;
+
+ /**
+ * Returns the L2 Message Verification contract instance.
+ */
+ l2MessageVerification(): Promise;
}
diff --git a/src/adapters/ethers/resources/interop/address.ts b/src/adapters/ethers/resources/interop/address.ts
new file mode 100644
index 0000000..27ba466
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/address.ts
@@ -0,0 +1,38 @@
+// Ethers adapter: ERC-7930 interoperable address encoding
+import { concat, getAddress, getBytes, hexlify, toBeArray, toBeHex } from 'ethers';
+import type { Address, Hex } from '../../../../core/types/primitives';
+
+const PREFIX_EVM_CHAIN = getBytes('0x00010000'); // version(0x0001) + chainType(eip-155 → 0x0000)
+const PREFIX_EVM_ADDRESS = getBytes('0x000100000014'); // version + chainType + zero chainRef len + addr len (20)
+
+/**
+ * Formats an ERC-7930 interoperable address (version 1) that describes an EVM chain
+ * without specifying a destination address. Mirrors InteroperableAddress.formatEvmV1(chainId).
+ */
+export function formatInteropEvmChain(chainId: bigint): Hex {
+ const chainRef = toBeArray(chainId);
+ const chainRefLength = getBytes(toBeHex(chainRef.length, 1));
+
+ const payload = concat([PREFIX_EVM_CHAIN, chainRefLength, chainRef, new Uint8Array([0])]);
+
+ return hexlify(payload) as Hex;
+}
+
+/**
+ * Formats an ERC-7930 interoperable address (version 1) that describes an EVM address
+ * without a chain reference. Mirrors InteroperableAddress.formatEvmV1(address).
+ */
+export function formatInteropEvmAddress(address: Address): Hex {
+ const normalized = getAddress(address);
+ const addrBytes = getBytes(normalized);
+ const payload = concat([PREFIX_EVM_ADDRESS, addrBytes]);
+ return hexlify(payload) as Hex;
+}
+
+/**
+ * Codec for interop address encoding used by route builders.
+ */
+export const interopCodec = {
+ formatChain: formatInteropEvmChain,
+ formatAddress: formatInteropEvmAddress,
+};
diff --git a/src/adapters/ethers/resources/interop/attributes/resource.ts b/src/adapters/ethers/resources/interop/attributes/resource.ts
new file mode 100644
index 0000000..02ed01d
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/attributes/resource.ts
@@ -0,0 +1,56 @@
+// src/adapters/ethers/resources/interop/attributes/resource.ts
+import { Interface } from 'ethers';
+import {
+ createAttributesResource,
+ type AttributesResource,
+} from '../../../../../core/resources/interop/attributes/resource';
+import IERC7786AttributesAbi from '../../../../../core/internal/abis/IERC7786Attributes';
+import type { Hex } from '../../../../../core/types/primitives';
+import type { InteropParams } from '../../../../../core/types/flows/interop';
+import type { BuildCtx } from '../context';
+import type { InteropAttributes } from '../../../../../core/resources/interop/plan';
+import { assertNever } from '../../../../../core/utils';
+
+export function getInteropAttributes(params: InteropParams, ctx: BuildCtx): InteropAttributes {
+ const bundleAttributes: Hex[] = [];
+ if (params.execution?.only) {
+ bundleAttributes.push(ctx.attributes.bundle.executionAddress(params.execution.only));
+ }
+ if (params.unbundling?.by) {
+ bundleAttributes.push(ctx.attributes.bundle.unbundlerAddress(params.unbundling.by));
+ }
+
+ const callAttributes = params.actions.map((action) => {
+ switch (action.type) {
+ case 'sendNative': {
+ const baseMatches = ctx.baseTokens.src.toLowerCase() === ctx.baseTokens.dst.toLowerCase();
+ if (baseMatches) {
+ return [ctx.attributes.call.interopCallValue(action.amount)];
+ }
+ return [ctx.attributes.call.indirectCall(action.amount)];
+ }
+ case 'call':
+ if (action.value && action.value > 0n) {
+ return [ctx.attributes.call.interopCallValue(action.value)];
+ }
+ return [];
+ case 'sendErc20':
+ return [ctx.attributes.call.indirectCall(0n)];
+ default:
+ assertNever(action);
+ }
+ });
+
+ return { bundleAttributes, callAttributes };
+}
+
+export function createEthersAttributesResource(
+ opts: { iface?: Interface } = {},
+): AttributesResource {
+ const iface = opts.iface ?? new Interface(IERC7786AttributesAbi);
+
+ const encode = (fn: string, args: readonly unknown[]): Hex =>
+ iface.encodeFunctionData(fn, args) as Hex;
+
+ return createAttributesResource({ encode });
+}
diff --git a/src/adapters/ethers/resources/interop/context.ts b/src/adapters/ethers/resources/interop/context.ts
new file mode 100644
index 0000000..890327b
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/context.ts
@@ -0,0 +1,121 @@
+// src/adapters/ethers/resources/interop/context.ts
+import type { AbstractProvider } from 'ethers';
+import { Interface } from 'ethers';
+import type { EthersClient, ProtocolVersion } from '../../client';
+import type { Address } from '../../../../core/types/primitives';
+import type { CommonCtx } from '../../../../core/types/flows/base';
+import type { InteropParams } from '../../../../core/types/flows/interop';
+import { type TxGasOverrides, toGasOverrides } from '../../../../core/types/fees';
+import type { TokensResource } from '../../../../core/types/flows/token';
+import type { AttributesResource } from '../../../../core/resources/interop/attributes/resource';
+import type { ContractsResource } from '../contracts';
+import { IInteropHandlerABI, InteropCenterABI } from '../../../../core/abi';
+import { createError } from '../../../../core/errors/factory';
+import { OP_INTEROP } from '../../../../core/types/errors';
+
+const MIN_INTEROP_PROTOCOL = 31;
+
+async function assertInteropProtocolVersion(
+ client: EthersClient,
+ srcChainId: bigint,
+ dstChainId: bigint,
+): Promise {
+ const [srcProtocolVersion, dstProtocolVersion] = await Promise.all([
+ client.getProtocolVersion(srcChainId),
+ client.getProtocolVersion(dstChainId),
+ ]);
+
+ const assertProtocolVersion = (chainId: bigint, protocolVersion: ProtocolVersion): void => {
+ if (protocolVersion[1] < MIN_INTEROP_PROTOCOL) {
+ throw createError('VALIDATION', {
+ resource: 'interop',
+ operation: OP_INTEROP.context.protocolVersion,
+ message: `Interop requires protocol version 31.0+. Found: ${protocolVersion[1]}.${protocolVersion[2]} for chain: ${chainId}.`,
+ context: {
+ chainId,
+ requiredMinor: MIN_INTEROP_PROTOCOL,
+ semver: protocolVersion,
+ },
+ });
+ }
+ };
+
+ assertProtocolVersion(srcChainId, srcProtocolVersion);
+ assertProtocolVersion(dstChainId, dstProtocolVersion);
+}
+
+// Common context for building interop (L2 -> L2) transactions
+export interface BuildCtx extends CommonCtx {
+ client: EthersClient;
+ tokens: TokensResource;
+ contracts: ContractsResource;
+
+ bridgehub: Address;
+ dstChainId: bigint;
+ dstProvider: AbstractProvider;
+ chainId: bigint;
+ interopCenter: Address;
+ interopHandler: Address;
+ l2MessageVerification: Address;
+ l2AssetRouter: Address;
+ l2NativeTokenVault: Address;
+
+ baseTokens: { src: Address; dst: Address; matches: boolean };
+ ifaces: { interopCenter: Interface; interopHandler: Interface };
+ attributes: AttributesResource;
+ gasOverrides?: TxGasOverrides;
+}
+
+export async function commonCtx(
+ dstProvider: AbstractProvider,
+ params: InteropParams,
+ client: EthersClient,
+ tokens: TokensResource,
+ contracts: ContractsResource,
+ attributes: AttributesResource,
+): Promise {
+ const sender = (await client.signer.getAddress()) as Address;
+ const chainId = (await client.l2.getNetwork()).chainId;
+ const dstChainId = (await dstProvider.getNetwork()).chainId;
+
+ const {
+ bridgehub,
+ l2AssetRouter,
+ l2NativeTokenVault,
+ interopCenter,
+ interopHandler,
+ l2MessageVerification,
+ } = await contracts.addresses();
+
+ await assertInteropProtocolVersion(client, chainId, dstChainId);
+
+ const [srcBaseToken, dstBaseToken] = await Promise.all([
+ client.baseToken(chainId),
+ client.baseToken(dstChainId),
+ ]);
+
+ const interopCenterIface = new Interface(InteropCenterABI);
+ const interopHandlerIface = new Interface(IInteropHandlerABI);
+ const baseMatches = srcBaseToken.toLowerCase() === dstBaseToken.toLowerCase();
+
+ return {
+ client,
+ tokens,
+ contracts,
+ sender,
+ chainIdL2: chainId,
+ chainId,
+ bridgehub,
+ dstChainId,
+ dstProvider,
+ interopCenter,
+ interopHandler,
+ l2MessageVerification,
+ l2AssetRouter,
+ l2NativeTokenVault,
+ baseTokens: { src: srcBaseToken, dst: dstBaseToken, matches: baseMatches },
+ ifaces: { interopCenter: interopCenterIface, interopHandler: interopHandlerIface },
+ attributes,
+ gasOverrides: params.txOverrides ? toGasOverrides(params.txOverrides) : undefined,
+ } satisfies BuildCtx;
+}
diff --git a/src/adapters/ethers/resources/interop/index.ts b/src/adapters/ethers/resources/interop/index.ts
new file mode 100644
index 0000000..1ddc556
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/index.ts
@@ -0,0 +1,358 @@
+// src/adapters/ethers/resources/interop/index.ts
+import type { EthersClient } from '../../client';
+import type { Hex } from '../../../../core/types/primitives';
+import { createEthersAttributesResource } from './attributes/resource';
+import type { AttributesResource } from '../../../../core/resources/interop/attributes/resource';
+import type {
+ InteropRoute,
+ InteropPlan,
+ InteropQuote,
+ InteropStatus,
+ InteropFinalizationResult,
+} from '../../../../core/types/flows/interop';
+import { isInteropFinalizationInfo as isInteropFinalizationInfoBase } from '../../../../core/types/flows/interop';
+import type { ContractsResource } from '../contracts';
+import { createTokensResource } from '../tokens';
+import { createContractsResource } from '../contracts';
+import type { TokensResource } from '../../../../core/types/flows/token';
+import { routeIndirect } from './routes/indirect';
+import { routeDirect } from './routes/direct';
+import type { InteropRouteStrategy } from './routes/types';
+import type { AbstractProvider, TransactionRequest } from 'ethers';
+import { isZKsyncError, OP_INTEROP } from '../../../../core/types/errors';
+import { createErrorHandlers } from '../../errors/error-ops';
+import { commonCtx, type BuildCtx } from './context';
+import { createError } from '../../../../core/errors/factory';
+import { pickInteropRoute } from '../../../../core/resources/interop/route';
+import {
+ createInteropFinalizationServices,
+ type InteropFinalizationServices,
+} from './services/finalization';
+import type { LogsQueryOptions } from './services/finalization/data-fetchers';
+import type {
+ InteropParams,
+ InteropHandle,
+ InteropWaitable,
+ InteropFinalizationInfo,
+} from './types';
+import { resolveDstProvider, resolveWaitableInput } from './resolvers';
+const { wrap, toResult } = createErrorHandlers('interop');
+
+// Interop Route map
+export const ROUTES: Record = {
+ direct: routeDirect(),
+ indirect: routeIndirect(),
+};
+
+export interface InteropResource {
+ quote(params: InteropParams): Promise;
+
+ tryQuote(
+ params: InteropParams,
+ ): Promise<{ ok: true; value: InteropQuote } | { ok: false; error: unknown }>;
+
+ prepare(params: InteropParams): Promise>;
+
+ tryPrepare(
+ params: InteropParams,
+ ): Promise<{ ok: true; value: InteropPlan } | { ok: false; error: unknown }>;
+
+ create(params: InteropParams): Promise>;
+
+ tryCreate(
+ params: InteropParams,
+ ): Promise<
+ { ok: true; value: InteropHandle } | { ok: false; error: unknown }
+ >;
+
+ status(h: InteropWaitable, opts?: LogsQueryOptions): Promise;
+
+ wait(
+ h: InteropWaitable,
+ opts?: { pollMs?: number; timeoutMs?: number },
+ ): Promise;
+
+ tryWait(
+ h: InteropWaitable,
+ opts?: { pollMs?: number; timeoutMs?: number },
+ ): Promise<{ ok: true; value: InteropFinalizationInfo } | { ok: false; error: unknown }>;
+
+ finalize(
+ h: InteropWaitable | InteropFinalizationInfo,
+ opts?: LogsQueryOptions,
+ ): Promise;
+
+ tryFinalize(
+ h: InteropWaitable | InteropFinalizationInfo,
+ opts?: LogsQueryOptions,
+ ): Promise<{ ok: true; value: InteropFinalizationResult } | { ok: false; error: unknown }>;
+}
+
+export function createInteropResource(
+ client: EthersClient,
+ tokens?: TokensResource,
+ contracts?: ContractsResource,
+ attributes?: AttributesResource,
+): InteropResource {
+ const svc: InteropFinalizationServices = createInteropFinalizationServices(client);
+ const tokensResource = tokens ?? createTokensResource(client);
+ const contractsResource = contracts ?? createContractsResource(client);
+ const attributesResource = attributes ?? createEthersAttributesResource();
+
+ // Internal helper: builds an InteropPlan along with the context used.
+ // Returns both so create() can reuse the context without rebuilding.
+ async function buildPlanWithCtx(
+ dstProvider: AbstractProvider,
+ params: InteropParams,
+ ): Promise<{ plan: InteropPlan; ctx: BuildCtx }> {
+ const ctx = await commonCtx(
+ dstProvider,
+ params,
+ client,
+ tokensResource,
+ contractsResource,
+ attributesResource,
+ );
+
+ const route = pickInteropRoute({
+ actions: params.actions,
+ ctx: {
+ sender: ctx.sender,
+ srcChainId: ctx.chainId,
+ dstChainId: ctx.dstChainId,
+ baseTokenSrc: ctx.baseTokens.src,
+ baseTokenDst: ctx.baseTokens.dst,
+ },
+ });
+
+ // Route-level preflight
+ await wrap(OP_INTEROP.routes[route].preflight, () => ROUTES[route].preflight?.(params, ctx), {
+ message: 'Interop preflight failed.',
+ ctx: { where: `routes.${route}.preflight` },
+ });
+
+ // Build concrete steps, approvals, and quote extras
+ const { steps, approvals, quoteExtras } = await wrap(
+ OP_INTEROP.routes[route].build,
+ () => ROUTES[route].build(params, ctx),
+ {
+ message: 'Failed to build interop route plan.',
+ ctx: { where: `routes.${route}.build` },
+ },
+ );
+
+ // Assemble plan summary
+ const summary: InteropQuote = {
+ route,
+ approvalsNeeded: approvals,
+ totalActionValue: quoteExtras.totalActionValue,
+ bridgedTokenTotal: quoteExtras.bridgedTokenTotal,
+ };
+
+ return { plan: { route, summary, steps }, ctx };
+ }
+
+ async function buildPlan(
+ dstProvider: AbstractProvider,
+ params: InteropParams,
+ ): Promise> {
+ const { plan } = await buildPlanWithCtx(dstProvider, params);
+ return plan;
+ }
+
+ // quote → build and return the summary
+ const quote = (params: InteropParams): Promise =>
+ wrap(OP_INTEROP.quote, async () => {
+ const plan = await buildPlan(resolveDstProvider(params.dstChain), params);
+ return plan.summary;
+ });
+
+ const tryQuote = (params: InteropParams) =>
+ toResult(OP_INTEROP.tryQuote, () => quote(params));
+
+ // prepare → build plan without executing
+ const prepare = (params: InteropParams): Promise> =>
+ wrap(OP_INTEROP.prepare, () => buildPlan(resolveDstProvider(params.dstChain), params), {
+ message: 'Internal error while preparing an interop plan.',
+ ctx: { where: 'interop.prepare' },
+ });
+
+ const tryPrepare = (params: InteropParams) =>
+ toResult>(OP_INTEROP.tryPrepare, () => prepare(params));
+
+ // create → execute the source-chain step(s)
+ // waits for each tx receipt to confirm (status != 0)
+ const create = (params: InteropParams): Promise> =>
+ wrap(
+ OP_INTEROP.create,
+ async () => {
+ // Build plan and reuse the context
+ const { plan, ctx } = await buildPlanWithCtx(resolveDstProvider(params.dstChain), params);
+ const signer = ctx.client.signerFor(ctx.client.l2);
+ const srcProvider = ctx.client.l2;
+
+ const from = await signer.getAddress();
+ let next: number;
+ if (typeof params.txOverrides?.nonce === 'number') {
+ next = params.txOverrides.nonce;
+ } else {
+ const blockTag = params.txOverrides?.nonce ?? 'pending';
+ next = await srcProvider.getTransactionCount(from, blockTag);
+ }
+
+ const stepHashes: Record = {};
+
+ for (const step of plan.steps) {
+ step.tx.nonce = next++;
+
+ // lock in chainId so ethers doesn't guess
+ if (!step.tx.chainId) {
+ step.tx.chainId = Number(ctx.chainId);
+ }
+
+ // best-effort gasLimit with buffer
+ if (!step.tx.gasLimit) {
+ try {
+ const est = await srcProvider.estimateGas({
+ ...step.tx,
+ from,
+ });
+ step.tx.gasLimit = (BigInt(est) * 115n) / 100n;
+ } catch {
+ // Intentionally empty: gas estimation is best-effort
+ }
+ }
+
+ let hash: Hex | undefined;
+ try {
+ const sent = await signer.sendTransaction(step.tx);
+ hash = sent.hash as Hex;
+ stepHashes[step.key] = hash;
+
+ const rcpt = await sent.wait();
+ if (rcpt?.status === 0) {
+ throw createError('EXECUTION', {
+ resource: 'interop',
+ operation: 'interop.create.sendTransaction',
+ message: 'Interop transaction reverted on source L2.',
+ context: { step: step.key, txHash: hash },
+ });
+ }
+ } catch (e) {
+ if (isZKsyncError(e)) throw e;
+ throw createError('EXECUTION', {
+ resource: 'interop',
+ operation: 'interop.create.sendTransaction',
+ message: 'Failed to send or confirm an interop transaction step.',
+ context: {
+ step: step.key,
+ txHash: hash,
+ nonce: Number(step.tx.nonce ?? -1),
+ },
+ cause: e as Error,
+ });
+ }
+ }
+
+ const last = Object.values(stepHashes).pop();
+ return {
+ kind: 'interop',
+ dstChain: params.dstChain,
+ stepHashes,
+ plan,
+ l2SrcTxHash: last ?? ('0x' as Hex),
+ };
+ },
+ {
+ message: 'Internal error while creating interop bundle.',
+ ctx: { where: 'interop.create' },
+ },
+ );
+
+ const tryCreate = (params: InteropParams) =>
+ toResult>(OP_INTEROP.tryCreate, () => create(params));
+
+ // status → non-blocking lifecycle inspection
+ const status = (h: InteropWaitable, opts?: LogsQueryOptions): Promise => {
+ const { dstProvider, waitable } = resolveWaitableInput(h);
+ return wrap(OP_INTEROP.status, () => svc.status(dstProvider, waitable, opts), {
+ message: 'Internal error while checking interop status.',
+ ctx: { where: 'interop.status' },
+ });
+ };
+
+ // wait → block until source finalization + destination root availability
+ const wait = (
+ h: InteropWaitable,
+ opts?: { pollMs?: number; timeoutMs?: number },
+ ): Promise => {
+ const { dstProvider, waitable } = resolveWaitableInput(h);
+ return wrap(
+ OP_INTEROP.wait,
+ async () => {
+ const info = await svc.wait(dstProvider, waitable, opts);
+ return { ...info, dstChain: h.dstChain };
+ },
+ {
+ message: 'Internal error while waiting for interop finalization.',
+ ctx: { where: 'interop.wait' },
+ },
+ );
+ };
+
+ const tryWait = (h: InteropWaitable, opts?: { pollMs?: number; timeoutMs?: number }) =>
+ toResult(OP_INTEROP.tryWait, () => wait(h, opts));
+
+ // finalize → executeBundle on destination chain,
+ // waits until that destination tx is mined,
+ // returns finalization metadata for UI / explorers.
+ const finalize = (
+ h: InteropWaitable | InteropFinalizationInfo,
+ opts?: LogsQueryOptions,
+ ): Promise =>
+ wrap(
+ OP_INTEROP.finalize,
+ async () => {
+ if (isInteropFinalizationInfoBase(h)) {
+ if (h.dstChain == null) {
+ throw createError('STATE', {
+ resource: 'interop',
+ operation: OP_INTEROP.finalize,
+ message: 'Missing dstChain in interop finalization info.',
+ context: { input: h },
+ });
+ }
+ const dstProvider = resolveDstProvider(h.dstChain);
+ return svc.finalize(dstProvider, h, opts);
+ }
+
+ const { dstProvider, waitable } = resolveWaitableInput(h);
+ const info = await svc.wait(dstProvider, waitable);
+ return svc.finalize(dstProvider, info, opts);
+ },
+ {
+ message: 'Failed to finalize/execute interop bundle on destination.',
+ ctx: { where: 'interop.finalize' },
+ },
+ );
+
+ const tryFinalize = (h: InteropWaitable | InteropFinalizationInfo, opts?: LogsQueryOptions) =>
+ toResult(OP_INTEROP.tryFinalize, () => finalize(h, opts));
+
+ return {
+ quote,
+ tryQuote,
+ prepare,
+ tryPrepare,
+ create,
+ tryCreate,
+ status,
+ wait,
+ tryWait,
+ finalize,
+ tryFinalize,
+ };
+}
+
+export { createInteropFinalizationServices };
+export type { InteropFinalizationServices };
diff --git a/src/adapters/ethers/resources/interop/resolvers.ts b/src/adapters/ethers/resources/interop/resolvers.ts
new file mode 100644
index 0000000..7ccabc3
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/resolvers.ts
@@ -0,0 +1,19 @@
+import { AbstractProvider, JsonRpcProvider } from 'ethers';
+import type { InteropWaitable as InteropWaitableBase } from '../../../../core/types/flows/interop';
+import type { DstChain, InteropWaitable, InteropHandle } from './types';
+
+/** Resolve a destination chain input (URL string or provider) into an AbstractProvider. */
+export function resolveDstProvider(dstChain: DstChain): AbstractProvider {
+ return typeof dstChain === 'string' ? new JsonRpcProvider(dstChain) : dstChain;
+}
+
+export function resolveWaitableInput(waitableInput: InteropWaitable): {
+ dstProvider: AbstractProvider;
+ waitable: InteropWaitableBase;
+} {
+ const input = waitableInput as { waitable?: InteropWaitableBase };
+ return {
+ dstProvider: resolveDstProvider(waitableInput.dstChain),
+ waitable: input.waitable ? input.waitable : (waitableInput as InteropHandle),
+ };
+}
diff --git a/src/adapters/ethers/resources/interop/routes/direct.ts b/src/adapters/ethers/resources/interop/routes/direct.ts
new file mode 100644
index 0000000..1245e33
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/routes/direct.ts
@@ -0,0 +1,70 @@
+import type { InteropParams } from '../../../../../core/types/flows/interop';
+import type { BuildCtx } from '../context';
+import type { TransactionRequest } from 'ethers';
+import type { InteropRouteStrategy } from './types';
+import { buildDirectBundle, preflightDirect } from '../../../../../core/resources/interop/plan';
+import { interopCodec } from '../address';
+import { getInteropAttributes } from '../attributes/resource';
+
+export function routeDirect(): InteropRouteStrategy {
+ return {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async preflight(params: InteropParams, ctx: BuildCtx) {
+ preflightDirect(params, {
+ dstChainId: ctx.dstChainId,
+ baseTokens: ctx.baseTokens,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ codec: interopCodec,
+ });
+ },
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async build(params: InteropParams, ctx: BuildCtx) {
+ const steps: Array<{
+ key: string;
+ kind: string;
+ description: string;
+ tx: TransactionRequest;
+ }> = [];
+
+ const attrs = getInteropAttributes(params, ctx);
+ const built = buildDirectBundle(
+ params,
+ {
+ dstChainId: ctx.dstChainId,
+ baseTokens: ctx.baseTokens,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ codec: interopCodec,
+ },
+ attrs,
+ );
+
+ const data = ctx.ifaces.interopCenter.encodeFunctionData('sendBundle', [
+ built.dstChain,
+ built.starters,
+ built.bundleAttributes,
+ ]);
+
+ steps.push({
+ key: 'sendBundle',
+ kind: 'interop.center',
+ description: `Send interop bundle (direct route; ${params.actions.length} actions)`,
+ // In direct route, msg.value equals the total forwarded value across
+ // all calls (sendNative.amount + call.value).
+ tx: {
+ to: ctx.interopCenter,
+ data,
+ value: built.quoteExtras.totalActionValue,
+ ...ctx.gasOverrides,
+ },
+ });
+
+ return {
+ steps,
+ approvals: built.approvals,
+ quoteExtras: built.quoteExtras,
+ };
+ },
+ };
+}
diff --git a/src/adapters/ethers/resources/interop/routes/indirect.ts b/src/adapters/ethers/resources/interop/routes/indirect.ts
new file mode 100644
index 0000000..6fb0d02
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/routes/indirect.ts
@@ -0,0 +1,105 @@
+import { Contract, type TransactionRequest } from 'ethers';
+import type { Hex } from '../../../../../core/types/primitives';
+import type { InteropParams } from '../../../../../core/types/flows/interop';
+import type { BuildCtx } from '../context';
+import type { InteropRouteStrategy } from './types';
+import { IERC20ABI } from '../../../../../core/abi';
+import { buildIndirectBundle, preflightIndirect } from '../../../../../core/resources/interop/plan';
+import { interopCodec } from '../address';
+import { getErc20Tokens, buildEnsureTokenSteps, resolveErc20AssetIds } from '../services/erc20';
+import { getStarterData } from '../services/starter-data';
+import { getInteropAttributes } from '../attributes/resource';
+
+export function routeIndirect(): InteropRouteStrategy {
+ return {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async preflight(params: InteropParams, ctx: BuildCtx) {
+ preflightIndirect(params, {
+ dstChainId: ctx.dstChainId,
+ baseTokens: ctx.baseTokens,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ codec: interopCodec,
+ });
+ },
+ async build(params: InteropParams, ctx: BuildCtx) {
+ const steps: Array<{
+ key: string;
+ kind: string;
+ description: string;
+ tx: TransactionRequest;
+ }> = [];
+
+ const erc20Tokens = getErc20Tokens(params);
+ const erc20AssetIds = await resolveErc20AssetIds(erc20Tokens, ctx);
+ const attributes = getInteropAttributes(params, ctx);
+ const starterData = await getStarterData(params, ctx, erc20AssetIds);
+ const bundle = buildIndirectBundle(
+ params,
+ {
+ dstChainId: ctx.dstChainId,
+ baseTokens: ctx.baseTokens,
+ l2AssetRouter: ctx.l2AssetRouter,
+ l2NativeTokenVault: ctx.l2NativeTokenVault,
+ codec: interopCodec,
+ },
+ attributes,
+ starterData,
+ );
+
+ // Explicit registration steps keep quote/prepare side-effect free.
+ steps.push(...buildEnsureTokenSteps(erc20Tokens, ctx));
+
+ // Check allowance and only approve when needed.
+ for (const approval of bundle.approvals) {
+ const erc20 = new Contract(approval.token, IERC20ABI, ctx.client.l2);
+ const currentAllowance = (await erc20.allowance(
+ ctx.sender,
+ ctx.l2NativeTokenVault,
+ )) as bigint;
+
+ if (currentAllowance < approval.amount) {
+ const approveData = erc20.interface.encodeFunctionData('approve', [
+ ctx.l2NativeTokenVault,
+ approval.amount,
+ ]) as Hex;
+
+ steps.push({
+ key: `approve:${approval.token}:${ctx.l2NativeTokenVault}`,
+ kind: 'approve',
+ description: `Approve ${ctx.l2NativeTokenVault} to spend ${approval.amount} of ${approval.token}`,
+ tx: {
+ to: approval.token,
+ data: approveData,
+ ...ctx.gasOverrides,
+ },
+ });
+ }
+ }
+
+ const data = ctx.ifaces.interopCenter.encodeFunctionData('sendBundle', [
+ bundle.dstChain,
+ bundle.starters,
+ bundle.bundleAttributes,
+ ]) as Hex;
+
+ steps.push({
+ key: 'sendBundle',
+ kind: 'interop.center',
+ description: 'Send interop bundle (indirect route)',
+ tx: {
+ to: ctx.interopCenter,
+ data,
+ value: bundle.quoteExtras.totalActionValue,
+ ...ctx.gasOverrides,
+ },
+ });
+
+ return {
+ steps,
+ approvals: bundle.approvals,
+ quoteExtras: bundle.quoteExtras,
+ };
+ },
+ };
+}
diff --git a/src/adapters/ethers/resources/interop/routes/types.ts b/src/adapters/ethers/resources/interop/routes/types.ts
new file mode 100644
index 0000000..bd089ca
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/routes/types.ts
@@ -0,0 +1,21 @@
+// src/adapters/ethers/resources/interop/routes/types.ts
+import type { TransactionRequest } from 'ethers';
+import type { InteropParams } from '../../../../../core/types/flows/interop';
+import type { BuildCtx } from '../context';
+import type { PlanStep, ApprovalNeed } from '../../../../../core/types/flows/base';
+import type { QuoteExtras } from '../../../../../core/types/flows/interop';
+
+export interface InteropRouteStrategy {
+ // Preflight checks. Throw with a descriptive message on invalid inputs.
+ preflight(params: InteropParams, ctx: BuildCtx): Promise;
+
+ // Build the plan steps + approvals + quote extras.
+ build(
+ params: InteropParams,
+ ctx: BuildCtx,
+ ): Promise<{
+ steps: Array>;
+ approvals: ApprovalNeed[];
+ quoteExtras: QuoteExtras;
+ }>;
+}
diff --git a/src/adapters/ethers/resources/interop/services/erc20.ts b/src/adapters/ethers/resources/interop/services/erc20.ts
new file mode 100644
index 0000000..e905e95
--- /dev/null
+++ b/src/adapters/ethers/resources/interop/services/erc20.ts
@@ -0,0 +1,64 @@
+// src/adapters/ethers/resources/interop/services/erc20.ts
+//
+// ERC-20 helpers for indirect interop routes.
+// Extracted so that route files stay focused on preflight + build flow.
+
+import { Contract, type TransactionRequest } from 'ethers';
+import type { Address, Hex } from '../../../../../core/types/primitives';
+import type { InteropParams } from '../../../../../core/types/flows/interop';
+import type { BuildCtx } from '../context';
+import { L2NativeTokenVaultABI } from '../../../../../core/abi';
+
+/** Collect unique ERC-20 token addresses referenced by `sendErc20` actions. */
+export function getErc20Tokens(params: InteropParams): Address[] {
+ const erc20Tokens = new Map();
+ for (const action of params.actions) {
+ if (action.type !== 'sendErc20') continue;
+ erc20Tokens.set(action.token.toLowerCase(), action.token);
+ }
+ return Array.from(erc20Tokens.values());
+}
+
+/** Build NTV `ensureTokenIsRegistered` steps for each ERC-20 token. */
+export function buildEnsureTokenSteps(
+ erc20Tokens: Address[],
+ ctx: BuildCtx,
+): Array<{
+ key: string;
+ kind: string;
+ description: string;
+ tx: TransactionRequest;
+}> {
+ if (erc20Tokens.length === 0) return [];
+
+ const ntv = new Contract(ctx.l2NativeTokenVault, L2NativeTokenVaultABI, ctx.client.l2);
+
+ return erc20Tokens.map((token) => ({
+ key: `ensure-token:${token.toLowerCase()}`,
+ kind: 'interop.ntv.ensure-token',
+ description: `Ensure ${token} is registered in the native token vault`,
+ tx: {
+ to: ctx.l2NativeTokenVault,
+ data: ntv.interface.encodeFunctionData('ensureTokenIsRegistered', [token]) as Hex,
+ ...ctx.gasOverrides,
+ },
+ }));
+}
+
+/** Resolve asset IDs for each ERC-20 token via a static-call to NTV. */
+export async function resolveErc20AssetIds(
+ erc20Tokens: Address[],
+ ctx: BuildCtx,
+): Promise