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> { + const assetIds = new Map(); + if (erc20Tokens.length === 0) return assetIds; + + const ntv = new Contract(ctx.l2NativeTokenVault, L2NativeTokenVaultABI, ctx.client.getL2Signer()); + + for (const token of erc20Tokens) { + const assetId = (await ntv.getFunction('ensureTokenIsRegistered').staticCall(token)) as Hex; + assetIds.set(token.toLowerCase(), assetId); + } + + return assetIds; +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/bundle.ts b/src/adapters/ethers/resources/interop/services/finalization/bundle.ts new file mode 100644 index 0000000..0e77f5e --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/bundle.ts @@ -0,0 +1,125 @@ +import { + Contract, + type AbstractProvider, + type TransactionResponse, + type TransactionReceipt, +} from 'ethers'; +import type { Hex } from '../../../../../../core/types/primitives'; +import type { InteropFinalizationInfo } from '../../../../../../core/types/flows/interop'; +import type { EthersClient } from '../../../../client'; +import { createErrorHandlers, toZKsyncError } from '../../../../errors/error-ops'; +import { OP_INTEROP } from '../../../../../../core/types'; +import { createError } from '../../../../../../core/errors/factory'; +import { isZKsyncError } from '../../../../../../core/types/errors'; +import IInteropHandlerAbi from '../../../../../../core/internal/abis/IInteropHandler'; +import { getTopics } from './topics'; +import type { InteropPhase } from '../../../../../../core/types/flows/interop'; +import type { InteropTopics } from '../../../../../../core/resources/interop/events'; +import { getLogs, type LogsQueryOptions } from './data-fetchers'; + +const { wrap } = createErrorHandlers('interop'); + +export async function getBundleStatus( + client: EthersClient, + dstProvider: AbstractProvider, + topics: InteropTopics, + bundleHash: Hex, + opts?: LogsQueryOptions, +): Promise<{ phase: InteropPhase; dstExecTxHash?: Hex }> { + const { interopHandler } = await client.ensureAddresses(); + // Single call: filter only by bundleHash (topic1), then classify via topic0 locally. + const bundleLogs = await getLogs(dstProvider, interopHandler, [null, bundleHash], opts); + + const findLastByTopic = (eventTopic: Hex) => + bundleLogs.findLast((log) => log.topics[0].toLowerCase() === eventTopic.toLowerCase()); + + const lifecycleChecks: Array<{ phase: InteropPhase; topic: Hex; includeTxHash?: boolean }> = [ + { phase: 'UNBUNDLED', topic: topics.bundleUnbundled, includeTxHash: true }, + { phase: 'EXECUTED', topic: topics.bundleExecuted, includeTxHash: true }, + { phase: 'VERIFIED', topic: topics.bundleVerified }, + ]; + + for (const check of lifecycleChecks) { + const match = findLastByTopic(check.topic); + if (!match) continue; + + if (check.includeTxHash) { + return { phase: check.phase, dstExecTxHash: match.transactionHash }; + } + return { phase: check.phase }; + } + + return { phase: 'SENT' }; +} + +export async function executeBundle( + client: EthersClient, + dstProvider: AbstractProvider, + info: InteropFinalizationInfo, + opts?: LogsQueryOptions, +): Promise<{ hash: Hex; wait: () => Promise }> { + const { topics } = getTopics(); + const { bundleHash, encodedData, proof } = info; + + const dstStatus = await getBundleStatus(client, dstProvider, topics, bundleHash, opts); + + if (['EXECUTED', 'UNBUNDLED'].includes(dstStatus.phase)) { + throw createError('STATE', { + resource: 'interop', + operation: OP_INTEROP.finalize, + message: `Interop bundle has already been ${dstStatus.phase.toLowerCase()}.`, + context: { bundleHash }, + }); + } + + const signer = await wrap(OP_INTEROP.exec.sendStep, () => client.signerFor(dstProvider), { + message: 'Failed to resolve destination signer.', + }); + + const { interopHandler } = await client.ensureAddresses(); + + const handler = new Contract(interopHandler, IInteropHandlerAbi, signer); + try { + const txResponse = (await handler.executeBundle(encodedData, proof)) as TransactionResponse; + const hash = txResponse.hash as Hex; + return { + hash, + wait: async () => { + try { + const receipt = await txResponse.wait(); + if (!receipt || receipt.status !== 1) { + throw createError('EXECUTION', { + resource: 'interop', + operation: OP_INTEROP.exec.waitStep, + message: 'Interop bundle execution reverted on destination.', + context: { txHash: hash }, + }); + } + return receipt; + } catch (e) { + if (isZKsyncError(e)) throw e; + throw toZKsyncError( + 'EXECUTION', + { + resource: 'interop', + operation: OP_INTEROP.exec.waitStep, + message: 'Failed while waiting for executeBundle transaction on destination.', + context: { txHash: hash }, + }, + e, + ); + } + }, + }; + } catch (e) { + throw toZKsyncError( + 'EXECUTION', + { + resource: 'interop', + operation: OP_INTEROP.exec.sendStep, + message: 'Failed to send executeBundle transaction on destination chain.', + }, + e, + ); + } +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/data-fetchers.ts b/src/adapters/ethers/resources/interop/services/finalization/data-fetchers.ts new file mode 100644 index 0000000..659686b --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/data-fetchers.ts @@ -0,0 +1,139 @@ +import { Contract, isError, type AbstractProvider } from 'ethers'; +import type { Address, Hex } from '../../../../../../core/types/primitives'; +import type { Log } from '../../../../../../core/types/transactions'; +import { createErrorHandlers } from '../../../../errors/error-ops'; +import { OP_INTEROP } from '../../../../../../core/types'; +import { InteropRootStorageABI } from '../../../../../../core/abi'; +import { L2_INTEROP_ROOT_STORAGE_ADDRESS } from '../../../../../../core/constants'; + +const { wrap } = createErrorHandlers('interop'); +const DEFAULT_BLOCKS_RANGE_SIZE = 10_000; +const DEFAULT_MAX_BLOCKS_BACK = 20_000; +const SAFE_BLOCKS_RANGE_SIZE = 1_000; + +export interface LogsQueryOptions { + maxBlocksBack?: number; + logChunkSize?: number; +} + +// Server returns an error if the there is a block range limit and the requested range exceeds it. +// The error returned in such case is UNKNOWN_ERROR with a message containing "query exceeds max block range {limit}". +function parseMaxBlockRangeLimit(error: unknown): number | null { + if (!isError(error, 'UNKNOWN_ERROR')) return null; + if (!error.error || typeof error.error !== 'object') return null; + + const match = /query exceeds max block range\s+(\d+)/i.exec(error.error?.message); + if (!match) return null; + + const limit = Number.parseInt(match[1], 10); + return Number.isInteger(limit) && limit > 0 ? limit : null; +} + +export async function getTxReceipt(provider: AbstractProvider, txHash: Hex) { + const receipt = await wrap( + OP_INTEROP.svc.status.sourceReceipt, + () => provider.getTransactionReceipt(txHash), + { + ctx: { where: 'l2.getTransactionReceipt', l2SrcTxHash: txHash }, + message: 'Failed to fetch source L2 receipt for interop tx.', + }, + ); + if (!receipt) return null; + return { + logs: receipt.logs.map((log) => ({ + address: log.address as Address, + topics: log.topics as Hex[], + data: log.data as Hex, + transactionHash: log.transactionHash as Hex, + })), + }; +} + +export async function getLogs( + provider: AbstractProvider, + address: Address, + topics: Array, + opts?: LogsQueryOptions, +): Promise { + const maxBlocksBack = opts?.maxBlocksBack ?? DEFAULT_MAX_BLOCKS_BACK; + const initialChunkSize = opts?.logChunkSize ?? DEFAULT_BLOCKS_RANGE_SIZE; + + return await wrap( + OP_INTEROP.svc.status.dstLogs, + async () => { + const currentBlock = await provider.getBlockNumber(); + const minBlock = Math.max(0, currentBlock - maxBlocksBack); + + let toBlock = currentBlock; + let chunkSize = initialChunkSize; + + while (toBlock >= minBlock) { + const fromBlock = Math.max(minBlock, toBlock - chunkSize + 1); + + try { + const rawLogs = await provider.getLogs({ + address, + topics, + fromBlock, + toBlock, + }); + + if (rawLogs.length > 0) { + return rawLogs.map((log) => ({ + address: log.address as Address, + topics: log.topics as Hex[], + data: log.data as Hex, + transactionHash: log.transactionHash as Hex, + })); + } + + toBlock = fromBlock - 1; + } catch (error) { + // If the error is due to exceeding the server's max block range, reduce the chunk size and retry. + const serverLimit = parseMaxBlockRangeLimit(error); + if (serverLimit == null) { + // In case the error message cannot be parsed or a different error message format is returned by + // a provider, try once again with a small chunk size. + if (chunkSize > SAFE_BLOCKS_RANGE_SIZE) { + chunkSize = SAFE_BLOCKS_RANGE_SIZE; + } else { + // If we can't determine the server limit and the safe limit doesn't work, rethrow the error. + throw error; + } + } else { + chunkSize = Math.min(chunkSize, serverLimit); + } + } + } + + return []; + }, + { + ctx: { address, maxBlocksBack, logChunkSize: initialChunkSize }, + message: 'Failed to query destination bundle lifecycle logs.', + }, + ); +} + +export async function getInteropRoot( + provider: AbstractProvider, + rootChainId: bigint, + batchNumber: bigint, +): Promise { + return await wrap( + OP_INTEROP.svc.status.getRoot, + async () => { + const rootStorage = new Contract( + L2_INTEROP_ROOT_STORAGE_ADDRESS, + InteropRootStorageABI, + provider, + ); + + return (await rootStorage.interopRoots(rootChainId, batchNumber)) as Hex; + }, + { + ctx: { rootChainId, batchNumber }, + message: 'Failed to get interop root from the destination chain.', + }, + ); +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/decoders.ts b/src/adapters/ethers/resources/interop/services/finalization/decoders.ts new file mode 100644 index 0000000..4359d43 --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/decoders.ts @@ -0,0 +1,35 @@ +import { AbiCoder, type Interface } from 'ethers'; +import type { Hex } from '../../../../../../core/types/primitives'; +import type { Log } from '../../../../../../core/types/transactions'; + +export function decodeInteropBundleSent( + centerIface: Interface, + log: { data: Hex; topics: Hex[] }, +): { + bundleHash: Hex; + sourceChainId: bigint; + destinationChainId: bigint; +} { + const decoded = centerIface.decodeEventLog( + 'InteropBundleSent', + log.data, + log.topics, + ) as unknown as { + interopBundleHash: Hex; + interopBundle: { + sourceChainId: bigint; + destinationChainId: bigint; + }; + }; + + return { + bundleHash: decoded.interopBundleHash, + sourceChainId: decoded.interopBundle.sourceChainId, + destinationChainId: decoded.interopBundle.destinationChainId, + }; +} + +export function decodeL1MessageData(log: Log): Hex { + const decoded = AbiCoder.defaultAbiCoder().decode(['bytes'], log.data); + return decoded[0] as Hex; +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/index.ts b/src/adapters/ethers/resources/interop/services/finalization/index.ts new file mode 100644 index 0000000..4f98795 --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/index.ts @@ -0,0 +1,54 @@ +import type { AbstractProvider } from 'ethers'; +import type { + InteropStatus, + InteropWaitable, + InteropFinalizationInfo, + InteropFinalizationResult, +} from '../../../../../../core/types/flows/interop'; +import type { EthersClient } from '../../../../client'; +import { executeBundle } from './bundle'; +import { waitForFinalization } from './polling'; +import { getStatus } from './status'; +import type { LogsQueryOptions } from './data-fetchers'; + +export interface InteropFinalizationServices { + status( + dstProvider: AbstractProvider, + input: InteropWaitable, + opts?: LogsQueryOptions, + ): Promise; + wait( + dstProvider: AbstractProvider, + input: InteropWaitable, + opts?: { pollMs?: number; timeoutMs?: number }, + ): Promise; + finalize( + dstProvider: AbstractProvider, + info: InteropFinalizationInfo, + opts?: LogsQueryOptions, + ): Promise; +} + +export function createInteropFinalizationServices( + client: EthersClient, +): InteropFinalizationServices { + return { + status(dstProvider, input, opts) { + return getStatus(client, dstProvider, input, opts); + }, + + wait(dstProvider, input, opts) { + return waitForFinalization(client, dstProvider, input, opts); + }, + + async finalize(dstProvider, info, opts) { + const execResult = await executeBundle(client, dstProvider, info, opts); + await execResult.wait(); + + return { + bundleHash: info.bundleHash, + dstExecTxHash: execResult.hash, + }; + }, + }; +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/polling.ts b/src/adapters/ethers/resources/interop/services/finalization/polling.ts new file mode 100644 index 0000000..a1463c6 --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/polling.ts @@ -0,0 +1,234 @@ +import type { AbstractProvider } from 'ethers'; +import type { Hex } from '../../../../../../core/types/primitives'; +import type { + InteropWaitable, + InteropFinalizationInfo, +} from '../../../../../../core/types/flows/interop'; +import type { EthersClient } from '../../../../client'; +import { createErrorHandlers } from '../../../../errors/error-ops'; +import { createError } from '../../../../../../core/errors/factory'; +import { OP_INTEROP } from '../../../../../../core/types'; +import { isZKsyncError } from '../../../../../../core/types/errors'; +import { ZERO_HASH } from '../../../../../../core/types/primitives'; +import { sleep } from '../../../../../../core/utils'; +import { + resolveIdsFromWaitable, + parseBundleReceiptInfo, + buildFinalizationInfo, + DEFAULT_POLL_MS, + DEFAULT_TIMEOUT_MS, + type BundleReceiptInfo, +} from '../../../../../../core/resources/interop/finalization'; +import { getTopics } from './topics'; +import { decodeInteropBundleSent, decodeL1MessageData } from './decoders'; +import { getInteropRoot } from './data-fetchers'; +import { type ReceiptWithL2ToL1 } from '../../../../../../core/rpc/types'; + +const { wrap } = createErrorHandlers('interop'); + +function isProofNotReadyError(error: unknown): boolean { + return isZKsyncError(error, { + operation: 'zksrpc.getL2ToL1LogProof', + messageIncludes: 'proof not yet available', + }); +} + +function shouldRetryRootFetch(error: unknown): boolean { + if (!isZKsyncError(error)) return false; + return error.envelope.operation === OP_INTEROP.svc.status.getRoot; +} + +async function waitForProof( + client: EthersClient, + l2SrcTxHash: Hex, + logIndex: number, + blockNumber: bigint, + pollMs: number, + deadline: number, +) { + // Wait for the block to be finalized first + while (true) { + if (Date.now() > deadline) { + throw createError('TIMEOUT', { + resource: 'interop', + operation: OP_INTEROP.svc.wait.timeout, + message: 'Timed out waiting for block to be finalized.', + context: { l2SrcTxHash, logIndex, blockNumber }, + }); + } + + const finalizedBlock = await client.l2.getBlock('finalized'); + if (finalizedBlock && BigInt(finalizedBlock.number) >= blockNumber) { + break; + } + + await sleep(pollMs); + } + + // Block is finalized, poll proof until available. + while (true) { + if (Date.now() > deadline) { + throw createError('TIMEOUT', { + resource: 'interop', + operation: OP_INTEROP.svc.wait.timeout, + message: 'Timed out waiting for L2->L1 log proof to become available.', + context: { l2SrcTxHash, logIndex }, + }); + } + + try { + return await client.zks.getL2ToL1LogProof(l2SrcTxHash, logIndex); + } catch (error) { + if (!isProofNotReadyError(error)) throw error; + } + + await sleep(pollMs); + } +} + +async function waitForRoot( + provider: AbstractProvider, + expectedRoot: { rootChainId: bigint; batchNumber: bigint; expectedRoot: Hex }, + pollMs: number, + deadline: number, +): Promise { + while (true) { + if (Date.now() > deadline) { + throw createError('TIMEOUT', { + resource: 'interop', + operation: OP_INTEROP.svc.wait.timeout, + message: 'Timed out waiting for interop root to become available.', + context: { expectedRoot }, + }); + } + + let interopRoot: Hex | null = null; + try { + const root = await getInteropRoot( + provider, + expectedRoot.rootChainId, + expectedRoot.batchNumber, + ); + if (root !== ZERO_HASH) { + interopRoot = root; + } + } catch (error) { + if (!shouldRetryRootFetch(error)) throw error; + interopRoot = null; + } + + if (interopRoot) { + if (interopRoot.toLowerCase() === expectedRoot.expectedRoot.toLowerCase()) { + return; + } + throw createError('STATE', { + resource: 'interop', + operation: OP_INTEROP.wait, + message: 'Interop root mismatch.', + context: { + expected: expectedRoot.expectedRoot, + got: interopRoot, + }, + }); + } + + await sleep(pollMs); + } +} + +async function waitForTxReceipt( + client: EthersClient, + txHash: Hex, + pollMs: number, + deadline: number, +): Promise { + while (true) { + if (Date.now() > deadline) { + throw createError('TIMEOUT', { + resource: 'interop', + operation: OP_INTEROP.svc.wait.timeout, + message: 'Timed out waiting for source receipt to be available.', + context: { txHash }, + }); + } + + const receipt = await wrap( + OP_INTEROP.svc.status.sourceReceipt, + () => client.zks.getReceiptWithL2ToL1(txHash), + { + ctx: { where: 'zks.getReceiptWithL2ToL1', txHash }, + message: 'Failed to fetch source L2 receipt (with L2->L1 logs) for interop tx.', + }, + ); + + if (receipt) { + return receipt; + } + + await sleep(pollMs); + } +} + +export async function waitForFinalization( + client: EthersClient, + dstProvider: AbstractProvider, + input: InteropWaitable, + opts?: { pollMs?: number; timeoutMs?: number }, +): Promise { + const { topics, centerIface } = getTopics(); + const pollMs = opts?.pollMs ?? DEFAULT_POLL_MS; + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + const ids = resolveIdsFromWaitable(input); + if (!ids.l2SrcTxHash) { + throw createError('STATE', { + resource: 'interop', + operation: OP_INTEROP.svc.status.sourceReceipt, + message: 'Cannot wait for interop finalization: missing l2SrcTxHash.', + context: { input }, + }); + } + + const { interopCenter } = await client.ensureAddresses(); + let bundleInfo: BundleReceiptInfo | null = null; + while (!bundleInfo) { + if (Date.now() > deadline) { + throw createError('TIMEOUT', { + resource: 'interop', + operation: OP_INTEROP.svc.wait.timeout, + message: 'Timed out waiting for source receipt to be available.', + context: { l2SrcTxHash: ids.l2SrcTxHash }, + }); + } + const txReceipt = await waitForTxReceipt(client, ids.l2SrcTxHash, pollMs, deadline); + bundleInfo = parseBundleReceiptInfo({ + rawReceipt: txReceipt, + interopCenter, + interopBundleSentTopic: topics.interopBundleSent, + decodeInteropBundleSent: (log) => decodeInteropBundleSent(centerIface, log), + decodeL1MessageData, + l2SrcTxHash: ids.l2SrcTxHash, + }); + } + + const proof = await waitForProof( + client, + ids.l2SrcTxHash, + bundleInfo.l2ToL1LogIndex, + BigInt(bundleInfo.rawReceipt.blockNumber!), + pollMs, + deadline, + ); + + const finalizationInfo = buildFinalizationInfo( + { l2SrcTxHash: ids.l2SrcTxHash, bundleHash: ids.bundleHash }, + bundleInfo, + proof, + bundleInfo.l1MessageData, + ); + + await waitForRoot(dstProvider, finalizationInfo.expectedRoot, pollMs, deadline); + + return finalizationInfo; +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/status.ts b/src/adapters/ethers/resources/interop/services/finalization/status.ts new file mode 100644 index 0000000..c520d98 --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/status.ts @@ -0,0 +1,64 @@ +import type { AbstractProvider } from 'ethers'; +import type { + InteropStatus, + InteropWaitable, + InteropPhase, +} from '../../../../../../core/types/flows/interop'; +import type { Log } from '../../../../../../core/types/transactions'; +import type { EthersClient } from '../../../../client'; +import { + resolveIdsFromWaitable, + parseBundleSentFromReceipt, +} from '../../../../../../core/resources/interop/finalization'; +import { getTopics } from './topics'; +import { decodeInteropBundleSent } from './decoders'; +import { getTxReceipt } from './data-fetchers'; +import { getBundleStatus } from './bundle'; +import type { LogsQueryOptions } from './data-fetchers'; + +export async function getStatus( + client: EthersClient, + dstProvider: AbstractProvider, + input: InteropWaitable, + opts?: LogsQueryOptions, +): Promise { + const { topics, centerIface } = getTopics(); + const baseIds = resolveIdsFromWaitable(input); + + const enrichedIds = await (async () => { + if (baseIds.bundleHash) return baseIds; + if (!baseIds.l2SrcTxHash) return baseIds; + + const { interopCenter } = await client.ensureAddresses(); + const receipt = await getTxReceipt(client.l2, baseIds.l2SrcTxHash); + if (!receipt) return baseIds; + + const { bundleHash } = parseBundleSentFromReceipt({ + receipt: { logs: receipt.logs as Log[] }, + interopCenter, + interopBundleSentTopic: topics.interopBundleSent, + decodeInteropBundleSent: (log) => decodeInteropBundleSent(centerIface, log), + }); + + return { ...baseIds, bundleHash }; + })(); + + if (!enrichedIds.bundleHash) { + const phase: InteropPhase = enrichedIds.l2SrcTxHash ? 'SENT' : 'UNKNOWN'; + return { + phase, + l2SrcTxHash: enrichedIds.l2SrcTxHash, + bundleHash: enrichedIds.bundleHash, + dstExecTxHash: enrichedIds.dstExecTxHash, + }; + } + + const dstInfo = await getBundleStatus(client, dstProvider, topics, enrichedIds.bundleHash, opts); + + return { + phase: dstInfo.phase, + l2SrcTxHash: enrichedIds.l2SrcTxHash, + bundleHash: enrichedIds.bundleHash, + dstExecTxHash: dstInfo.dstExecTxHash ?? enrichedIds.dstExecTxHash, + }; +} diff --git a/src/adapters/ethers/resources/interop/services/finalization/topics.ts b/src/adapters/ethers/resources/interop/services/finalization/topics.ts new file mode 100644 index 0000000..525c27a --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/finalization/topics.ts @@ -0,0 +1,20 @@ +import { Interface } from 'ethers'; +import type { InteropTopics } from '../../../../../../core/resources/interop/events'; +import InteropCenterAbi from '../../../../../../core/internal/abis/InteropCenter'; +import IInteropHandlerAbi from '../../../../../../core/internal/abis/IInteropHandler'; +import type { Hex } from '../../../../../../core'; + +// Event topics and decoding +export function getTopics(): { topics: InteropTopics; centerIface: Interface } { + const centerIface = new Interface(InteropCenterAbi); + const handlerIface = new Interface(IInteropHandlerAbi); + + const topics = { + interopBundleSent: centerIface.getEvent('InteropBundleSent')!.topicHash as Hex, + bundleVerified: handlerIface.getEvent('BundleVerified')!.topicHash as Hex, + bundleExecuted: handlerIface.getEvent('BundleExecuted')!.topicHash as Hex, + bundleUnbundled: handlerIface.getEvent('BundleUnbundled')!.topicHash as Hex, + }; + + return { topics, centerIface }; +} diff --git a/src/adapters/ethers/resources/interop/services/starter-data.ts b/src/adapters/ethers/resources/interop/services/starter-data.ts new file mode 100644 index 0000000..bb66433 --- /dev/null +++ b/src/adapters/ethers/resources/interop/services/starter-data.ts @@ -0,0 +1,60 @@ +// src/adapters/ethers/resources/interop/services/starter-data.ts +// +// Builds interop starter data for all action types in a bundle. + +import type { Hex } from '../../../../../core/types/primitives'; +import type { InteropParams } from '../../../../../core/types/flows/interop'; +import type { BuildCtx } from '../context'; +import type { InteropStarterData } from '../../../../../core/resources/interop/plan'; +import { encodeNativeTokenVaultTransferData, encodeSecondBridgeDataV1 } from '../../utils'; +import { assertNever } from '../../../../../core/utils'; + +/** Build interop starter data for all actions in the bundle. */ +export async function getStarterData( + params: InteropParams, + ctx: BuildCtx, + erc20AssetIds: Map, +): Promise { + const starterData: InteropStarterData[] = []; + + for (const action of params.actions) { + switch (action.type) { + case 'sendErc20': { + const assetId = erc20AssetIds.get(action.token.toLowerCase()); + if (!assetId) { + throw new Error(`Missing precomputed assetId for token ${action.token}.`); + } + + const transferData = encodeNativeTokenVaultTransferData( + action.amount, + action.to, + action.token, + ); + const assetRouterPayload = encodeSecondBridgeDataV1(assetId, transferData) as Hex; + starterData.push({ assetRouterPayload }); + break; + } + case 'sendNative': + if (!ctx.baseTokens.matches) { + const assetId = await ctx.tokens.baseTokenAssetId(); + const transferData = encodeNativeTokenVaultTransferData( + action.amount, + action.to, + ctx.baseTokens.src, + ); + const assetRouterPayload = encodeSecondBridgeDataV1(assetId, transferData) as Hex; + starterData.push({ assetRouterPayload }); + } else { + starterData.push({}); + } + break; + case 'call': + starterData.push({}); + break; + default: + assertNever(action); + } + } + + return starterData; +} diff --git a/src/adapters/ethers/resources/interop/types.ts b/src/adapters/ethers/resources/interop/types.ts new file mode 100644 index 0000000..82ee110 --- /dev/null +++ b/src/adapters/ethers/resources/interop/types.ts @@ -0,0 +1,25 @@ +import type { AbstractProvider } from 'ethers'; +import type { + InteropParams as InteropParamsBase, + InteropHandle as InteropHandleBase, + InteropWaitable as InteropWaitableBase, + InteropFinalizationInfo as InteropFinalizationInfoBase, +} from '../../../../core/types/flows/interop'; + +export type DstChain = string | AbstractProvider; + +export interface InteropParams extends InteropParamsBase { + dstChain: DstChain; +} + +export interface InteropHandle extends InteropHandleBase { + dstChain: DstChain; +} + +export interface InteropFinalizationInfo extends InteropFinalizationInfoBase { + dstChain: DstChain; +} + +export type InteropWaitable = + | InteropHandle + | { dstChain: DstChain; waitable: InteropWaitableBase }; diff --git a/src/adapters/ethers/resources/withdrawals/services/finalization.ts b/src/adapters/ethers/resources/withdrawals/services/finalization.ts index c68f278..b0016ee 100644 --- a/src/adapters/ethers/resources/withdrawals/services/finalization.ts +++ b/src/adapters/ethers/resources/withdrawals/services/finalization.ts @@ -172,29 +172,12 @@ export function createFinalizationServices(client: EthersClient): FinalizationSe merkleProof: proof.proof, }; - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); return { params, nullifier: l1Nullifier }; }, async simulateFinalizeReadiness(params: FinalizeDepositParams): Promise { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.readiness.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); - + const { l1Nullifier } = await client.ensureAddresses(); // check if the withdrawal is already finalized const done = await (async (): Promise => { try { @@ -234,15 +217,7 @@ export function createFinalizationServices(client: EthersClient): FinalizationSe }, async isWithdrawalFinalized(key: WithdrawalKey) { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); const c = new Contract(l1Nullifier, IL1NullifierMini, l1); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return await wrapAs( @@ -257,15 +232,7 @@ export function createFinalizationServices(client: EthersClient): FinalizationSe }, async estimateFinalization(params: FinalizeDepositParams): Promise { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.estimate, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); const signer = client.getL1Signer(); const c = new Contract(l1Nullifier, IL1NullifierABI, signer); @@ -315,15 +282,7 @@ export function createFinalizationServices(client: EthersClient): FinalizationSe }, async finalizeDeposit(params: FinalizeDepositParams) { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); const c = new Contract(l1Nullifier, IL1NullifierABI, signer); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/src/adapters/ethers/sdk.ts b/src/adapters/ethers/sdk.ts index 934d00a..f0e03d6 100644 --- a/src/adapters/ethers/sdk.ts +++ b/src/adapters/ethers/sdk.ts @@ -1,34 +1,32 @@ // src/adapters/ethers/sdk.ts import type { EthersClient } from './client'; -import { - createDepositsResource, - type DepositsResource as DepositsResourceType, -} from './resources/deposits/index'; -import { - createWithdrawalsResource, - type WithdrawalsResource as WithdrawalsResourceType, -} from './resources/withdrawals/index'; +import { createDepositsResource, type DepositsResource } from './resources/deposits/index'; +import { createWithdrawalsResource, type WithdrawalsResource } from './resources/withdrawals/index'; +import { createInteropResource, type InteropResource } from './resources/interop/index'; import { createTokensResource } from './resources/tokens/index'; -import type { TokensResource as TokensResourceType } from '../../core/types/flows/token'; -import type { ContractsResource as ContractsResourceType } from './resources/contracts/index'; +import type { TokensResource } from '../../core/types/flows/token'; +import type { ContractsResource } from './resources/contracts/index'; import { createContractsResource } from './resources/contracts/index'; -// SDK interface, combining deposits, withdrawals, tokens, and contracts +// SDK interface, combining deposits, withdrawals, tokens, contracts, and interop export interface EthersSdk { - deposits: DepositsResourceType; - withdrawals: WithdrawalsResourceType; - tokens: TokensResourceType; - contracts: ContractsResourceType; + deposits: DepositsResource; + withdrawals: WithdrawalsResource; + tokens: TokensResource; + contracts: ContractsResource; + interop: InteropResource; } export function createEthersSdk(client: EthersClient): EthersSdk { const tokens = createTokensResource(client); const contracts = createContractsResource(client); + const interop = createInteropResource(client); return { deposits: createDepositsResource(client, tokens, contracts), withdrawals: createWithdrawalsResource(client, tokens, contracts), tokens, contracts, + interop, }; } diff --git a/src/adapters/viem/client.ts b/src/adapters/viem/client.ts index 755ebee..8c61a7b 100644 --- a/src/adapters/viem/client.ts +++ b/src/adapters/viem/client.ts @@ -18,6 +18,7 @@ import { L2_NATIVE_TOKEN_VAULT_ADDRESS, L2_BASE_TOKEN_ADDRESS, } from '../../core/constants'; +import { OP_CLIENT } from '../../core/types'; // ABIs from internal snapshot (same as ethers adapter) import { @@ -29,6 +30,9 @@ import { L1NativeTokenVaultABI, IBaseTokenABI, } from '../../core/abi'; +import { createErrorHandlers } from '../ethers/errors/error-ops'; + +const { wrap } = createErrorHandlers('client'); export interface ResolvedAddresses { bridgehub: Address; @@ -97,51 +101,61 @@ export function createViemClient(args: InitArgs): ViemClient { async function ensureAddresses(): Promise { if (addrCache) return addrCache; - // Bridgehub via zks_getBridgehubContract - const bridgehub = args.overrides?.bridgehub ?? (await zks.getBridgehubAddress()); - - // L1 AssetRouter via Bridgehub.assetRouter() - const l1AssetRouter = - args.overrides?.l1AssetRouter ?? - ((await l1.readContract({ - address: bridgehub, - abi: IBridgehubABI as Abi, - functionName: 'assetRouter', - })) as Address); - - // L1Nullifier via L1AssetRouter.L1_NULLIFIER() - const l1Nullifier = - args.overrides?.l1Nullifier ?? - ((await l1.readContract({ - address: l1AssetRouter, - abi: IL1AssetRouterABI as Abi, - functionName: 'L1_NULLIFIER', - })) as Address); - - // L1NativeTokenVault via L1Nullifier.l1NativeTokenVault() - const l1NativeTokenVault = - args.overrides?.l1NativeTokenVault ?? - ((await l1.readContract({ - address: l1Nullifier, - abi: IL1NullifierABI as Abi, - functionName: 'l1NativeTokenVault', - })) as Address); - - // L2 addresses from constants (overridable) - const l2AssetRouter = args.overrides?.l2AssetRouter ?? L2_ASSET_ROUTER_ADDRESS; - const l2NativeTokenVault = args.overrides?.l2NativeTokenVault ?? L2_NATIVE_TOKEN_VAULT_ADDRESS; - 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 via zks_getBridgehubContract + const bridgehub = args.overrides?.bridgehub ?? (await zks.getBridgehubAddress()); + + // L1 AssetRouter via Bridgehub.assetRouter() + const l1AssetRouter = + args.overrides?.l1AssetRouter ?? + ((await l1.readContract({ + address: bridgehub, + abi: IBridgehubABI as Abi, + functionName: 'assetRouter', + })) as Address); + + // L1Nullifier via L1AssetRouter.L1_NULLIFIER() + const l1Nullifier = + args.overrides?.l1Nullifier ?? + ((await l1.readContract({ + address: l1AssetRouter, + abi: IL1AssetRouterABI as Abi, + functionName: 'L1_NULLIFIER', + })) as Address); + + // L1NativeTokenVault via L1Nullifier.l1NativeTokenVault() + const l1NativeTokenVault = + args.overrides?.l1NativeTokenVault ?? + ((await l1.readContract({ + address: l1Nullifier, + abi: IL1NullifierABI as Abi, + functionName: 'l1NativeTokenVault', + })) as Address); + + // L2 addresses from constants (overridable) + const l2AssetRouter = args.overrides?.l2AssetRouter ?? L2_ASSET_ROUTER_ADDRESS; + const l2NativeTokenVault = + args.overrides?.l2NativeTokenVault ?? L2_NATIVE_TOKEN_VAULT_ADDRESS; + const l2BaseTokenSystem = args.overrides?.l2BaseTokenSystem ?? L2_BASE_TOKEN_ADDRESS; + + addrCache = { + bridgehub, + l1AssetRouter, + l1Nullifier, + l1NativeTokenVault, + l2AssetRouter, + l2NativeTokenVault, + l2BaseTokenSystem, + }; + return addrCache; + }, + { + ctx: { where: 'ensureAddresses' }, + message: 'Failed to ensure contract addresses.', + }, + ); } async function contracts() { diff --git a/src/adapters/viem/resources/withdrawals/services/finalization.ts b/src/adapters/viem/resources/withdrawals/services/finalization.ts index 4f699cb..ae38230 100644 --- a/src/adapters/viem/resources/withdrawals/services/finalization.ts +++ b/src/adapters/viem/resources/withdrawals/services/finalization.ts @@ -180,29 +180,13 @@ export function createFinalizationServices(client: ViemClient): FinalizationServ merkleProof: proof.proof, }; - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); return { params, nullifier: l1Nullifier }; }, async simulateFinalizeReadiness(params) { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.readiness.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); // First, check if the withdrawal is already finalized const done = await (async () => { @@ -248,15 +232,7 @@ export function createFinalizationServices(client: ViemClient): FinalizationServ }, async isWithdrawalFinalized(key: WithdrawalKey) { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); return await wrapAs( 'RPC', @@ -276,15 +252,7 @@ export function createFinalizationServices(client: ViemClient): FinalizationServ }, async estimateFinalization(params: FinalizeDepositParams): Promise { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.estimate, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); // Estimate gas for finalizeDeposit on the L1 Nullifier const gasLimit = await wrapAs( 'RPC', @@ -358,15 +326,7 @@ export function createFinalizationServices(client: ViemClient): FinalizationServ }, async finalizeDeposit(params: FinalizeDepositParams) { - const { l1Nullifier } = await wrapAs( - 'INTERNAL', - OP_WITHDRAWALS.finalize.fetchParams.ensureAddresses, - () => client.ensureAddresses(), - { - ctx: { where: 'ensureAddresses' }, - message: 'Failed to ensure L1 Nullifier address.', - }, - ); + const { l1Nullifier } = await client.ensureAddresses(); try { const hash = await client.l1Wallet.writeContract({ address: l1Nullifier, diff --git a/src/core/resources/interop/__tests__/finalization.test.ts b/src/core/resources/interop/__tests__/finalization.test.ts index a27cff1..be7bb41 100644 --- a/src/core/resources/interop/__tests__/finalization.test.ts +++ b/src/core/resources/interop/__tests__/finalization.test.ts @@ -46,14 +46,12 @@ describe('interop/finalization', () => { const handle = { l2SrcTxHash: TX_HASH, bundleHash: BUNDLE_HASH, - dstChainId: 2n, dstExecTxHash: '0xexec' as Hex, }; const result = resolveIdsFromWaitable(handle as any); expect(result).toEqual({ l2SrcTxHash: TX_HASH, bundleHash: BUNDLE_HASH, - dstChainId: 2n, dstExecTxHash: '0xexec', }); }); @@ -63,7 +61,6 @@ describe('interop/finalization', () => { const result = resolveIdsFromWaitable(handle as any); expect(result.l2SrcTxHash).toBe(TX_HASH); expect(result.bundleHash).toBeUndefined(); - expect(result.dstChainId).toBeUndefined(); }); }); diff --git a/src/core/resources/interop/__tests__/plan.test.ts b/src/core/resources/interop/__tests__/plan.test.ts index 2b5c98b..55e6f01 100644 --- a/src/core/resources/interop/__tests__/plan.test.ts +++ b/src/core/resources/interop/__tests__/plan.test.ts @@ -72,7 +72,6 @@ describe('interop/plan', () => { it('throws when ERC-20 actions are present', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }], }; expect(() => preflightDirect(params, baseCtx())).toThrow( @@ -82,7 +81,6 @@ describe('interop/plan', () => { it('throws when base tokens differ', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: 100n }], }; const ctx = baseCtx({ baseTokens: { src: ADDR_A, dst: ADDR_B } }); @@ -93,7 +91,6 @@ describe('interop/plan', () => { it('throws for negative sendNative amount', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: -1n }], }; expect(() => preflightDirect(params, baseCtx())).toThrow('sendNative.amount must be >= 0'); @@ -101,7 +98,6 @@ describe('interop/plan', () => { it('throws for negative call value', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: '0x', value: -1n }], }; expect(() => preflightDirect(params, baseCtx())).toThrow('call.value must be >= 0'); @@ -109,7 +105,6 @@ describe('interop/plan', () => { it('passes for valid direct route params', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendNative', to: ADDR_A, amount: 100n }, { type: 'call', to: ADDR_B, data: '0xabcd', value: 50n }, @@ -120,7 +115,6 @@ describe('interop/plan', () => { it('allows call without value', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: '0x' }], }; expect(() => preflightDirect(params, baseCtx())).not.toThrow(); @@ -130,7 +124,6 @@ describe('interop/plan', () => { describe('buildDirectBundle', () => { it('builds bundle for sendNative actions', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: 100n }], }; const result = buildDirectBundle(params, baseCtx(), emptyAttrs); @@ -145,7 +138,6 @@ describe('interop/plan', () => { it('builds bundle for call actions', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: '0xabcdef', value: 50n }], }; const result = buildDirectBundle(params, baseCtx(), emptyAttrs); @@ -158,7 +150,6 @@ describe('interop/plan', () => { it('includes call attributes in starters', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: 100n }], }; const attrs: InteropAttributes = { @@ -173,7 +164,6 @@ describe('interop/plan', () => { it('handles call without data', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: undefined as unknown as Hex }], }; const result = buildDirectBundle(params, baseCtx(), emptyAttrs); @@ -192,7 +182,6 @@ describe('interop/plan', () => { it('throws when no ERC-20 and base tokens match', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: 100n }], }; expect(() => preflightIndirect(params, baseCtx())).toThrow( @@ -202,7 +191,6 @@ describe('interop/plan', () => { it('passes for ERC-20 actions with matching base tokens', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }], }; expect(() => preflightIndirect(params, baseCtx())).not.toThrow(); @@ -210,7 +198,6 @@ describe('interop/plan', () => { it('passes for mismatched base tokens without ERC-20', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendNative', to: ADDR_A, amount: 100n }], }; const ctx = baseCtx({ baseTokens: { src: ADDR_A, dst: ADDR_B } }); @@ -219,7 +206,6 @@ describe('interop/plan', () => { it('throws for negative sendNative amount', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'sendNative', to: ADDR_A, amount: -1n }, @@ -230,7 +216,6 @@ describe('interop/plan', () => { it('throws for negative sendErc20 amount', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: -1n }], }; expect(() => preflightIndirect(params, baseCtx())).toThrow('sendErc20.amount must be >= 0'); @@ -238,7 +223,6 @@ describe('interop/plan', () => { it('throws for negative call value', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'call', to: ADDR_A, data: '0x', value: -1n }, @@ -249,7 +233,6 @@ describe('interop/plan', () => { it('throws for call.value > 0 when base tokens differ', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: '0x', value: 100n }], }; const ctx = baseCtx({ baseTokens: { src: ADDR_A, dst: ADDR_B } }); @@ -260,7 +243,6 @@ describe('interop/plan', () => { it('allows call.value = 0 when base tokens differ', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'call', to: ADDR_A, data: '0x', value: 0n }], }; const ctx = baseCtx({ baseTokens: { src: ADDR_A, dst: ADDR_B } }); @@ -271,7 +253,6 @@ describe('interop/plan', () => { describe('buildIndirectBundle', () => { it('builds bundle with ERC-20 approvals', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'sendErc20', token: ADDR_B, to: ADDR_A, amount: 200n }, @@ -299,7 +280,6 @@ describe('interop/plan', () => { it('routes ERC-20 actions via asset router', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }], }; const starterData: InteropStarterData[] = [{ assetRouterPayload: '0xpayload' }]; @@ -309,9 +289,19 @@ describe('interop/plan', () => { expect(result.starters[0][1]).toBe('0xpayload'); }); + it('throws when sendErc20 action is missing asset router payload', () => { + const params: InteropParams = { + actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }], + }; + const starterData: InteropStarterData[] = [{}]; + + expect(() => buildIndirectBundle(params, baseCtx(), emptyAttrs, starterData)).toThrow( + 'buildIndirectBundle: missing assetRouterPayload for sendErc20 action.', + ); + }); + it('routes sendNative with matching base tokens directly', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'sendNative', to: ADDR_B, amount: 50n }, @@ -327,7 +317,6 @@ describe('interop/plan', () => { it('handles call actions', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'call', to: ADDR_B, data: '0xabcdef', value: 25n }, @@ -343,7 +332,6 @@ describe('interop/plan', () => { it('includes call attributes', () => { const params: InteropParams = { - dstChainId: 2n, actions: [{ type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }], }; const attrs: InteropAttributes = { @@ -359,7 +347,6 @@ describe('interop/plan', () => { it('handles call without data', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'call', to: ADDR_B, data: undefined as unknown as Hex }, @@ -373,7 +360,6 @@ describe('interop/plan', () => { it('aggregates approvals for same token', () => { const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'sendErc20', token: TOKEN, to: ADDR_B, amount: 200n }, @@ -399,7 +385,6 @@ describe('interop/plan', () => { const tokenLower = '0xcccccccccccccccccccccccccccccccccccccccc' as const; const tokenUpper = '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' as const; const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: tokenLower, to: ADDR_A, amount: 100n }, { type: 'sendErc20', token: tokenUpper, to: ADDR_B, amount: 200n }, @@ -418,7 +403,6 @@ describe('interop/plan', () => { it('aggregates same tokens while keeping different tokens separate', () => { const TOKEN_2 = '0x1111111111111111111111111111111111111111' as const; const params: InteropParams = { - dstChainId: 2n, actions: [ { type: 'sendErc20', token: TOKEN, to: ADDR_A, amount: 100n }, { type: 'sendErc20', token: TOKEN_2, to: ADDR_A, amount: 50n }, diff --git a/src/core/resources/interop/attributes/bundle.ts b/src/core/resources/interop/attributes/bundle.ts index 4c7484d..b5a38b2 100644 --- a/src/core/resources/interop/attributes/bundle.ts +++ b/src/core/resources/interop/attributes/bundle.ts @@ -4,7 +4,6 @@ import type { AttributesCodec } from './types'; export function createBundleAttributes(codec: AttributesCodec) { const executionAddress = (executor: Address): Hex => codec.encode('executionAddress', [executor]); - const unbundlerAddress = (addr: Address): Hex => codec.encode('unbundlerAddress', [addr]); return { diff --git a/src/core/resources/interop/attributes/types.ts b/src/core/resources/interop/attributes/types.ts index aa3a937..a612e19 100644 --- a/src/core/resources/interop/attributes/types.ts +++ b/src/core/resources/interop/attributes/types.ts @@ -1,11 +1,9 @@ // src/core/interop/attributes/types.ts import type { Hex } from '../../../types/primitives'; -import type { DecodedAttribute } from '../../../types/flows/interop'; -// Codec interface for encoding/decoding interop message attributes. +// Codec interface for encoding interop message attributes. // Abstracts the ABI encoding logic, allowing the core resource to remain // adapter agnostic. Adapters (e.g. viem) provide the actual implementation. export interface AttributesCodec { encode(fn: string, args: readonly unknown[]): Hex; - decode(attr: Hex): DecodedAttribute; } diff --git a/src/core/resources/interop/finalization.ts b/src/core/resources/interop/finalization.ts index a134f0f..9bc4ec4 100644 --- a/src/core/resources/interop/finalization.ts +++ b/src/core/resources/interop/finalization.ts @@ -29,7 +29,6 @@ export const DEFAULT_TIMEOUT_MS = 300_000; interface ResolvedInteropIds { l2SrcTxHash?: Hex; bundleHash?: Hex; - dstChainId?: bigint; dstExecTxHash?: Hex; } @@ -41,7 +40,6 @@ export function resolveIdsFromWaitable(input: InteropWaitable): ResolvedInteropI return { l2SrcTxHash: input.l2SrcTxHash, bundleHash: input.bundleHash, - dstChainId: input.dstChainId, dstExecTxHash: input.dstExecTxHash, }; } diff --git a/src/core/resources/interop/plan.ts b/src/core/resources/interop/plan.ts index 687e514..12ba9f1 100644 --- a/src/core/resources/interop/plan.ts +++ b/src/core/resources/interop/plan.ts @@ -193,8 +193,10 @@ export function buildIndirectBundle( return [directTo, '0x' as Hex, callAttributes]; case 'call': return [directTo, action.data ?? ('0x' as Hex), callAttributes]; + case 'sendErc20': + throw new Error('buildIndirectBundle: missing assetRouterPayload for sendErc20 action.'); default: - return [directTo, '0x' as Hex, callAttributes]; + return assertNever(action); } }); diff --git a/src/core/types/__tests__/errors.test.ts b/src/core/types/__tests__/errors.test.ts index 05bc969..27f615d 100644 --- a/src/core/types/__tests__/errors.test.ts +++ b/src/core/types/__tests__/errors.test.ts @@ -82,6 +82,42 @@ describe('types/errors — isZKsyncError', () => { expect(isZKsyncError(err)).toBe(true); }); + it('supports filtering by type/resource/operation/messageIncludes', () => { + const err = new ZKsyncError( + makeEnvelope({ + type: 'STATE', + resource: 'interop', + operation: 'interop.wait', + message: 'Timed out waiting for source receipt', + }), + ); + + expect( + isZKsyncError(err, { + type: 'STATE', + resource: 'interop', + operation: 'interop.wait', + messageIncludes: 'Source Receipt', + }), + ).toBe(true); + }); + + it('returns false when filter criteria do not match', () => { + const err = new ZKsyncError( + makeEnvelope({ + type: 'STATE', + resource: 'interop', + operation: 'interop.wait', + message: 'Timed out waiting for source receipt', + }), + ); + + expect(isZKsyncError(err, { type: 'RPC' })).toBe(false); + expect(isZKsyncError(err, { resource: 'deposits' })).toBe(false); + expect(isZKsyncError(err, { operation: 'interop.status' })).toBe(false); + expect(isZKsyncError(err, { messageIncludes: 'destination root' })).toBe(false); + }); + it('returns false for plain errors and other values', () => { expect(isZKsyncError(new Error('nope'))).toBe(false); expect(isZKsyncError(null)).toBe(false); diff --git a/src/core/types/errors.ts b/src/core/types/errors.ts index abe5b43..bbfc705 100644 --- a/src/core/types/errors.ts +++ b/src/core/types/errors.ts @@ -103,14 +103,33 @@ if (kInspect) { } // ---- Factory & type guards ---- -export function isZKsyncError(e: unknown): e is ZKsyncError { +export function isZKsyncError( + e: unknown, + opts?: { + type?: ErrorType; + resource?: Resource; + operation?: string; + messageIncludes?: string; + }, +): e is ZKsyncError { if (!e || typeof e !== 'object') return false; const maybe = e as { envelope?: unknown }; if (!('envelope' in maybe)) return false; const envelope = maybe.envelope as Record | undefined; - return typeof envelope?.type === 'string' && typeof envelope?.message === 'string'; + if (typeof envelope?.type !== 'string' || typeof envelope?.message !== 'string') return false; + + if (opts?.type && envelope.type !== opts.type) return false; + if (opts?.resource && envelope.resource !== opts.resource) return false; + if (opts?.operation && envelope.operation !== opts.operation) return false; + if ( + opts?.messageIncludes && + !envelope.message.toLowerCase().includes(opts.messageIncludes.toLowerCase()) + ) + return false; + + return true; } // "receipt not found" detector across viem / ethers / generic RPC. @@ -174,6 +193,11 @@ export function isReceiptNotFound(e: unknown): boolean { // TryResult type for operations that can fail without throwing export type TryResult = { ok: true; value: T } | { ok: false; error: ZKsyncError }; +export const OP_CLIENT = { + ensureAddresses: 'client.ensureAddresses', + getSemverProtocolVersion: 'client.getSemverProtocolVersion', +} as const; + // Operation constants for Deposit error contexts export const OP_DEPOSITS = { // high-level flow ops @@ -265,10 +289,8 @@ export const OP_WITHDRAWALS = { messengerIndex: 'withdrawals.finalize.fetchParams:messengerIndex', proof: 'withdrawals.finalize.fetchParams:proof', network: 'withdrawals.finalize.fetchParams:network', - ensureAddresses: 'withdrawals.finalize.fetchParams:ensureAddresses', }, readiness: { - ensureAddresses: 'withdrawals.finalize.readiness:ensureAddresses', isFinalized: 'withdrawals.finalize.readiness:isWithdrawalFinalized', simulate: 'withdrawals.finalize.readiness:simulate', }, @@ -293,7 +315,10 @@ export const OP_INTEROP = { tryWait: 'interop.tryWait', finalize: 'interop.finalize', tryFinalize: 'interop.tryFinalize', - + context: { + chainTypeManager: 'interop.chainTypeManager', + protocolVersion: 'interop.protocolVersion', + }, // route-specific ops (keep names aligned with files) routes: { direct: { @@ -315,11 +340,10 @@ export const OP_INTEROP = { svc: { status: { sourceReceipt: 'interop.svc.status:sourceReceipt', - ensureAddresses: 'interop.svc.status:ensureAddresses', parseSentLog: 'interop.svc.status:parseSentLog', - requireDstProvider: 'interop.svc.status:requireDstProvider', dstLogs: 'interop.svc.status:dstLogs', derive: 'interop.svc.status:derive', + getRoot: 'interop.svc.status:getRoot', }, wait: { poll: 'interop.svc.wait:poll', diff --git a/src/core/types/flows/interop.ts b/src/core/types/flows/interop.ts index 1c10317..ae2e7f8 100644 --- a/src/core/types/flows/interop.ts +++ b/src/core/types/flows/interop.ts @@ -2,36 +2,7 @@ import type { Address, Hex } from '../primitives'; import type { ApprovalNeed, Plan, Handle } from './base'; import { isHash, isHash66, isHash66Array, isAddress, isBigint, isNumber } from '../../utils'; - -/** Encoded call attributes for interop */ -export type EncodedCallAttributes = readonly Hex[]; - -/** Encoded bundle attributes for interop */ -export type EncodedBundleAttributes = readonly Hex[]; - -/** - * A decoded interop attribute with its function selector and arguments. - */ -export interface DecodedAttribute { - /** Function selector (0x + 8 hex chars, representing 4 bytes) */ - selector: Hex; - /** Name of the attribute function */ - name: string; - /** Full function signature (e.g. "interopCallValue(uint256)") when ABI is known */ - signature?: string; - /** Decoded arguments array (types vary based on function) */ - args: unknown[]; -} - -/** - * Summary of all decoded attributes for both call-level and bundle-level attributes. - */ -export interface DecodedAttributesSummary { - /** Attributes that apply to individual interop calls */ - call: DecodedAttribute[]; - /** Attributes that apply to the entire interop bundle */ - bundle: DecodedAttribute[]; -} +import type { TxOverrides } from '../fees'; /** * The routing mechanism for interop execution. @@ -53,16 +24,14 @@ export type InteropAction = * Input parameters for initiating an interop operation. */ export interface InteropParams { - /** Destination chain ID (EIP-155 format) */ - dstChainId: bigint; /** Ordered list of actions to execute on destination chain */ actions: InteropAction[]; - /** Optional: Override default sender address for the operation */ - sender?: Address; /** Optional: Restrict execution to a specific address on destination */ execution?: { only: Address }; /** Optional: Specify who can unbundle actions */ unbundling?: { by: Address }; + /** Optional: Gas overrides for L2 transaction */ + txOverrides?: TxOverrides; } /** @@ -84,6 +53,16 @@ export interface InteropQuote { l2Fee?: bigint; } +/** + * Quote add-ons a route can compute + */ +export interface QuoteExtras { + /** Sum of msg.value across actions (sendNative + call.value). */ + totalActionValue: bigint; + /** Sum of ERC-20 amounts across actions (for approvals/bridging). */ + bridgedTokenTotal: bigint; +} + /** * Execution plan for an interop operation. * Contains transaction details, routing, and quote before submission. @@ -104,8 +83,6 @@ export interface InteropHandle l1MsgHash?: Hex; /** Interop bundle hash */ bundleHash?: Hex; - /** Destination chain ID (EIP-155 format) */ - dstChainId?: bigint; /** Transaction hash of execution on destination chain (once executed) */ dstExecTxHash?: Hex; } @@ -148,8 +125,6 @@ export interface InteropStatus { bundleHash?: Hex; /** Destination chain execution transaction hash */ dstExecTxHash?: Hex; - /** Destination chain ID (EIP-155 format) */ - dstChainId?: bigint; } /** @@ -258,8 +233,6 @@ export function isInteropFinalizationInfo(obj: unknown): obj is InteropFinalizat export interface InteropFinalizationResult { /** Interop bundle hash that was finalized */ bundleHash: Hex; - /** Destination chain ID */ - dstChainId: bigint; /** Transaction hash of the successful execution on destination */ dstExecTxHash: Hex; } diff --git a/src/core/types/primitives.ts b/src/core/types/primitives.ts index 3b01ab7..3b4fdd8 100644 --- a/src/core/types/primitives.ts +++ b/src/core/types/primitives.ts @@ -4,3 +4,5 @@ export type Address = `0x${string}`; export type Hex = `0x${string}`; export type Hash = Hex; + +export const ZERO_HASH: Hash = '0x0000000000000000000000000000000000000000000000000000000000000000';