Files
smom-dbis-138/services/relay/src/RelayService.js
defiQUG 1ec308c3a0
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m12s
CI/CD Pipeline / Security Scanning (push) Successful in 2m21s
CI/CD Pipeline / Lint and Format (push) Failing after 25s
CI/CD Pipeline / Terraform Validation (push) Failing after 24s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 22s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 41s
Validation / validate-genesis (push) Successful in 27s
Validation / validate-terraform (push) Failing after 25s
Validation / validate-kubernetes (push) Failing after 9s
Validation / validate-smart-contracts (push) Failing after 9s
Validation / validate-security (push) Failing after 1m6s
Validation / validate-documentation (push) Failing after 16s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 23s
Verify Deployment / Verify Deployment (push) Failing after 13m52s
Add Monad CCIP deploy scripts, relay hardening, and bridge contract updates.
Includes Monad↔Chain138 CCIP proof/deploy/verify tooling, relay service guards,
bridge integration tweaks, and frontend ENS/network config follow-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 12:52:49 -07:00

1220 lines
44 KiB
JavaScript

/**
* CCIP Relay Service
* Monitors MessageSent events and relays messages to destination chain
*/
import { ethers, NonceManager } from 'ethers';
import { MessageSentABI, RelayRouterABI, RelayBridgeABI, ERC20ABI } from './abis.js';
import { MessageQueue } from './MessageQueue.js';
import { RpcUrlPool } from './rpcPool.js';
import {
isRelayShedding,
getRelaySheddingSourcePollIntervalMs,
getRelaySheddingQueueIdleMs
} from './config.js';
export class RelayService {
constructor(config, logger) {
this.config = config;
this.logger = logger;
this.isRunning = false;
this.sourceProvider = null;
this.destinationProvider = null;
this.destinationSubmitProvider = null;
this.destinationRpcPool = null;
this._readProviders = new Map();
this._activeRelayCount = 0;
this.sourceSigner = null;
this.destinationSigner = null;
this.messageQueue = new MessageQueue(this.logger, {
persistencePath: config.queuePersistence?.path || ''
});
// Contract instances
this.sourceRouter = null;
this.destinationRelayRouter = null;
this.destinationRelayBridge = null;
this.destinationBridgeContracts = new Map();
this.messageSentInterface = new ethers.Interface(MessageSentABI);
/** @type {number} throttle for shedding warning logs */
this._lastSheddingLogTs = 0;
this.startedAt = new Date().toISOString();
this.startEpochMs = Date.now();
this.relayerAddress = '';
this.lastSourcePoll = null;
this.lastSeenMessage = null;
this.lastRelayAttempt = null;
this.lastRelaySuccess = null;
this.lastError = null;
}
getReadProvider(url) {
const key = String(url || '');
if (!this._readProviders.has(key)) {
this._readProviders.set(key, new ethers.JsonRpcProvider(key));
}
return this._readProviders.get(key);
}
getMaxConcurrent() {
const configured = this.config.concurrency?.maxConcurrent;
if (Number.isFinite(configured) && configured >= 1) {
return Math.min(Math.floor(configured), 12);
}
const fromEnv = parseInt(process.env.RELAY_MAX_CONCURRENT || '1', 10);
if (!Number.isFinite(fromEnv) || fromEnv < 1) return 1;
return Math.min(fromEnv, 12);
}
async probeBridgeProcessed(messageId, targetBridge, url) {
const bridge = new ethers.Contract(
targetBridge,
RelayBridgeABI,
this.getReadProvider(url)
);
if (bridge.processed) {
if (await bridge.processed(messageId)) return true;
}
if (bridge.processedTransfers) {
if (await bridge.processedTransfers(messageId)) return true;
}
return false;
}
async getDestinationBridgeContract(targetBridge) {
const key = String(targetBridge).toLowerCase();
let contract = this.destinationBridgeContracts.get(key);
if (!contract) {
contract = new ethers.Contract(targetBridge, RelayBridgeABI, this.destinationProvider);
this.destinationBridgeContracts.set(key, contract);
}
return contract;
}
/** True when destination bridge already recorded this messageId (no relay tx needed). */
async isDeliveredOnDestination(messageId, targetBridge) {
if (process.env.RELAY_SKIP_DESTINATION_PROCESSED_PROBE === '1') {
return false;
}
if (!targetBridge) {
return false;
}
try {
if (this.destinationRpcPool && this.destinationRpcPool.size > 0) {
return await this.destinationRpcPool.withFailover(
'processed probe',
(url) => this.probeBridgeProcessed(messageId, targetBridge, url)
);
}
const bridge = await this.getDestinationBridgeContract(targetBridge);
if (bridge.processed) {
if (await bridge.processed(messageId)) {
return true;
}
}
if (bridge.processedTransfers) {
if (await bridge.processedTransfers(messageId)) {
return true;
}
}
} catch (probeErr) {
this.logger.debug(`Destination processed probe failed for ${messageId}`, probeErr);
}
return false;
}
normalizeAddress(value) {
if (!value) return '';
try {
return ethers.getAddress(value);
} catch (_) {
return '';
}
}
getConfiguredSourceBridge() {
return this.normalizeAddress(this.config.sourceChain.bridgeAddress);
}
getDestinationBridgeAllowlist() {
return (this.config.destinationChain.relayBridgeAllowlist || [])
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean);
}
resolveTargetBridge(receiver) {
let targetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress);
try {
if (receiver) {
const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver);
if (decoded && decoded[0]) {
targetBridge = this.normalizeAddress(decoded[0]) || targetBridge;
}
}
} catch (_) {
// Keep the configured fallback bridge when receiver decode fails.
}
return targetBridge;
}
resolveBridgeForMessage(messageData) {
const linkBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeLinkAddress);
const wethBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress);
const tokenAmounts = messageData.tokenAmounts || [];
if (tokenAmounts.length > 0 && linkBridge) {
const sourceToken = String(tokenAmounts[0].token || '').toLowerCase();
const link138 = '0xb7721dd53a8c629d9f1ba31a5819afe250002b03';
if (sourceToken === link138) {
return linkBridge;
}
}
return this.resolveTargetBridge(messageData.receiver) || wethBridge;
}
mapSourceTokenToDestination(sourceToken) {
const normalized = this.normalizeAddress(sourceToken);
if (!normalized) return sourceToken;
const mapping = this.config.tokenMapping || {};
return (
mapping[normalized] ||
mapping[normalized.toLowerCase()] ||
normalized
);
}
normalizeRelayData(data, receiver, tokenAmounts, targetBridge, sender) {
let normalizedData = data;
const emptyData = !data || data === '0x' || data === '0x0';
const linkBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeLinkAddress);
const target = this.normalizeAddress(targetBridge);
if (emptyData && linkBridge && target && target.toLowerCase() === linkBridge.toLowerCase()) {
let recipient = '';
try {
if (receiver) {
const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver);
recipient = this.normalizeAddress(decoded[0]);
}
} catch (_) {
// Fall through to optional sender-based recipient below.
}
if (!recipient && sender) {
recipient = this.normalizeAddress(sender);
}
const amount = tokenAmounts.length > 0
? (typeof tokenAmounts[0].amount === 'bigint'
? tokenAmounts[0].amount
: BigInt(tokenAmounts[0].amount.toString()))
: 0n;
if (recipient && amount > 0n) {
normalizedData = ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'uint256'],
[recipient, amount]
);
this.logger.info(
`Synthesized LINK bridge payload for ${recipient} amount ${amount.toString()}`
);
}
}
if (process.env.RELAY_EXPAND_TWO_FIELD_PAYLOAD === '1') {
try {
const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address', 'uint256'], normalizedData);
const reencoded2 = ethers.AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [decoded[0], decoded[1]]);
if ((normalizedData || '').toLowerCase() === reencoded2.toLowerCase()) {
normalizedData = ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'uint256', 'address', 'uint256'],
[decoded[0], decoded[1], sender, 0]
);
this.logger.debug('Normalized 2-field payload to 4-field payload');
}
} catch (_) {
// Keep original payload when it does not match (address,uint256).
}
}
return normalizedData;
}
evaluateMessageScope(messageData) {
const sourceBridge = this.getConfiguredSourceBridge();
const sender = this.normalizeAddress(messageData.sender);
const targetBridge = this.resolveBridgeForMessage(messageData);
const allowlist = this.getDestinationBridgeAllowlist();
if (sourceBridge && sender && sender.toLowerCase() !== sourceBridge.toLowerCase()) {
return {
inScope: false,
reason: `sender ${sender} does not match worker source bridge ${sourceBridge}`,
sourceBridge,
sender,
targetBridge
};
}
if (!targetBridge) {
return {
inScope: false,
reason: 'destination bridge could not be resolved from receiver or config',
sourceBridge,
sender,
targetBridge: ''
};
}
if (allowlist.length > 0 && !allowlist.includes(targetBridge.toLowerCase())) {
return {
inScope: false,
reason: `destination bridge ${targetBridge} not in worker allowlist`,
sourceBridge,
sender,
targetBridge
};
}
return {
inScope: true,
reason: '',
sourceBridge,
sender,
targetBridge
};
}
static summarizeError(error) {
if (!error) return '';
if (typeof error === 'string') return error;
return String(error.shortMessage || error.message || error);
}
/**
* Fail fast when RPC URL points at the wrong chain (e.g. Avalanche RPC + mainnet DEST_CHAIN_ID).
*/
async assertNetworkAlignment(label, provider, expectedChainId) {
const network = await provider.getNetwork();
const actual = Number(network.chainId);
const expected = Number(expectedChainId);
if (!Number.isFinite(expected) || expected <= 0) {
throw new Error(`${label} expected chainId not configured (${expectedChainId})`);
}
if (actual !== expected) {
throw new Error(
`${label} RPC chain-id mismatch: provider reports ${actual} but config expects ${expected}. ` +
'Check DEST_RPC_URL / RPC_URL_MAINNET and .env.local (named profiles skip .env.local by default).'
);
}
this.logger.info('%s RPC chain-id OK (%s)', label, actual);
}
recordError(scope, error, extra = {}) {
this.lastError = {
at: new Date().toISOString(),
scope,
message: RelayService.summarizeError(error),
...extra
};
}
getHealthStatus() {
if (!this.isRunning) {
return 'stopped';
}
if (isRelayShedding()) {
return 'paused';
}
if (this.lastSourcePoll && this.lastSourcePoll.ok === false) {
return 'degraded';
}
if (!this.lastSourcePoll || !this.lastSourcePoll.at) {
return 'starting';
}
const lastPollMs = Date.parse(this.lastSourcePoll.at);
const staleAfterMs = Math.max(this.getSourcePollIntervalMs() * 3, 15000);
if (Number.isFinite(lastPollMs) && Date.now() - lastPollMs > staleAfterMs) {
return 'stale';
}
const lastErrorMs = this.lastError && this.lastError.at ? Date.parse(this.lastError.at) : 0;
const lastSuccessMs = this.lastRelaySuccess && this.lastRelaySuccess.at ? Date.parse(this.lastRelaySuccess.at) : 0;
if (
this.lastError &&
(
this.lastError.scope === 'relay_message' ||
this.lastError.scope === 'bridge_inventory' ||
this.lastError.scope === 'bridge_inventory_probe'
) &&
Number.isFinite(lastErrorMs) &&
lastErrorMs > 0 &&
lastErrorMs >= lastSuccessMs
) {
return 'degraded';
}
return 'operational';
}
async ensureTargetBridgeInventory(messageId, targetBridge, tokenAmounts) {
if (process.env.RELAY_ENFORCE_BRIDGE_TOKEN_BALANCE !== '1') {
return { ok: true };
}
for (const tokenAmount of tokenAmounts) {
const tokenAddress = ethers.getAddress(tokenAmount.token);
const requiredAmount = typeof tokenAmount.amount === 'bigint'
? tokenAmount.amount
: BigInt(tokenAmount.amount.toString());
const tokenContract = new ethers.Contract(tokenAddress, ERC20ABI, this.destinationProvider);
const availableAmount = await tokenContract.balanceOf(targetBridge);
if (availableAmount < requiredAmount) {
const shortfall = requiredAmount - availableAmount;
return {
ok: false,
token: tokenAddress,
requiredAmount,
availableAmount,
shortfall,
message: `Insufficient bridge inventory for ${messageId}: ${tokenAddress} available=${availableAmount.toString()} required=${requiredAmount.toString()} shortfall=${shortfall.toString()}`
};
}
}
return { ok: true };
}
getHealthSnapshot() {
const queueStats = this.messageQueue.getStats();
const status = this.getHealthStatus();
const sourceBridge = this.getConfiguredSourceBridge();
const allowlist = this.getDestinationBridgeAllowlist();
const defaultTargetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress);
return {
ok: status === 'operational' || status === 'paused' || status === 'starting',
status,
service: {
name: 'ccip-relay',
running: this.isRunning,
pid: process.pid,
profile: process.env.RELAY_PROFILE || 'default',
started_at: this.startedAt,
uptime_sec: Math.max(0, Math.floor((Date.now() - this.startEpochMs) / 1000)),
relayer_address: this.relayerAddress || this.config.relayer.address || ''
},
source: {
chain_name: this.config.sourceChain.name,
chain_id: this.config.sourceChain.chainId,
chain_selector: this.config.sourceChainSelector.toString(),
router_address: this.config.sourceChain.routerAddress,
bridge_address: this.config.sourceChain.bridgeAddress,
bridge_filter: sourceBridge || ''
},
destination: {
chain_name: this.config.destinationChain.name,
chain_id: this.config.destinationChain.chainId,
chain_selector: this.config.destinationChain.chainSelector.toString(),
relay_router: this.config.destinationChain.relayRouterAddress,
relay_bridge: this.config.destinationChain.relayBridgeAddress,
relay_bridge_default: defaultTargetBridge || '',
relay_bridge_allowlist: allowlist,
delivery_mode: this.config.destinationChain.deliveryMode
},
scope: {
source_bridge: sourceBridge || '',
destination_bridge_default: defaultTargetBridge || '',
destination_bridge_allowlist: allowlist
},
monitoring: {
start_block: String(this.config.monitoring.startBlock),
poll_interval_ms: this.config.monitoring.pollInterval,
effective_source_poll_interval_ms: this.getSourcePollIntervalMs(),
confirmation_blocks: this.config.monitoring.confirmationBlocks,
finality_delay_blocks: this.config.monitoring.finalityDelayBlocks || 0,
replay_window_blocks: this.config.monitoring.replayWindowBlocks || 0,
shedding: isRelayShedding(),
delivery_enabled: !isRelayShedding()
},
queue: {
size: queueStats.queueSize,
processed: queueStats.processed,
failed: queueStats.failed,
in_flight: queueStats.inFlight,
persistence_enabled: queueStats.persistenceEnabled,
last_persisted_at: queueStats.lastPersistedAt
},
concurrency: {
max_concurrent: this.getMaxConcurrent(),
active_relay_tasks: this._activeRelayCount
},
last_source_poll: this.lastSourcePoll,
last_seen_message: this.lastSeenMessage,
last_relay_attempt: this.lastRelayAttempt,
last_relay_success: this.lastRelaySuccess,
last_error: this.lastError
};
}
shouldSkipMessageId(messageId) {
const normalized = String(messageId || '').toLowerCase();
return normalized && this.config.skipMessageIds && this.config.skipMessageIds.has(normalized);
}
/** Polling interval for source router logs (longer while shedding to cut RPC churn). */
getSourcePollIntervalMs() {
if (isRelayShedding()) {
return getRelaySheddingSourcePollIntervalMs();
}
return this.config.monitoring.pollInterval;
}
async getDestinationTxOptions() {
const txOptions = {
gasLimit: BigInt(process.env.RELAY_DEST_GAS_LIMIT || '1000000')
};
if (process.env.RELAY_DEST_LEGACY_TX !== '1') {
return txOptions;
}
let gasPrice = process.env.RELAY_DEST_GAS_PRICE_WEI ? BigInt(process.env.RELAY_DEST_GAS_PRICE_WEI) : null;
if (gasPrice === null) {
try {
const feeData = await this.destinationProvider.getFeeData();
if (feeData.gasPrice) {
gasPrice = feeData.gasPrice;
}
} catch (_) {
// Fall through to raw RPC fallback below.
}
}
if (gasPrice === null) {
const gasProvider = this.destinationSubmitProvider || this.destinationProvider;
const rawGasPrice = await gasProvider.send('eth_gasPrice', []);
gasPrice = BigInt(rawGasPrice);
}
const bumpPct = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_PCT || '20');
const bumpWei = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_WEI || '1000000');
gasPrice = gasPrice + (gasPrice * bumpPct) / 100n + bumpWei;
txOptions.type = 0;
txOptions.gasPrice = gasPrice;
return txOptions;
}
/** Prevent indefinite hang when mainnet confirmation stalls (blocks queue processor). */
async waitForDestinationReceipt(tx, messageId) {
const timeoutMs = parseInt(process.env.RELAY_TX_CONFIRM_TIMEOUT_MS || '180000', 10);
const safeTimeout = Number.isFinite(timeoutMs) && timeoutMs >= 30_000 ? timeoutMs : 180_000;
let timer;
try {
return await Promise.race([
tx.wait(),
new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`tx.wait timed out after ${safeTimeout}ms for ${messageId}${tx?.hash ? ` (${tx.hash})` : ''}`
)
),
safeTimeout
);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async start() {
this.logger.info('Initializing relay service...');
const poolUrls =
(this.config.destinationChain.rpcPoolUrls && this.config.destinationChain.rpcPoolUrls.length > 0)
? this.config.destinationChain.rpcPoolUrls
: [this.config.destinationChain.rpcUrl];
this.destinationRpcPool = new RpcUrlPool(poolUrls, this.logger);
const submitUrl = this.config.destinationChain.submitRpcUrl || poolUrls[0];
this.sourceProvider = new ethers.JsonRpcProvider(this.config.sourceChain.rpcUrl);
this.destinationProvider = new ethers.JsonRpcProvider(poolUrls[0]);
this.destinationSubmitProvider =
submitUrl === poolUrls[0]
? this.destinationProvider
: new ethers.JsonRpcProvider(submitUrl);
await this.assertNetworkAlignment('source', this.sourceProvider, this.config.sourceChain.chainId);
await this.assertNetworkAlignment('destination', this.destinationProvider, this.config.destinationChain.chainId);
if (this.destinationSubmitProvider !== this.destinationProvider) {
await this.assertNetworkAlignment(
'destination-submit',
this.destinationSubmitProvider,
this.config.destinationChain.chainId
);
this.logger.info('Destination submit RPC: %s', submitUrl);
}
if (!this.config.relayer.privateKey) {
throw new Error('Relayer private key not configured');
}
this.sourceSigner = new ethers.Wallet(this.config.relayer.privateKey, this.sourceProvider);
const destinationWallet = new ethers.Wallet(
this.config.relayer.privateKey,
this.destinationSubmitProvider
);
this.destinationSigner = new NonceManager(destinationWallet);
this.relayerAddress = String(destinationWallet.address);
await this.messageQueue.init();
this.logger.info('Relayer address: %s', this.relayerAddress);
this.logger.info(
'Relay concurrency: maxConcurrent=%s rpcPoolSize=%s',
this.getMaxConcurrent(),
poolUrls.length
);
// Validate relay router address (bridge can be dynamic from message receiver)
if (!this.config.destinationChain.relayRouterAddress ||
this.config.destinationChain.relayRouterAddress === '') {
throw new Error(`Relay router address must be configured on destination chain. Router: ${this.config.destinationChain.relayRouterAddress}`);
}
// Initialize contract instances
this.sourceRouter = new ethers.Contract(
this.config.sourceChain.routerAddress,
MessageSentABI,
this.sourceProvider
);
this.destinationRelayRouter = new ethers.Contract(
this.config.destinationChain.relayRouterAddress,
RelayRouterABI,
this.destinationSigner
);
// Backward-compatible static destination bridge (optional)
if (this.config.destinationChain.relayBridgeAddress) {
this.destinationRelayBridge = new ethers.Contract(
this.config.destinationChain.relayBridgeAddress,
RelayBridgeABI,
this.destinationProvider
);
}
// Start monitoring and queue drain concurrently (monitoring loop never returns).
this.isRunning = true;
void this.startProcessingQueue();
await this.startMonitoring();
}
async stop() {
this.logger.info('Stopping relay service...');
this.isRunning = false;
const deadline = Date.now() + 30_000;
while (this._activeRelayCount > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
await this.messageQueue.persistSnapshot();
}
/** Preferred chunk size for scanning (adaptive split handles stricter RPCs). Override: SOURCE_LOGS_MAX_BLOCK_RANGE */
getSourceLogsMaxBlockRange() {
const v = parseInt(
process.env.SOURCE_LOGS_MAX_BLOCK_RANGE || process.env.RELAY_SOURCE_LOGS_MAX_BLOCK_RANGE || '8000',
10
);
return Number.isFinite(v) && v >= 500 ? v : 8000;
}
static _isRpcLogRangeError(err) {
const msg = String(err && err.message ? err.message : err);
return (
msg.includes('maximum RPC range') ||
msg.includes('exceeds maximum') ||
msg.includes('requested too many blocks') ||
msg.includes('maximum is set to') ||
(msg.includes('-32000') && (
msg.includes('range') ||
msg.includes('too many blocks') ||
msg.includes('maximum is set to')
))
);
}
async fetchSourceLogs(fromBlock, toBlock) {
const payload = {
jsonrpc: '2.0',
id: 1,
method: 'eth_getLogs',
params: [{
address: this.config.sourceChain.routerAddress,
fromBlock: ethers.toQuantity(fromBlock),
toBlock: ethers.toQuantity(toBlock),
topics: [this.messageSentInterface.getEvent('MessageSent').topicHash]
}]
};
const response = await fetch(this.config.sourceChain.rpcUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`eth_getLogs HTTP ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`eth_getLogs RPC ${data.error.code}: ${data.error.message}`);
}
return Array.isArray(data.result) ? data.result : [];
}
_sortRawLogs(all) {
all.sort((a, b) => {
const ba = Number(BigInt(a.blockNumber));
const bb = Number(BigInt(b.blockNumber));
if (ba !== bb) return ba - bb;
return String(a.logIndex || '').localeCompare(String(b.logIndex || ''));
});
return all;
}
/**
* Single eth_getLogs for [fromBlock,toBlock]; on RPC range-limit errors, bisect until requests fit.
*/
async fetchSourceLogsAdaptive(fromBlock, toBlock) {
if (toBlock < fromBlock) return [];
try {
return await this.fetchSourceLogs(fromBlock, toBlock);
} catch (e) {
if (!RelayService._isRpcLogRangeError(e) || fromBlock === toBlock) throw e;
const mid = Math.floor((fromBlock + toBlock) / 2);
const left = await this.fetchSourceLogsAdaptive(fromBlock, mid);
const right = await this.fetchSourceLogsAdaptive(mid + 1, toBlock);
return this._sortRawLogs([...left, ...right]);
}
}
/**
* Scan MessageSent logs from fromBlock..toBlock using coarse chunks, each resolved via adaptive splits.
*/
async fetchSourceLogsChunked(fromBlock, toBlock) {
const maxSpan = this.getSourceLogsMaxBlockRange();
if (toBlock < fromBlock) return [];
const all = [];
let cursor = fromBlock;
while (cursor <= toBlock && this.isRunning) {
const chunkEnd = Math.min(toBlock, cursor + maxSpan - 1);
const chunk = await this.fetchSourceLogsAdaptive(cursor, chunkEnd);
if (chunk.length) all.push(...chunk);
cursor = chunkEnd + 1;
}
return this._sortRawLogs(all);
}
async startMonitoring() {
this.logger.info('Starting event monitoring...');
let startBlock;
if (this.config.monitoring.startBlock === 'latest' || isNaN(this.config.monitoring.startBlock)) {
const currentBlock = await this.sourceProvider.getBlockNumber();
startBlock = currentBlock > 0 ? currentBlock - 1 : 0;
} else {
startBlock = parseInt(this.config.monitoring.startBlock);
}
this.logger.info(`Monitoring from block: ${startBlock}`);
// HTTP filter subscriptions are unreliable on several RPCs used here.
// Polling via queryFilter is the default and can be overridden explicitly.
if (process.env.RELAY_USE_EVENT_SUBSCRIPTIONS === '1') {
this.sourceRouter.on('MessageSent', async (messageId, destinationChainSelector, sender, receiver, data, tokenAmounts, feeToken, extraArgs, event) => {
try {
const destSelector = destinationChainSelector.toString();
const expectedSelector = this.config.destinationChain.chainSelector.toString();
if (this.shouldSkipMessageId(messageId)) {
this.logger.warn(`Skipping MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
return;
}
if (destSelector !== expectedSelector) {
this.logger.debug(`Ignoring message for different chain: ${destSelector}`);
return;
}
const scope = this.evaluateMessageScope({ messageId, sender, receiver, tokenAmounts });
if (!scope.inScope) {
this.logger.debug(`Ignoring message ${messageId}; ${scope.reason}`);
return;
}
this.logger.info('MessageSent event detected:', {
messageId: messageId,
destinationChainSelector: destinationChainSelector.toString(),
sender: sender,
targetBridge: scope.targetBridge,
blockNumber: event.blockNumber,
transactionHash: event.transactionHash
});
const receipt = await event.getTransactionReceipt();
const confirmations = this.config.monitoring.confirmationBlocks;
if (confirmations > 0) {
await receipt.confirmations(confirmations);
}
const formattedTokenAmounts = tokenAmounts.map(ta => ({
token: ta.token,
amount: ta.amount,
amountType: ta.amountType
}));
if (await this.isDeliveredOnDestination(messageId, scope.targetBridge)) {
this.logger.info(`Message ${messageId} already on destination; recording processed (live event)`);
await this.messageQueue.markProcessed(messageId);
return;
}
await this.messageQueue.add({
messageId,
destinationChainSelector,
sender,
receiver,
targetBridge: scope.targetBridge,
data,
tokenAmounts: formattedTokenAmounts,
feeToken,
extraArgs,
blockNumber: event.blockNumber,
transactionHash: event.transactionHash
});
} catch (error) {
this.logger.error('Error processing MessageSent event:', error);
}
});
}
// Also poll from start block to catch any missed events
this.pollHistoricalEvents(startBlock);
}
async pollHistoricalEvents(startBlock) {
while (this.isRunning) {
try {
const currentBlock = await this.sourceProvider.getBlockNumber();
const finalityDelay = this.config.monitoring.finalityDelayBlocks || 0;
const replayWindow = this.config.monitoring.replayWindowBlocks || 0;
const toBlock = currentBlock > finalityDelay ? currentBlock - finalityDelay : currentBlock;
if (toBlock >= startBlock) {
this.logger.info(`Polling events from block ${startBlock} to ${toBlock}`);
const logs = await this.fetchSourceLogsChunked(startBlock, toBlock);
this.lastSourcePoll = {
at: new Date().toISOString(),
ok: true,
from_block: startBlock,
to_block: toBlock,
logs_fetched: logs.length
};
if (this.lastError && this.lastError.scope === 'source_poll') {
this.lastError = null;
}
this.logger.info(`Fetched ${logs.length} MessageSent log(s) from source router`);
for (const log of logs) {
let decoded;
try {
decoded = this.messageSentInterface.parseLog(log);
} catch (error) {
this.logger.error('Error decoding MessageSent log:', error);
continue;
}
const { messageId, destinationChainSelector, sender, receiver, data, tokenAmounts, feeToken, extraArgs } = decoded.args;
const destSelector = destinationChainSelector.toString();
const expectedSelector = this.config.destinationChain.chainSelector.toString();
if (this.shouldSkipMessageId(messageId)) {
this.logger.warn(`Skipping historical MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
continue;
}
if (destSelector !== expectedSelector) {
continue;
}
const scope = this.evaluateMessageScope({ messageId, sender, receiver, tokenAmounts });
if (!scope.inScope) {
this.logger.debug(`Ignoring historical message ${messageId}; ${scope.reason}`);
continue;
}
this.logger.info('Historical MessageSent detected:', {
messageId,
destinationChainSelector: destSelector,
sender,
targetBridge: scope.targetBridge,
blockNumber: log.blockNumber,
transactionHash: log.transactionHash
});
this.lastSeenMessage = {
at: new Date().toISOString(),
message_id: messageId,
destination_chain_selector: destSelector,
sender,
block_number: log.blockNumber,
transaction_hash: log.transactionHash
};
if (await this.isDeliveredOnDestination(messageId, scope.targetBridge)) {
await this.messageQueue.markProcessed(messageId);
continue;
}
await this.messageQueue.add({
messageId,
destinationChainSelector,
sender,
receiver,
targetBridge: scope.targetBridge,
data,
tokenAmounts: tokenAmounts.map((ta) => ({
token: ta.token,
amount: ta.amount,
amountType: ta.amountType
})),
feeToken,
extraArgs,
blockNumber: log.blockNumber,
transactionHash: log.transactionHash
}, { forceReplay: true });
}
startBlock = Math.max(0, toBlock - replayWindow + 1);
}
// Wait before next poll (longer interval while relay shedding is on)
await new Promise(resolve => setTimeout(resolve, this.getSourcePollIntervalMs()));
} catch (error) {
this.logger.error('Error polling historical events:', error);
this.lastSourcePoll = {
at: new Date().toISOString(),
ok: false,
from_block: startBlock,
error: RelayService.summarizeError(error)
};
this.recordError('source_poll', error, {
from_block: startBlock
});
await new Promise(resolve => setTimeout(resolve, this.getSourcePollIntervalMs()));
}
}
}
async startProcessingQueue() {
const maxConcurrent = this.getMaxConcurrent();
this.logger.info('Starting message queue processor (maxConcurrent=%s)...', maxConcurrent);
const worker = async (workerId) => {
while (this.isRunning) {
try {
if (isRelayShedding()) {
if (workerId === 0) {
const stats = this.messageQueue.getStats();
const now = Date.now();
if (stats.queueSize > 0 && now - this._lastSheddingLogTs >= 60000) {
this._lastSheddingLogTs = now;
this.logger.warn(
`Relay shedding ON: ${stats.queueSize} message(s) queued; destination delivery paused. ` +
`Set RELAY_SHEDDING=0 and RELAY_DELIVERY_ENABLED=1 then restart (or reload env) to deliver.`
);
}
}
await new Promise((r) => setTimeout(r, getRelaySheddingQueueIdleMs()));
continue;
}
const message = await this.messageQueue.getNext();
if (!message) {
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
this._activeRelayCount += 1;
try {
await this.relayMessage(message);
} catch (error) {
this.logger.error(`Worker ${workerId} relay error:`, error);
} finally {
this._activeRelayCount = Math.max(0, this._activeRelayCount - 1);
}
} catch (error) {
this.logger.error(`Worker ${workerId} queue loop error:`, error);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
};
for (let i = 0; i < maxConcurrent; i += 1) {
void worker(i);
}
}
async relayMessage(messageData) {
const { messageId, destinationChainSelector, sender, receiver, data, tokenAmounts } = messageData;
if (this.shouldSkipMessageId(messageId)) {
this.logger.warn(`Skipping queued message ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
await this.messageQueue.markProcessed(messageId);
return null;
}
const scope = this.evaluateMessageScope(messageData);
if (!scope.inScope) {
this.logger.info(`Skipping queued message ${messageId}; ${scope.reason}`);
await this.messageQueue.markProcessed(messageId);
return null;
}
this.logger.info(`Relaying message ${messageId} to destination chain...`);
try {
// On-chain pause (CCIPRelayRouter Pausable): avoid broadcasting reverting txs
if (this.config.destinationChain.deliveryMode !== 'direct' && this.destinationRelayRouter) {
try {
const routerPaused = await this.destinationRelayRouter.paused();
if (routerPaused) {
this.logger.warn(
`Destination relay router is paused; deferring ${messageId} (enable RELAY_SHEDDING=1 to pause off-chain too)`
);
await this.messageQueue.resetRetryCount(messageId);
await this.messageQueue.retry(messageId, { increment: false });
await new Promise((r) => setTimeout(r, 15000));
return null;
}
} catch (pauseCheckErr) {
this.logger.debug('Router paused() check skipped or unsupported', pauseCheckErr);
}
}
// Route to bridge encoded in MessageSent.receiver (bytes). Fallback to static env bridge.
const targetBridge = scope.targetBridge || this.resolveBridgeForMessage(messageData);
if (!targetBridge) {
throw new Error(`No destination bridge for message ${messageId}: receiver decode failed and DEST_RELAY_BRIDGE not set`);
}
this.lastRelayAttempt = {
at: new Date().toISOString(),
message_id: messageId,
destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '',
target_bridge: targetBridge,
token_count: tokenAmounts.length
};
// Optional allowlist hardening.
const allowlist = this.getDestinationBridgeAllowlist();
if (allowlist.length > 0 && !allowlist.includes(String(targetBridge).toLowerCase())) {
this.logger.info(
`Skipping message ${messageId} for bridge ${targetBridge}; not in this worker's DEST_RELAY_BRIDGE_ALLOWLIST`
);
await this.messageQueue.markProcessed(messageId);
return null;
}
const targetBridgeContract = await this.getDestinationBridgeContract(targetBridge);
const alreadyProcessed = await this.isDeliveredOnDestination(messageId, targetBridge);
if (alreadyProcessed) {
this.logger.info(`Message ${messageId} already processed on destination; skipping relay tx`);
await this.messageQueue.markProcessed(messageId);
return null;
}
// Dedicated cW bridge workers can replay historical messages for canonical
// assets that are no longer configured on the destination multi-token bridge.
// When that mapping is absent, the destination bridge will always revert with
// "token not configured", so retire the message instead of retry-looping it.
if (targetBridgeContract.canonicalToMirrored && data && tokenAmounts.length === 0) {
try {
const decodedPayload = ethers.AbiCoder.defaultAbiCoder().decode(
['address', 'address', 'uint256'],
data
);
const canonicalToken = ethers.getAddress(decodedPayload[0]);
const mirroredToken = await targetBridgeContract.canonicalToMirrored(canonicalToken);
if (mirroredToken === ethers.ZeroAddress) {
this.logger.warn(
`Skipping queued message ${messageId}; canonical token ${canonicalToken} is not configured on destination bridge ${targetBridge}`
);
await this.messageQueue.markProcessed(messageId);
return null;
}
} catch (mappingProbeErr) {
this.logger.debug(
`canonicalToMirrored probe skipped for ${messageId}; continuing with relay attempt`,
mappingProbeErr
);
}
}
// Map token addresses from source chain to destination chain
const mappedTokenAmounts = tokenAmounts.map(ta => {
const sourceToken = ethers.getAddress(ta.token);
const destinationToken = this.mapSourceTokenToDestination(sourceToken);
this.logger.debug(`Mapping token ${sourceToken} -> ${destinationToken}`);
return {
token: destinationToken, // Map to destination chain token address
amount: typeof ta.amount === 'bigint' ? ta.amount : BigInt(ta.amount.toString()),
amountType: Number(ta.amountType) // Ensure it's a number (uint8)
};
});
let inventoryCheck;
try {
inventoryCheck = await this.ensureTargetBridgeInventory(
messageId,
targetBridge,
mappedTokenAmounts
);
} catch (inventoryProbeError) {
this.logger.warn(
`Bridge inventory probe failed for ${messageId}; deferring message until the next cycle`,
inventoryProbeError
);
this.recordError('bridge_inventory_probe', inventoryProbeError, {
message_id: messageId,
target_bridge: targetBridge
});
await this.messageQueue.resetRetryCount(messageId);
await this.messageQueue.retry(messageId, { increment: false });
await new Promise(resolve => setTimeout(resolve, this.config.retry.retryDelay));
return null;
}
if (!inventoryCheck.ok) {
const inventoryError = new Error(inventoryCheck.message);
this.logger.warn(inventoryCheck.message);
this.recordError('bridge_inventory', inventoryError, {
message_id: messageId,
target_bridge: targetBridge,
token: inventoryCheck.token,
available_amount: inventoryCheck.availableAmount.toString(),
required_amount: inventoryCheck.requiredAmount.toString(),
shortfall: inventoryCheck.shortfall.toString()
});
await this.messageQueue.resetRetryCount(messageId);
await this.messageQueue.retry(messageId, { increment: false });
await new Promise(resolve => setTimeout(resolve, this.config.retry.retryDelay));
return null;
}
const normalizedData = this.normalizeRelayData(
data,
messageData.receiver,
tokenAmounts,
targetBridge,
sender
);
// Construct Any2EVMMessage struct
// tokenAmounts is an array of TokenAmount structs: { token: address, amount: uint256, amountType: uint8 }
const any2EVMMessage = {
messageId: messageId,
sourceChainSelector: BigInt(this.config.sourceChainSelector.toString()),
sender: ethers.AbiCoder.defaultAbiCoder().encode(['address'], [sender]),
data: normalizedData,
tokenAmounts: mappedTokenAmounts
};
this.logger.debug('Relaying message with struct:', {
messageId: any2EVMMessage.messageId,
sourceChainSelector: any2EVMMessage.sourceChainSelector,
tokenAmountsCount: any2EVMMessage.tokenAmounts.length
});
const txOptions = await this.getDestinationTxOptions();
let tx;
if (this.config.destinationChain.deliveryMode === 'direct') {
this.logger.info(`Direct-delivery mode: calling bridge ${targetBridge} without relay router`);
const directBridge = new ethers.Contract(
targetBridge,
RelayBridgeABI,
this.destinationSigner
);
tx = await directBridge.ccipReceive(any2EVMMessage, txOptions);
} else {
// Call relay router with properly formatted struct
tx = await this.destinationRelayRouter.relayMessage(
targetBridge,
any2EVMMessage,
txOptions
);
}
this.logger.info(`Relay transaction sent: ${tx.hash}`);
const receipt = await this.waitForDestinationReceipt(tx, messageId);
this.logger.info(`Message ${messageId} relayed successfully. Transaction: ${receipt.hash}`);
this.lastRelaySuccess = {
at: new Date().toISOString(),
message_id: messageId,
destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '',
target_bridge: targetBridge,
tx_hash: receipt.hash
};
if (
this.lastError &&
(
this.lastError.scope === 'relay_message' ||
this.lastError.scope === 'bridge_inventory' ||
this.lastError.scope === 'bridge_inventory_probe'
)
) {
this.lastError = null;
}
// Mark message as processed
await this.messageQueue.markProcessed(messageId);
return receipt;
} catch (error) {
this.logger.error(`Error relaying message ${messageId}:`, error);
this.recordError('relay_message', error, {
message_id: messageId
});
// Retry logic
const retryCount = await this.messageQueue.getRetryCount(messageId);
if (retryCount < this.config.retry.maxRetries) {
this.logger.info(`Retrying message ${messageId} (attempt ${retryCount + 1})`);
await this.messageQueue.retry(messageId);
await new Promise(resolve => setTimeout(resolve, this.config.retry.retryDelay));
} else {
this.logger.error(`Message ${messageId} failed after ${this.config.retry.maxRetries} retries`);
await this.messageQueue.markFailed(messageId);
}
throw error;
}
}
}