|
| 1 | +import type { JsonRpcProvider } from "ethers"; |
| 2 | +import { Contract, Wallet, AbiCoder } from "ethers"; |
| 3 | +import type { ChainAddresses, CoreDeployedAddresses } from "./types"; |
| 4 | +import { sleep } from "./utils"; |
| 5 | + |
| 6 | +/** |
| 7 | + * L2→L2 Cross-Chain Relayer |
| 8 | + * |
| 9 | + * Monitors L2 chains for special cross-chain message transactions and relays them |
| 10 | + * through L1 to the target L2 chain. |
| 11 | + * |
| 12 | + * Flow: L2 Source → L1 Bridgehub → L2 Target |
| 13 | + * |
| 14 | + * To send a cross-chain message from L2, send a transaction to a special marker address: |
| 15 | + * - To: 0x0000000000000000000000000000000000000420 (CROSS_CHAIN_MESSENGER) |
| 16 | + * - Data: ABI-encoded (targetChainId, targetAddress, targetCalldata) |
| 17 | + */ |
| 18 | +export class L2ToL2Relayer { |
| 19 | + private l1Provider: JsonRpcProvider; |
| 20 | + private l2Providers: Map<number, JsonRpcProvider>; |
| 21 | + private l1Wallet: Wallet; |
| 22 | + private l1Addresses: CoreDeployedAddresses; |
| 23 | + private chainAddresses: Map<number, ChainAddresses>; |
| 24 | + private isRunning: boolean = false; |
| 25 | + private pollingInterval: number = 2000; // 2 seconds |
| 26 | + private lastProcessedBlocks: Map<number, number> = new Map(); |
| 27 | + private processedTxHashes: Set<string> = new Set(); |
| 28 | + |
| 29 | + // InteropCenter system contract address |
| 30 | + private readonly INTEROP_CENTER_ADDR = "0x000000000000000000000000000000000001000d"; |
| 31 | + |
| 32 | + // InteropBundleSent event signature |
| 33 | + // event InteropBundleSent(bytes32 l2l1MsgHash, bytes32 interopBundleHash, InteropBundle interopBundle) |
| 34 | + private readonly INTEROP_BUNDLE_SENT_TOPIC = "0xd5e1642d9c6ff371d1f102384c70a9a38530493e4747a53919f128685013cb6e"; |
| 35 | + |
| 36 | + constructor( |
| 37 | + l1Provider: JsonRpcProvider, |
| 38 | + l2Providers: Map<number, JsonRpcProvider>, |
| 39 | + privateKey: string, |
| 40 | + l1Addresses: CoreDeployedAddresses, |
| 41 | + chainAddresses: Map<number, ChainAddresses>, |
| 42 | + pollingIntervalMs: number = 2000 |
| 43 | + ) { |
| 44 | + this.l1Provider = l1Provider; |
| 45 | + this.l2Providers = l2Providers; |
| 46 | + this.l1Wallet = new Wallet(privateKey, l1Provider); |
| 47 | + this.l1Addresses = l1Addresses; |
| 48 | + this.chainAddresses = chainAddresses; |
| 49 | + this.pollingInterval = pollingIntervalMs; |
| 50 | + |
| 51 | + // Initialize last processed blocks for each L2 |
| 52 | + for (const chainId of l2Providers.keys()) { |
| 53 | + this.lastProcessedBlocks.set(chainId, 0); |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + async start(): Promise<void> { |
| 58 | + console.log("🌉 Starting L2→L2 Cross-Chain Relayer..."); |
| 59 | + |
| 60 | + // Get current block numbers as starting points |
| 61 | + for (const [chainId, provider] of this.l2Providers.entries()) { |
| 62 | + const blockNumber = await provider.getBlockNumber(); |
| 63 | + this.lastProcessedBlocks.set(chainId, blockNumber); |
| 64 | + console.log(` Starting from L2 chain ${chainId} block ${blockNumber}`); |
| 65 | + } |
| 66 | + |
| 67 | + this.isRunning = true; |
| 68 | + |
| 69 | + // Start polling loop |
| 70 | + this.poll(); |
| 71 | + |
| 72 | + console.log("✅ L2→L2 Relayer started"); |
| 73 | + } |
| 74 | + |
| 75 | + async stop(): Promise<void> { |
| 76 | + console.log("🛑 Stopping L2→L2 Cross-Chain Relayer..."); |
| 77 | + this.isRunning = false; |
| 78 | + console.log("✅ L2→L2 Relayer stopped"); |
| 79 | + } |
| 80 | + |
| 81 | + private async poll(): Promise<void> { |
| 82 | + while (this.isRunning) { |
| 83 | + try { |
| 84 | + await this.processAllChains(); |
| 85 | + } catch (error) { |
| 86 | + console.error("❌ L2→L2 Relayer error:", error); |
| 87 | + } |
| 88 | + |
| 89 | + await sleep(this.pollingInterval); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + private async processAllChains(): Promise<void> { |
| 94 | + for (const [sourceChainId, provider] of this.l2Providers.entries()) { |
| 95 | + await this.processChain(sourceChainId, provider); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + private async processChain(sourceChainId: number, provider: JsonRpcProvider): Promise<void> { |
| 100 | + const currentBlock = await provider.getBlockNumber(); |
| 101 | + const lastProcessed = this.lastProcessedBlocks.get(sourceChainId) || 0; |
| 102 | + |
| 103 | + if (currentBlock <= lastProcessed) { |
| 104 | + return; |
| 105 | + } |
| 106 | + |
| 107 | + const fromBlock = lastProcessed + 1; |
| 108 | + const toBlock = currentBlock; |
| 109 | + |
| 110 | + // Process blocks in batches to avoid overwhelming the RPC |
| 111 | + const batchSize = 10; |
| 112 | + for (let start = fromBlock; start <= toBlock; start += batchSize) { |
| 113 | + const end = Math.min(start + batchSize - 1, toBlock); |
| 114 | + await this.processBlockRange(sourceChainId, provider, start, end); |
| 115 | + } |
| 116 | + |
| 117 | + this.lastProcessedBlocks.set(sourceChainId, currentBlock); |
| 118 | + } |
| 119 | + |
| 120 | + private async processBlockRange( |
| 121 | + sourceChainId: number, |
| 122 | + provider: JsonRpcProvider, |
| 123 | + fromBlock: number, |
| 124 | + toBlock: number |
| 125 | + ): Promise<void> { |
| 126 | + for (let blockNum = fromBlock; blockNum <= toBlock; blockNum++) { |
| 127 | + const block = await provider.getBlock(blockNum, true); |
| 128 | + |
| 129 | + if (!block || !block.transactions) { |
| 130 | + continue; |
| 131 | + } |
| 132 | + |
| 133 | + for (const txHash of block.transactions) { |
| 134 | + await this.processTransaction(sourceChainId, provider, txHash as string); |
| 135 | + } |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + private async processTransaction( |
| 140 | + sourceChainId: number, |
| 141 | + provider: JsonRpcProvider, |
| 142 | + txHash: string |
| 143 | + ): Promise<void> { |
| 144 | + // Skip if already processed |
| 145 | + if (this.processedTxHashes.has(txHash)) { |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + // Get transaction receipt to check for InteropBundleSent event |
| 150 | + const receipt = await provider.getTransactionReceipt(txHash); |
| 151 | + |
| 152 | + if (!receipt || !receipt.logs) { |
| 153 | + return; |
| 154 | + } |
| 155 | + |
| 156 | + // Check if any log is an InteropBundleSent event from InteropCenter |
| 157 | + let foundInteropEvent = false; |
| 158 | + let interopEventLog: any = null; |
| 159 | + |
| 160 | + for (const log of receipt.logs) { |
| 161 | + if ( |
| 162 | + log.address.toLowerCase() === this.INTEROP_CENTER_ADDR.toLowerCase() && |
| 163 | + log.topics[0] === this.INTEROP_BUNDLE_SENT_TOPIC |
| 164 | + ) { |
| 165 | + foundInteropEvent = true; |
| 166 | + interopEventLog = log; |
| 167 | + break; |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + if (!foundInteropEvent) { |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + console.log(`\n 🔗 Found L2→L2 cross-chain message on chain ${sourceChainId}`); |
| 176 | + console.log(` Source Tx Hash: ${txHash}`); |
| 177 | + |
| 178 | + try { |
| 179 | + await this.relayCrossChainMessage(sourceChainId, txHash, interopEventLog, provider); |
| 180 | + this.processedTxHashes.add(txHash); |
| 181 | + console.log(` ✅ Cross-chain message relayed`); |
| 182 | + } catch (error: any) { |
| 183 | + console.error(` ❌ Failed to relay message:`, error.message); |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + private async relayCrossChainMessage( |
| 188 | + sourceChainId: number, |
| 189 | + sourceTxHash: string, |
| 190 | + interopEventLog: any, |
| 191 | + sourceProvider: JsonRpcProvider |
| 192 | + ): Promise<void> { |
| 193 | + // Parse InteropBundleSent event to extract destination chain and calls |
| 194 | + const abiCoder = AbiCoder.defaultAbiCoder(); |
| 195 | + |
| 196 | + // InteropBundleSent event structure: |
| 197 | + // event InteropBundleSent(bytes32 l2l1MsgHash, bytes32 interopBundleHash, InteropBundle interopBundle) |
| 198 | + // InteropBundle: (bytes32 canonicalHash, bytes32 chainTreeRoot, bytes32 destination, uint256 nonce, InteropCallStarter[] calls) |
| 199 | + // InteropCallStarter: (address target, uint256 value, bytes data) |
| 200 | + |
| 201 | + let targetChainId: number; |
| 202 | + let calls: Array<{ target: string; value: bigint; data: string }>; |
| 203 | + |
| 204 | + try { |
| 205 | + // The third parameter (index 2) in the event is the InteropBundle struct |
| 206 | + // Data field contains the non-indexed parameters |
| 207 | + const decodedData = abiCoder.decode( |
| 208 | + [ |
| 209 | + "bytes32", // l2l1MsgHash |
| 210 | + "bytes32", // interopBundleHash |
| 211 | + "tuple(bytes32,bytes32,bytes32,uint256,tuple(address,uint256,bytes)[])", // InteropBundle |
| 212 | + ], |
| 213 | + interopEventLog.data |
| 214 | + ); |
| 215 | + |
| 216 | + const interopBundle = decodedData[2]; |
| 217 | + const destinationBytes = interopBundle[2]; // bytes32 destination |
| 218 | + const rawCalls = interopBundle[4]; // InteropCallStarter[] calls (as arrays) |
| 219 | + |
| 220 | + // Decode destination (uint256 encoded as bytes32) |
| 221 | + targetChainId = Number(abiCoder.decode(["uint256"], destinationBytes)[0]); |
| 222 | + |
| 223 | + // Convert tuple arrays to objects |
| 224 | + calls = rawCalls.map((call: any) => ({ |
| 225 | + target: call[0], // address |
| 226 | + value: call[1], // uint256 |
| 227 | + data: call[2], // bytes |
| 228 | + })); |
| 229 | + |
| 230 | + console.log(` From Chain: ${sourceChainId}`); |
| 231 | + console.log(` To Chain: ${targetChainId}`); |
| 232 | + console.log(` Calls: ${calls.length}`); |
| 233 | + |
| 234 | + for (let i = 0; i < calls.length; i++) { |
| 235 | + console.log(` Call ${i + 1}: ${calls[i].target} with ${calls[i].data.length} bytes data`); |
| 236 | + } |
| 237 | + } catch (error) { |
| 238 | + console.error(` Failed to decode InteropBundleSent event:`, error); |
| 239 | + return; |
| 240 | + } |
| 241 | + |
| 242 | + // Verify target chain exists |
| 243 | + const targetProvider = this.l2Providers.get(targetChainId); |
| 244 | + if (!targetProvider) { |
| 245 | + console.error(` Target chain ${targetChainId} not found`); |
| 246 | + return; |
| 247 | + } |
| 248 | + |
| 249 | + console.log(` Executing ${calls.length} call(s) on target L2 chain...`); |
| 250 | + |
| 251 | + // Direct execution on target L2 (bypassing L1 for Anvil testing) |
| 252 | + const targetWallet = new Wallet(this.l1Wallet.privateKey, targetProvider); |
| 253 | + |
| 254 | + // Execute each call in the bundle |
| 255 | + for (let i = 0; i < calls.length; i++) { |
| 256 | + const call = calls[i]; |
| 257 | + const tx = await targetWallet.sendTransaction({ |
| 258 | + to: call.target, |
| 259 | + value: call.value, |
| 260 | + data: call.data, |
| 261 | + gasLimit: 1000000, |
| 262 | + }); |
| 263 | + |
| 264 | + console.log(` L2 Target Tx ${i + 1}: ${tx.hash}`); |
| 265 | + |
| 266 | + const receipt = await tx.wait(); |
| 267 | + console.log(` Confirmed in L2 block ${receipt?.blockNumber}`); |
| 268 | + } |
| 269 | + } |
| 270 | + |
| 271 | + getStats(): { processedMessages: number; chainsMonitored: number } { |
| 272 | + return { |
| 273 | + processedMessages: this.processedTxHashes.size, |
| 274 | + chainsMonitored: this.l2Providers.size, |
| 275 | + }; |
| 276 | + } |
| 277 | +} |
0 commit comments