#!/usr/bin/env node /** * Minimal tests for CCIP Relay Service. * Run with: npm test * Does not start the relay or connect to RPC. * For full tests, set PRIVATE_KEY (or RELAYER_PRIVATE_KEY) and run; config load will then validate. */ import { existsSync } from 'fs'; import { mkdtemp, rm } from 'fs/promises'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { ethers } from 'ethers'; import { MessageQueue } from './src/MessageQueue.js'; import { RelayService } from './src/RelayService.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); function assert(condition, message) { if (!condition) { console.error('FAIL:', message); process.exit(1); } } console.log('Relay service structure tests...'); assert(existsSync(join(__dirname, 'src', 'config.js')), 'src/config.js should exist'); assert(existsSync(join(__dirname, 'src', 'RelayService.js')), 'src/RelayService.js should exist'); assert(existsSync(join(__dirname, 'src', 'healthServer.js')), 'src/healthServer.js should exist'); assert(existsSync(join(__dirname, 'index.js')), 'index.js should exist'); const logger = { info() {}, warn() {}, error() {}, debug() {}, }; const relay = new RelayService({ sourceChain: { name: 'Chain 138', chainId: 138, rpcUrl: 'http://example.invalid', routerAddress: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', bridgeAddress: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', }, destinationChain: { name: 'Ethereum Mainnet', chainId: 1, rpcUrl: 'http://example.invalid', relayRouterAddress: '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', relayBridgeAddress: '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', relayBridgeAllowlist: ['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'], chainSelector: 5009297550715157269n, deliveryMode: 'router', }, tokenMapping: {}, sourceChainSelector: 138n, relayer: { privateKey: '', address: '' }, monitoring: { startBlock: 'latest', pollInterval: 5000, confirmationBlocks: 1, finalityDelayBlocks: 2, replayWindowBlocks: 32, }, retry: { maxRetries: 3, retryDelay: 5000 }, skipMessageIds: new Set(), }, logger); const cwReceiver = ethers.AbiCoder.defaultAbiCoder().encode( ['address'], ['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'] ); const scoped = relay.evaluateMessageScope({ sender: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', receiver: cwReceiver, }); assert(scoped.inScope === true, 'cW message should match the configured worker scope'); assert( scoped.targetBridge === '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', 'cW target bridge should decode from receiver bytes' ); const wethReceiver = ethers.AbiCoder.defaultAbiCoder().encode( ['address'], ['0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939'] ); const rejected = relay.evaluateMessageScope({ sender: '0xcacfd227A040002e49e2e01626363071324f820a', receiver: wethReceiver, }); assert(rejected.inScope === false, 'WETH message should be rejected by the cW worker scope'); const tempDir = await mkdtemp(join(tmpdir(), 'relay-queue-test-')); const queueStatePath = join(tempDir, 'queue-state.json'); const queue = new MessageQueue(logger, { persistencePath: queueStatePath }); await queue.init(); const queuedMessage = { messageId: '0xtest-message', sender: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', receiver: cwReceiver, data: '0x', tokenAmounts: [] }; await queue.add(queuedMessage); const inFlightMessage = await queue.getNext(); assert(inFlightMessage?.messageId === queuedMessage.messageId, 'getNext should return the queued message'); await queue.retry(queuedMessage.messageId); const retriedMessage = await queue.getNext(); assert(retriedMessage?.messageId === queuedMessage.messageId, 'retry should requeue the original message payload'); await queue.resetRetryCount(queuedMessage.messageId); await queue.retry(queuedMessage.messageId, { increment: false }); assert((await queue.getRetryCount(queuedMessage.messageId)) === 0, 'deferred requeue should not consume retry budget'); const persistentQueue = new MessageQueue(logger, { persistencePath: queueStatePath }); await persistentQueue.init(); assert( persistentQueue.getStats().queueSize === 1, 'persisted queue snapshot should reload queued messages after restart' ); const probeRelay = new RelayService({ sourceChain: { name: 'Chain 138', chainId: 138, rpcUrl: 'http://example.invalid', routerAddress: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', bridgeAddress: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', }, destinationChain: { name: 'Ethereum Mainnet', chainId: 1, rpcUrl: 'http://example.invalid', relayRouterAddress: '0x416564Ab73Ad5710855E98dC7bC7Bff7387285BA', relayBridgeAddress: '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5', relayBridgeAllowlist: ['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'], chainSelector: 5009297550715157269n, deliveryMode: 'router', }, tokenMapping: {}, sourceChainSelector: 138n, relayer: { privateKey: '0x' + '11'.repeat(32), address: '' }, monitoring: { startBlock: 'latest', pollInterval: 5000, confirmationBlocks: 1, finalityDelayBlocks: 2, replayWindowBlocks: 32, }, retry: { maxRetries: 3, retryDelay: 1 }, queuePersistence: { path: join(tempDir, 'probe-queue-state.json') }, skipMessageIds: new Set(), }, logger); await probeRelay.messageQueue.init(); await probeRelay.messageQueue.add({ messageId: '0xprobe-fail', destinationChainSelector: 5009297550715157269n, sender: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7', receiver: cwReceiver, data: '0x', tokenAmounts: [{ token: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', amount: 1n, amountType: 0 }], }); probeRelay.destinationRelayRouter = { paused: async () => false }; probeRelay.destinationProvider = {}; probeRelay.getDestinationTxOptions = async () => ({ gasLimit: 1000000n }); probeRelay.ensureTargetBridgeInventory = async () => { throw new Error('temporary eth_call failure'); }; const deferredMessage = await probeRelay.messageQueue.getNext(); await probeRelay.relayMessage(deferredMessage); assert( probeRelay.lastError?.scope === 'bridge_inventory_probe', 'inventory probe failures should be tracked under bridge_inventory_probe' ); assert( (await probeRelay.messageQueue.getRetryCount('0xprobe-fail')) === 0, 'inventory probe deferrals should not consume retry budget' ); assert( probeRelay.messageQueue.getStats().queueSize === 1, 'inventory probe deferrals should requeue the original message' ); await rm(tempDir, { recursive: true, force: true }); console.log('OK: relay service structure valid'); process.exit(0);