/** * CCIP Watcher and Relayer * Production-grade service to watch Chain-138 and relay transactions to Ethereum via CCIP */ import { ethers } from "ethers"; import { Pool } from "pg"; import * as dotenv from "dotenv"; import winston from "winston"; dotenv.config(); // Configuration const CONFIG = { // Chain-138 RPC CHAIN138_RPC_WS: process.env.CHAIN138_RPC_WS || process.env.CHAIN138_RPC_URL || "", CHAIN138_RPC_HTTP: process.env.CHAIN138_RPC_HTTP || process.env.CHAIN138_RPC_URL || "", // Ethereum RPC ETHEREUM_RPC: process.env.ETHEREUM_MAINNET_RPC || process.env.ETHEREUM_RPC || "", // Contract addresses CCIP_REPORTER_ADDRESS: process.env.CCIP_REPORTER_CHAIN138_ADDRESS || "", CCIP_LOGGER_ADDRESS: process.env.CCIP_LOGGER_ETH_ADDRESS || "", // Chain selectors (from CCIP Directory) CHAIN138_SELECTOR: process.env.CHAIN138_SELECTOR || "0x000000000000008a", ETH_MAINNET_SELECTOR: process.env.ETH_MAINNET_SELECTOR || "0x500147", // Relayer configuration RELAYER_PRIVATE_KEY: process.env.RELAYER_PRIVATE_KEY || "", BATCH_SIGNER_PRIVATE_KEY: process.env.BATCH_SIGNER_PRIVATE_KEY || "", // Batching configuration BATCH_SIZE: parseInt(process.env.BATCH_SIZE || "10"), BATCH_INTERVAL_MS: parseInt(process.env.BATCH_INTERVAL_MS || "60000"), // 1 minute // Confirmation requirements CONFIRMATIONS_REQUIRED: parseInt(process.env.CONFIRMATIONS_REQUIRED || "12"), // Database DATABASE_URL: process.env.DATABASE_URL || "postgresql://user:password@localhost:5432/ccip_relayer", // Monitoring METRICS_PORT: parseInt(process.env.METRICS_PORT || "9090"), }; // Logger setup const logger = winston.createLogger({ level: process.env.LOG_LEVEL || "info", format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console({ format: winston.format.simple(), }), ], }); // Database connection const pool = new Pool({ connectionString: CONFIG.DATABASE_URL, }); // Initialize database schema async function initDatabase() { await pool.query(` CREATE TABLE IF NOT EXISTS outbox ( id SERIAL PRIMARY KEY, tx_hash VARCHAR(66) UNIQUE NOT NULL, from_address VARCHAR(42) NOT NULL, to_address VARCHAR(42) NOT NULL, value NUMERIC NOT NULL, block_number BIGINT NOT NULL, observed_at TIMESTAMP DEFAULT NOW(), confirmed_at TIMESTAMP, relayed BOOLEAN DEFAULT FALSE, relayed_txhash VARCHAR(66), batch_id VARCHAR(66), attempts INT DEFAULT 0, last_error TEXT ); CREATE INDEX IF NOT EXISTS idx_outbox_relayed ON outbox(relayed); CREATE INDEX IF NOT EXISTS idx_outbox_confirmed ON outbox(confirmed_at); CREATE INDEX IF NOT EXISTS idx_outbox_batch ON outbox(batch_id); `); logger.info("Database initialized"); } // Watch Chain-138 for transactions async function watchChain138() { if (!CONFIG.CHAIN138_RPC_WS && !CONFIG.CHAIN138_RPC_HTTP) { throw new Error("Chain-138 RPC URL not configured"); } const provider = CONFIG.CHAIN138_RPC_WS ? new ethers.WebSocketProvider(CONFIG.CHAIN138_RPC_WS) : new ethers.JsonRpcProvider(CONFIG.CHAIN138_RPC_HTTP); logger.info("Watching Chain-138 for transactions..."); // Subscribe to new blocks provider.on("block", async (blockNumber) => { try { const block = await provider.getBlock(blockNumber, true); if (!block || !block.transactions) return; // Process transactions in block for (const tx of block.transactions) { if (typeof tx === "string") continue; // Skip if just hash // Check if transaction matches criteria (you can add filters here) const txReceipt = await provider.getTransactionReceipt(tx.hash); if (!txReceipt || txReceipt.status !== 1) continue; // Skip failed txs // Insert into outbox (idempotent) await pool.query( `INSERT INTO outbox (tx_hash, from_address, to_address, value, block_number, confirmed_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (tx_hash) DO NOTHING`, [ tx.hash, tx.from || ethers.ZeroAddress, tx.to || ethers.ZeroAddress, tx.value?.toString() || "0", blockNumber, ] ); logger.debug(`Observed transaction: ${tx.hash}`); } } catch (error) { logger.error("Error processing block:", error); } }); } // Batch dispatcher async function dispatchBatches() { if (!CONFIG.CCIP_REPORTER_ADDRESS || !CONFIG.RELAYER_PRIVATE_KEY) { throw new Error("CCIP Reporter address or relayer key not configured"); } const provider = new ethers.JsonRpcProvider(CONFIG.CHAIN138_RPC_HTTP); const wallet = new ethers.Wallet(CONFIG.RELAYER_PRIVATE_KEY, provider); const reporter = new ethers.Contract( CONFIG.CCIP_REPORTER_ADDRESS, [ "function reportBatch(bytes32,bytes32[],address[],address[],uint256[],bytes) payable", "function estimateFee(bytes32[],address[],address[],uint256[],bytes) view returns(uint256)", ], wallet ); setInterval(async () => { try { // Get unrelayed, confirmed transactions const result = await pool.query( `SELECT * FROM outbox WHERE relayed = FALSE AND confirmed_at IS NOT NULL ORDER BY block_number ASC LIMIT $1`, [CONFIG.BATCH_SIZE] ); if (result.rows.length === 0) return; // Prepare batch data const txHashes: string[] = []; const froms: string[] = []; const tos: string[] = []; const values: string[] = []; for (const row of result.rows) { txHashes.push(row.tx_hash); froms.push(row.from_address); tos.push(row.to_address); values.push(row.value); } // Generate batch ID const batchId = ethers.keccak256( ethers.AbiCoder.defaultAbiCoder().encode( ["bytes32[]", "uint256"], [txHashes, Date.now()] ) ); // Sign batch (if signer key configured) let signature = "0x"; if (CONFIG.BATCH_SIGNER_PRIVATE_KEY) { const signerWallet = new ethers.Wallet(CONFIG.BATCH_SIGNER_PRIVATE_KEY); const digest = ethers.keccak256( ethers.AbiCoder.defaultAbiCoder().encode( ["bytes32", "bytes32", "bytes32", "bytes32", "bytes32"], [ batchId, ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32[]"], [txHashes])), ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [froms])), ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [tos])), ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["uint256[]"], [values])), ] ) ); const messageHash = ethers.hashMessage(ethers.getBytes(digest)); signature = await signerWallet.signMessage(ethers.getBytes(messageHash)); } // Estimate fee const fee = await reporter.estimateFee( txHashes, froms, tos, values, signature ); // Check balance const balance = await provider.getBalance(wallet.address); if (balance < fee) { logger.warn(`Insufficient balance. Required: ${ethers.formatEther(fee)} ETH, Have: ${ethers.formatEther(balance)} ETH`); return; } // Send batch logger.info(`Relaying batch ${batchId} with ${txHashes.length} transactions`); const tx = await reporter.reportBatch( batchId, txHashes, froms, tos, values, signature, { value: fee } ); const receipt = await tx.wait(); logger.info(`Batch relayed. TX: ${receipt.hash}`); // Update outbox await pool.query( `UPDATE outbox SET relayed = TRUE, relayed_txhash = $1, batch_id = $2 WHERE tx_hash = ANY($3::varchar[])`, [receipt.hash, batchId, txHashes] ); logger.info(`Batch ${batchId} successfully relayed`); } catch (error) { logger.error("Error dispatching batch:", error); // Update attempts // (Implementation for retry logic) } }, CONFIG.BATCH_INTERVAL_MS); } // Main function async function main() { logger.info("Starting CCIP Watcher and Relayer..."); try { await initDatabase(); await watchChain138(); await dispatchBatches(); logger.info("CCIP Watcher and Relayer started successfully"); } catch (error) { logger.error("Failed to start:", error); process.exit(1); } } // Handle graceful shutdown process.on("SIGINT", async () => { logger.info("Shutting down..."); await pool.end(); process.exit(0); }); if (require.main === module) { main(); } export { main, watchChain138, dispatchBatches };