diff --git a/services/relay/.env.bsc.example b/services/relay/.env.bsc.example new file mode 100644 index 0000000..fb4ca20 --- /dev/null +++ b/services/relay/.env.bsc.example @@ -0,0 +1,25 @@ +# Copy to .env.bsc and adjust. start-relay.sh loads parent smom-dbis-138/.env for PRIVATE_KEY. +# RPC_URL_138: use Core RPC on LAN for deploy; public RPC ok for read-only relay scans. +RPC_URL_138=http://192.168.11.211:8545 +CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 +CCIPWETH9_BRIDGE_CHAIN138=0xcacfd227A040002e49e2e01626363071324f820a +SOURCE_CHAIN_SELECTOR=138 + +DEST_CHAIN_NAME=BSC +DEST_CHAIN_ID=56 +DEST_RPC_URL=https://bsc.publicnode.com +DEST_CHAIN_SELECTOR=11344663589394136015 +DEST_RELAY_ROUTER=0x4d9Bc6c74ba65E37c4139F0aEC9fc5Ddff28Dcc4 +DEST_RELAY_BRIDGE=0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C +DEST_WETH9_ADDRESS=0xe0E93247376aa097dB308B92e6Ba36bA015535D0 + +RELAYER_PRIVATE_KEY=${PRIVATE_KEY} +RELAYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 + +START_BLOCK=latest +POLL_INTERVAL=5000 +CONFIRMATION_BLOCKS=1 +MAX_RETRIES=3 +RETRY_DELAY=5000 +LOG_LEVEL=info +DEST_RELAY_BRIDGE_ALLOWLIST=0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C diff --git a/services/relay/README.md b/services/relay/README.md index 11b9b92..233e4cc 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -1,217 +1,114 @@ # CCIP Relay Service -Custom relay mechanism for delivering CCIP messages from Chain 138 to Ethereum Mainnet. +Off-chain relay for forwarding Chain 138 `MessageSent` events to destination relay routers/bridges. -## Architecture +## Current Topology -The relay system consists of: +Source (Chain 138) — match `.env.bsc` / operator deploy: +- Router: `0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817` +- WETH9 bridge: `0xcacfd227A040002e49e2e01626363071324f820a` -1. **CCIPRelayRouter** (on Ethereum Mainnet): Receives relayed messages and forwards them to bridge contracts -2. **CCIPRelayBridge** (on Ethereum Mainnet): Receives messages from relay router and transfers tokens to recipients -3. **Relay Service** (off-chain): Monitors MessageSent events on Chain 138 and relays messages to Ethereum Mainnet +Destinations: +- BSC relay router: `0x4d9Bc6c74ba65E37c4139F0aEC9fc5Ddff28Dcc4` +- BSC relay bridge: `0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C` +- AVAX relay router: `0x2a0023Ad5ce1Ac6072B454575996DfFb1BB11b16` +- AVAX relay bridge: `0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F` -## Current Deployment +## Env Profiles -### Deployed Contracts (Ethereum Mainnet) +Use the prebuilt env files in this folder: +- `.env.bsc` (template: `.env.bsc.example`) +- `.env.avax` +- `.env` (default/fallback) -- **Relay Router**: `0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb` -- **Relay Bridge**: `0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939` -- **WETH9**: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` +Each profile sets destination RPC, selector, relay router/bridge, and destination WETH token. -### Source Chain (Chain 138) +### `START_BLOCK` after catch-up -- **CCIP Router** (emits MessageSent): `0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817` -- **WETH9 Bridge** (LINK fee): `0xcacfd227A040002e49e2e01626363071324f820a` -- **WETH9 Bridge** (native ETH fee): `0x63cbeE010D64ab7F1760ad84482D6cC380435ab5` -- **WETH9**: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` +When historical `MessageSent` logs are fully relayed, set **`START_BLOCK=latest`** in `.env.bsc` (or your profile) so a cold start only scans from **~current head − 1** instead of re-queuing the whole backfill range. To replay from an old height again, set an explicit decimal block (e.g. `3012930`) and restart. -Both bridges have mainnet destination set to **CCIPRelayBridge** (`0xF9A32F37...`) so the relay service delivers to mainnet. +**BSC RPC:** Prefer a node that accepts short `eth_getLogs` windows (e.g. `https://bsc.publicnode.com`). Some Binance seeds return `-32005` for log queries the relay uses for destination checks. -## Deployment +### Fund BSC relay bridge (WETH) -### 1. Deploy Relay Contracts on Ethereum Mainnet +From repo root (loads `smom-dbis-138/.env` and relay `.env.bsc` for addresses): ```bash -cd /home/intlc/projects/proxmox/smom-dbis-138 - -# Set environment variables -export PRIVATE_KEY=0x... -export RPC_URL_MAINNET=https://mainnet.infura.io/v3/YOUR_PROJECT_ID # or Alchemy; avoid public RPC 429 -export WETH9_MAINNET=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 -export RELAYER_ADDRESS=0x... # Address that will run relay service - -# Deploy -forge script script/DeployCCIPRelay.s.sol:DeployCCIPRelay \ - --rpc-url $RPC_URL_MAINNET \ - --broadcast \ - --legacy \ - --via-ir +./scripts/bridge/fund-bsc-relay-bridge.sh --dry-run +./scripts/bridge/fund-bsc-relay-bridge.sh # full deployer WETH → bridge +# ./scripts/bridge/fund-bsc-relay-bridge.sh 1000000000000000 # 0.001 WETH wei ``` -### 2. Configure Environment +Wrap BNB to WETH on the deployer first (`cast send "deposit()" --value ...` on BSC) if needed. -The service uses environment variables. Create `.env` file in `services/relay/`: +## Relay shedding (save destination gas) + +When **no** 138→Mainnet (or configured destination) relay deliveries are needed, pause **destination-chain** transactions so the relayer does not spend native gas on `relayMessage` / direct `ccipReceive`: + +| Variable | Meaning | +|----------|---------| +| `RELAY_SHEDDING=1` | **On** — shedding active (`true` / `yes` / `on` also work). | +| `RELAY_DELIVERY_ENABLED=0` | Same as shedding on (`false` / `no` / `off`). | +| `RELAY_SHEDDING_SOURCE_POLL_INTERVAL_MS` | Source router log poll interval while shedding (default **60000** ms, min 5000). Reduces Chain 138 RPC usage. | +| `RELAY_SHEDDING_QUEUE_POLL_MS` | Idle interval for the queue loop while shedding (default **5000** ms, min 1000). | + +**Behavior:** Source `MessageSent` logs are still ingested and messages **queue in memory**. When you set `RELAY_SHEDDING=0` (and `RELAY_DELIVERY_ENABLED=1`) and **restart** the service, pending messages are delivered as usual. For production, plan shedding around low bridge traffic so the queue stays small (in-memory queue is lost on process crash). + +### On-chain pause (`CCIPRelayRouter`) + +The destination **CCIPRelayRouter** inherits OpenZeppelin **`Pausable`**: admins with `DEFAULT_ADMIN_ROLE` may call **`pause()`** / **`unpause()`**. While paused, **`relayMessage` reverts** (no delivery through the router). + +**Relay service:** Before sending `relayMessage`, the worker calls **`paused()`** on the destination router (router mode only). If paused, it **re-queues** the message and waits 15s instead of broadcasting a reverting tx. Older routers without `paused()` skip this check (call errors are logged at debug). + +**Important:** If you `pause()` the router but leave the relay **process** running **without** `RELAY_SHEDDING=1`, failed txs are much less likely thanks to the check above, but off-chain activity (source polling, queue growth) still runs. Prefer **`RELAY_SHEDDING=1`** (or stop the service) whenever the router is paused for an extended period. + +**Direct-delivery** mode (`DEST_DELIVERY_MODE=direct`) calls the bridge’s `ccipReceive` directly and **does not** go through the router—pause the router alone does not stop that path; use shedding or revoke `ROUTER_ROLE` on the bridge as appropriate. + +## Start Relay ```bash -# Source Chain (Chain 138) -RPC_URL_138=https://rpc-core.d-bis.org -CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817 -CCIPWETH9_BRIDGE_CHAIN138=0xcacfd227A040002e49e2e01626363071324f820a - -# Destination Chain (Ethereum Mainnet) — use Infura/Alchemy to avoid 429 rate limits -RPC_URL_MAINNET=https://mainnet.infura.io/v3/YOUR_PROJECT_ID -# Or set ETHEREUM_MAINNET_RPC in smom-dbis-138/.env; relay uses RPC_URL_MAINNET first, then ETHEREUM_MAINNET_RPC -CCIP_RELAY_ROUTER_MAINNET=0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb -CCIP_RELAY_BRIDGE_MAINNET=0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 - -# Relayer Configuration -PRIVATE_KEY=0x... # Private key for relayer (needs ETH on mainnet for gas) -RELAYER_PRIVATE_KEY=${PRIVATE_KEY} # Alternative name - -# Monitoring Configuration -START_BLOCK=latest # or specific block number (e.g., 242500) -POLL_INTERVAL=5000 # milliseconds -CONFIRMATION_BLOCKS=1 - -# Retry Configuration -MAX_RETRIES=3 -RETRY_DELAY=5000 # milliseconds - -# Chain Selectors -SOURCE_CHAIN_ID=138 -DESTINATION_CHAIN_SELECTOR=5009297550715157269 -``` - -**Important**: If `PRIVATE_KEY` contains variable expansion (e.g., `${PRIVATE_KEY}`), create a `.env.local` file with the expanded value to avoid issues with `dotenv`. - -### 3. Install Dependencies - -```bash -cd services/relay +cd /home/intlc/projects/proxmox/smom-dbis-138/services/relay npm install -``` -### 4. Start Relay Service +# BSC relay profile +./start-relay.sh bsc -```bash -# Start service -npm start +# AVAX relay profile +./start-relay.sh avax -# Or use the wrapper script (recommended) +# Default profile ./start-relay.sh ``` -## How It Works +`start-relay.sh` loads env in this order: +1. `.env.` (if profile argument provided) +2. `.env.local` +3. `.env` -1. **Event Monitoring**: The relay service monitors `MessageSent` events from the CCIP Router on Chain 138 -2. **Message Queue**: Detected messages are added to a queue for processing -3. **Token Address Mapping**: Source chain token addresses are mapped to destination chain addresses -4. **Message Relay**: For each message: - - Constructs the `Any2EVMMessage` format with mapped token addresses - - Calls `relayMessage` on the Relay Router contract on Ethereum Mainnet - - Relay Router forwards to Relay Bridge - - Relay Bridge calls `ccipReceive` and transfers tokens to recipient - -## Configuration - -Key configuration options in `.env`: - -- `RPC_URL_138`: RPC endpoint for Chain 138 (default: `http://192.168.11.250:8545`) -- `RPC_URL_MAINNET`: RPC endpoint for Ethereum Mainnet (relay also reads `ETHEREUM_MAINNET_RPC` from smom-dbis-138/.env). Prefer Infura `https://mainnet.infura.io/v3/` or Alchemy to avoid public RPC rate limits (429). Default fallback: `https://ethereum.publicnode.com`. -- `PRIVATE_KEY` or `RELAYER_PRIVATE_KEY`: Private key for relayer (needs ETH on mainnet for gas) -- `START_BLOCK`: Block number to start monitoring from (default: `latest` or specific block number) -- `POLL_INTERVAL`: How often to poll for new events in milliseconds (default: `5000`) -- `CONFIRMATION_BLOCKS`: Number of confirmations to wait before processing (default: `1`) -- `MAX_RETRIES`: Maximum retry attempts for failed relays (default: `3`) -- `RETRY_DELAY`: Delay between retries in milliseconds (default: `5000`) +If parent project `.env` defines `PRIVATE_KEY`, `${PRIVATE_KEY}` references in relay env files are expanded. ## Critical Requirements -### Bridge Funding +- Relayer key must hold native gas on destination chain. +- Destination relay bridge must hold enough WETH for payouts. +- Source bridge destination mapping must point to the correct destination relay bridge. +- Source router `feeToken()` must be a deployed ERC20 with sufficient deployer balance. -**The relay bridge must be funded with WETH9 tokens before it can complete transfers.** +## Fast Status Checks -- Bridge Address: `0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939` -- WETH9 Address: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` -- Current Status: Bridge must have sufficient WETH9 to cover all transfers - -To check bridge balance: +Check source destination mappings: ```bash -cast call 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 \ - "balanceOf(address)" \ - 0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 \ - --rpc-url $RPC_URL_MAINNET +cast call 0xcacfd227A040002e49e2e01626363071324f820a "destinations(uint64)" 11344663589394136015 --rpc-url https://rpc.public-0138.defi-oracle.io +cast call 0xcacfd227A040002e49e2e01626363071324f820a "destinations(uint64)" 6433500567565415381 --rpc-url https://rpc.public-0138.defi-oracle.io ``` -To fund the bridge (if you have WETH9): +Check message settlement: ```bash -cast send 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 \ - "transfer(address,uint256)" \ - 0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939 \ - 20000000000000000000000 \ - --rpc-url $RPC_URL_MAINNET \ - --private-key $PRIVATE_KEY +cast call 0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C "processedTransfers(bytes32)(bool)" --rpc-url https://bsc.publicnode.com +cast call 0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F "processedTransfers(bytes32)(bool)" --rpc-url https://avalanche-c-chain.publicnode.com ``` -## Security Considerations - -- **Relayer Role**: Only addresses with relayer role can call `relayMessage` -- **Bridge Authorization**: Only authorized bridges can receive messages -- **Replay Protection**: Messages are tracked by messageId to prevent duplicate processing -- **Access Control**: Admin can add/remove bridges and relayers -- **Token Address Mapping**: Source chain token addresses are mapped to destination chain addresses - -## Monitoring - -The service logs all activities. Check logs for: -- `relay-service.log`: Combined log output -- Console output: Real-time status - -Monitor key metrics: -- Messages detected per hour -- Messages relayed successfully -- Failed relay attempts -- Bridge WETH9 balance - -## Troubleshooting - -### Service won't start -1. Check all environment variables are set correctly -2. Verify RPC endpoints are accessible -3. Ensure private key is valid and properly expanded (use `.env.local` if needed) -4. Check Node.js and npm are installed - -### Messages not being relayed -1. Check relayer has ETH for gas on mainnet -2. Verify relay router and bridge addresses are correct -3. Ensure relayer address has RELAYER_ROLE -4. Check bridge is authorized in router -5. Verify bridge has sufficient WETH9 balance - -### Relay transactions failing -1. **Most common**: Bridge has insufficient WETH9 tokens - fund the bridge -2. Check gas limit is sufficient (default: 1,000,000) -3. Verify message format is correct -4. Review error logs for specific revert reasons -5. Check transaction receipt for revert reason - -### High gas costs -- Adjust gas limit in `RelayService.js` if needed -- Monitor gas prices and adjust timing if possible -- Consider message batching for future improvements - -## Development - +Check destination bridge liquidity: ```bash -# Run in development mode with auto-reload -npm run dev - -# Run tests (if available) -npm test +cast call "balanceOf(address)(uint256)" --rpc-url ``` - -## Related Documentation - -- [Architecture Documentation](../docs/relay/ARCHITECTURE.md) -- [Deployment Guide](DEPLOYMENT_GUIDE.md) -- [Investigation Report](../docs/relay/INVESTIGATION_REPORT.md) diff --git a/services/relay/src/RelayService.js b/services/relay/src/RelayService.js index 3a34a52..5475e83 100644 --- a/services/relay/src/RelayService.js +++ b/services/relay/src/RelayService.js @@ -6,6 +6,11 @@ import { ethers } from 'ethers'; import { MessageSentABI, RelayRouterABI, RelayBridgeABI } from './abis.js'; import { MessageQueue } from './MessageQueue.js'; +import { + isRelayShedding, + getRelaySheddingSourcePollIntervalMs, + getRelaySheddingQueueIdleMs +} from './config.js'; export class RelayService { constructor(config, logger) { @@ -22,16 +27,27 @@ export class RelayService { 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; + } + + /** Polling interval for source router logs (longer while shedding to cut RPC churn). */ + getSourcePollIntervalMs() { + if (isRelayShedding()) { + return getRelaySheddingSourcePollIntervalMs(); + } + return this.config.monitoring.pollInterval; } async start() { this.logger.info('Initializing relay service...'); - // Initialize providers with explicit network to avoid "failed to detect network" on slow/unusual RPCs - const sourceNetwork = { chainId: this.config.sourceChain.chainId, name: this.config.sourceChain.name }; - const destNetwork = { chainId: this.config.destinationChain.chainId, name: this.config.destinationChain.name }; - this.sourceProvider = new ethers.JsonRpcProvider(this.config.sourceChain.rpcUrl, sourceNetwork); - this.destinationProvider = new ethers.JsonRpcProvider(this.config.destinationChain.rpcUrl, destNetwork); + // Use plain JsonRpcProvider here; explicit custom-network pinning caused inconsistent log polling + // on the nonstandard Chain 138 RPC even though direct manual queries succeed. + this.sourceProvider = new ethers.JsonRpcProvider(this.config.sourceChain.rpcUrl); + this.destinationProvider = new ethers.JsonRpcProvider(this.config.destinationChain.rpcUrl); // Initialize signers if (!this.config.relayer.privateKey) { @@ -42,12 +58,10 @@ export class RelayService { this.logger.info('Relayer address: %s', String(this.destinationSigner.address)); - // Validate relay router and bridge addresses + // Validate relay router address (bridge can be dynamic from message receiver) if (!this.config.destinationChain.relayRouterAddress || - this.config.destinationChain.relayRouterAddress === '' || - !this.config.destinationChain.relayBridgeAddress || - this.config.destinationChain.relayBridgeAddress === '') { - throw new Error(`Relay router and bridge addresses must be configured on destination chain. Router: ${this.config.destinationChain.relayRouterAddress}, Bridge: ${this.config.destinationChain.relayBridgeAddress}`); + this.config.destinationChain.relayRouterAddress === '') { + throw new Error(`Relay router address must be configured on destination chain. Router: ${this.config.destinationChain.relayRouterAddress}`); } // Initialize contract instances @@ -63,11 +77,14 @@ export class RelayService { this.destinationSigner ); - this.destinationRelayBridge = new ethers.Contract( - this.config.destinationChain.relayBridgeAddress, - RelayBridgeABI, - this.destinationProvider - ); + // 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 this.isRunning = true; @@ -82,72 +99,165 @@ export class RelayService { this.isRunning = false; // Additional cleanup if needed } + + /** 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('-32000') && msg.includes('range')) + ); + } + + 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)) { - startBlock = await this.sourceProvider.getBlockNumber(); + 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}`); - // Listen for MessageSent events - this.sourceRouter.on('MessageSent', async (messageId, destinationChainSelector, sender, receiver, data, tokenAmounts, feeToken, extraArgs, event) => { - try { - // Only process messages for our destination chain - const destSelector = destinationChainSelector.toString(); - const expectedSelector = this.config.destinationChain.chainSelector.toString(); - - if (destSelector !== expectedSelector) { - this.logger.debug(`Ignoring message for different chain: ${destSelector}`); - return; + // 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 (destSelector !== expectedSelector) { + this.logger.debug(`Ignoring message for different chain: ${destSelector}`); + return; + } + + this.logger.info('MessageSent event detected:', { + messageId: messageId, + destinationChainSelector: destinationChainSelector.toString(), + sender: sender, + 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 + })); + + await this.messageQueue.add({ + messageId, + destinationChainSelector, + sender, + receiver, + data, + tokenAmounts: formattedTokenAmounts, + feeToken, + extraArgs, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash + }); + + } catch (error) { + this.logger.error('Error processing MessageSent event:', error); } - - this.logger.info('MessageSent event detected:', { - messageId: messageId, - destinationChainSelector: destinationChainSelector.toString(), - sender: sender, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash - }); - - // Wait for confirmations - const receipt = await event.getTransactionReceipt(); - const confirmations = this.config.monitoring.confirmationBlocks; - - if (confirmations > 0) { - await receipt.confirmations(confirmations); - } - - // Format tokenAmounts properly - const formattedTokenAmounts = tokenAmounts.map(ta => ({ - token: ta.token, - amount: ta.amount, - amountType: ta.amountType - })); - - // Add message to queue - await this.messageQueue.add({ - messageId, - destinationChainSelector, - sender, - receiver, - 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); @@ -157,45 +267,70 @@ export class RelayService { while (this.isRunning) { try { const currentBlock = await this.sourceProvider.getBlockNumber(); - - if (currentBlock > startBlock) { - this.logger.debug(`Polling events from block ${startBlock} to ${currentBlock}`); - - const filter = this.sourceRouter.filters.MessageSent(); - const events = await this.sourceRouter.queryFilter(filter, startBlock, currentBlock); - - for (const event of events) { - // Process event (same logic as event listener) - const { messageId, destinationChainSelector, sender, receiver, data, tokenAmounts, feeToken, extraArgs } = event.args; - + 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.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 (destSelector === expectedSelector) { - await this.messageQueue.add({ - messageId, - destinationChainSelector, - sender, - receiver, - data, - tokenAmounts, - feeToken, - extraArgs, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash - }); + + if (destSelector !== expectedSelector) { + continue; } + + this.logger.info('Historical MessageSent detected:', { + messageId, + destinationChainSelector: destSelector, + sender, + blockNumber: log.blockNumber, + transactionHash: log.transactionHash + }); + + await this.messageQueue.add({ + messageId, + destinationChainSelector, + sender, + receiver, + data, + tokenAmounts: tokenAmounts.map((ta) => ({ + token: ta.token, + amount: ta.amount, + amountType: ta.amountType + })), + feeToken, + extraArgs, + blockNumber: log.blockNumber, + transactionHash: log.transactionHash + }); } - startBlock = currentBlock + 1; + startBlock = Math.max(0, toBlock - replayWindow + 1); } - // Wait before next poll - await new Promise(resolve => setTimeout(resolve, this.config.monitoring.pollInterval)); + // 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); - await new Promise(resolve => setTimeout(resolve, this.config.monitoring.pollInterval)); + await new Promise(resolve => setTimeout(resolve, this.getSourcePollIntervalMs())); } } } @@ -205,6 +340,22 @@ export class RelayService { while (this.isRunning) { try { + // Relay shedding: do not submit destination-chain txs (saves gas). Messages keep queuing + // from source polling; when shedding is off, the queue drains normally. + if (isRelayShedding()) { + 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) { @@ -226,6 +377,73 @@ export class RelayService { 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.retry(messageId); + 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. + let targetBridge = this.config.destinationChain.relayBridgeAddress; + try { + if (receiver) { + const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver); + if (decoded && decoded[0]) targetBridge = ethers.getAddress(decoded[0]); + } + } catch (_) { + // keep fallback targetBridge + } + if (!targetBridge) { + throw new Error(`No destination bridge for message ${messageId}: receiver decode failed and DEST_RELAY_BRIDGE not set`); + } + + // Optional allowlist hardening. + const allowlist = this.config.destinationChain.relayBridgeAllowlist || []; + if (allowlist.length > 0 && !allowlist.includes(String(targetBridge).toLowerCase())) { + throw new Error(`Bridge ${targetBridge} not in DEST_RELAY_BRIDGE_ALLOWLIST`); + } + + let targetBridgeContract = this.destinationBridgeContracts.get(targetBridge.toLowerCase()); + if (!targetBridgeContract) { + targetBridgeContract = new ethers.Contract(targetBridge, RelayBridgeABI, this.destinationProvider); + this.destinationBridgeContracts.set(targetBridge.toLowerCase(), targetBridgeContract); + } + + // Idempotency guard: do not relay a message that destination already marked processed. + let alreadyProcessed = false; + try { + if (targetBridgeContract.processed) { + alreadyProcessed = await targetBridgeContract.processed(messageId); + } + } catch (_) { + // ignore and try legacy method below + } + if (!alreadyProcessed) { + try { + if (targetBridgeContract.processedTransfers) { + alreadyProcessed = await targetBridgeContract.processedTransfers(messageId); + } + } catch (_) { + // destination bridge may not expose either helper + } + } + if (alreadyProcessed) { + this.logger.info(`Message ${messageId} already processed on destination; skipping relay tx`); + await this.messageQueue.markProcessed(messageId); + return null; + } + // Map token addresses from source chain to destination chain const mappedTokenAmounts = tokenAmounts.map(ta => { const sourceToken = ethers.getAddress(ta.token); @@ -240,13 +458,33 @@ export class RelayService { }; }); + // Optional normalization for legacy bridges that decode 4-field payloads: + // (recipient, amount, sender, nonce). TwoWayTokenBridgeL1/L2 decode 2-field payloads + // (recipient, amount), so leave those unchanged unless explicitly enabled. + let normalizedData = data; + if (process.env.RELAY_EXPAND_TWO_FIELD_PAYLOAD === '1') { + try { + const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address', 'uint256'], data); + const reencoded2 = ethers.AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [decoded[0], decoded[1]]); + if ((data || '').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 for message ${messageId}`); + } + } catch (_) { + // Keep original payload when it does not match (address,uint256). + } + } + // Construct Any2EVMMessage struct // tokenAmounts is an array of TokenAmount structs: { token: address, amount: uint256, amountType: uint8 } const any2EVMMessage = { messageId: messageId, - sourceChainSelector: Number(this.config.sourceChainSelector), // Convert BigInt to number for uint64 + sourceChainSelector: BigInt(this.config.sourceChainSelector.toString()), sender: ethers.AbiCoder.defaultAbiCoder().encode(['address'], [sender]), - data: data, + data: normalizedData, tokenAmounts: mappedTokenAmounts }; @@ -256,12 +494,23 @@ export class RelayService { tokenAmountsCount: any2EVMMessage.tokenAmounts.length }); - // Call relay router with properly formatted struct - const tx = await this.destinationRelayRouter.relayMessage( - this.config.destinationChain.relayBridgeAddress, - any2EVMMessage, - { gasLimit: 1000000 } // Increased gas limit - ); + 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, { gasLimit: 1000000 }); + } else { + // Call relay router with properly formatted struct + tx = await this.destinationRelayRouter.relayMessage( + targetBridge, + any2EVMMessage, + { gasLimit: 1000000 } + ); + } this.logger.info(`Relay transaction sent: ${tx.hash}`); @@ -293,4 +542,3 @@ export class RelayService { } } } - diff --git a/services/relay/src/config.js b/services/relay/src/config.js index 793cda0..2ba15e8 100644 --- a/services/relay/src/config.js +++ b/services/relay/src/config.js @@ -4,37 +4,66 @@ import dotenv from 'dotenv'; import path from 'path'; +import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load project root first so PRIVATE_KEY is set, then relay .env -const projectEnv = path.resolve(__dirname, '../../.env'); +const projectEnv = path.resolve(__dirname, '../../../.env'); const relayEnv = path.resolve(__dirname, '../.env'); dotenv.config({ path: projectEnv }); dotenv.config({ path: relayEnv }); + +const DEFAULT_SOURCE_CHAIN_ID = Number(process.env.SOURCE_CHAIN_ID || '138'); +const DEFAULT_DEST_CHAIN_ID = Number(process.env.DEST_CHAIN_ID || '1'); // Fill contract addresses from master JSON (config/smart-contracts-master.json) when not set in .env -const proxmoxRoot = path.resolve(__dirname, '../../../../'); -const contractsLoaderPath = path.join(proxmoxRoot, 'config', 'contracts-loader.cjs'); -const tokenMappingLoaderPath = path.join(proxmoxRoot, 'config', 'token-mapping-loader.cjs'); +const repoRoots = [ + path.resolve(__dirname, '../../../'), + path.resolve(__dirname, '../../../../') +]; + +function resolveConfigFile(fileName) { + for (const root of repoRoots) { + const candidate = path.join(root, 'config', fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return path.join(repoRoots[0], 'config', fileName); +} + +const contractsLoaderPath = resolveConfigFile('contracts-loader.cjs'); +const tokenMappingLoaderPath = resolveConfigFile('token-mapping-loader.cjs'); try { const require = createRequire(import.meta.url); const { loadContractsIntoProcessEnv } = require(contractsLoaderPath); - if (typeof loadContractsIntoProcessEnv === 'function') loadContractsIntoProcessEnv([138, 1]); + if (typeof loadContractsIntoProcessEnv === 'function') { + loadContractsIntoProcessEnv([DEFAULT_SOURCE_CHAIN_ID, DEFAULT_DEST_CHAIN_ID]); + } } catch (_) { /* run from smom-dbis-138 only: loader not found */ } -// Token mapping (Chain 138 -> Mainnet): prefer config/token-mapping.json when available +// Token mapping for the active source/destination pair: prefer multichain mapping when available. function getTokenMapping() { + const sourceChainId = Number(process.env.SOURCE_CHAIN_ID || '138'); + const destinationChainId = Number(process.env.DEST_CHAIN_ID || '1'); try { const require = createRequire(import.meta.url); - const { getRelayTokenMapping } = require(tokenMappingLoaderPath); + const { getTokenMappingForPair, getRelayTokenMapping } = require(tokenMappingLoaderPath); + const pair = getTokenMappingForPair && getTokenMappingForPair(sourceChainId, destinationChainId); + if (pair && pair.addressMapFromTo && Object.keys(pair.addressMapFromTo).length > 0) { + return pair.addressMapFromTo; + } const fromFile = getRelayTokenMapping && getRelayTokenMapping(); - if (fromFile && Object.keys(fromFile).length > 0) return fromFile; + if (sourceChainId === 138 && destinationChainId === 1 && fromFile && Object.keys(fromFile).length > 0) { + return fromFile; + } } catch (_) { /* config not available */ } - // Fallback: only WETH9 is accepted by Mainnet CCIPRelayBridge; LINK mapped for future bridge support + const destinationWeth9 = process.env.DEST_WETH9_ADDRESS || process.env.DEST_WETH_ADDRESS || '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + // Fallback keeps WETH and LINK mapping for legacy relay profiles. return { - '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH9 - '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03': '0x514910771AF9Ca656af840dff83E8264EcF986CA' // LINK (bridge does not accept yet) + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': destinationWeth9, + '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03': process.env.DEST_LINK_ADDRESS || '0x514910771AF9Ca656af840dff83E8264EcF986CA' }; } // If PRIVATE_KEY still missing, try cwd-relative paths (e.g. run from repo root or relay dir) @@ -70,34 +99,76 @@ function getMainnetRpcUrl() { return raw; } +function getSourceRpcUrl() { + return ( + process.env.SOURCE_RPC_URL || + process.env.RPC_URL_138_PUBLIC || + process.env.CHAIN138_RPC_URL_PUBLIC || + process.env.RPC_URL_138 || + process.env.RPC_URL || + 'https://rpc.public-0138.defi-oracle.io' + ); +} + +function getDestinationRelayBridgeAddress() { + if (Object.prototype.hasOwnProperty.call(process.env, 'DEST_RELAY_BRIDGE')) { + return process.env.DEST_RELAY_BRIDGE || ''; + } + return ( + process.env.CCIP_RELAY_BRIDGE_MAINNET || + process.env.RELAY_BRIDGE_MAINNET || + '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939' + ); +} + export const config = { - // Source chain (Chain 138) — use Public RPC (VMID 2201) per standard; fallback to Core (RPC_URL_138) + // Source chain: defaults to Chain 138 but can be overridden for reverse relay profiles. sourceChain: { - name: 'Chain 138', - chainId: 138, - rpcUrl: process.env.RPC_URL_138_PUBLIC || process.env.RPC_URL_138 || process.env.RPC_URL || 'https://rpc-http-pub.d-bis.org', - routerAddress: process.env.CCIP_ROUTER_CHAIN138 || process.env.CCIP_ROUTER || '0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', - bridgeAddress: process.env.CCIPWETH9_BRIDGE_CHAIN138 || '0x971cD9D156f193df8051E48043C476e53ECd4693' + name: process.env.SOURCE_CHAIN_NAME || 'Chain 138', + chainId: DEFAULT_SOURCE_CHAIN_ID, + rpcUrl: getSourceRpcUrl(), + routerAddress: + process.env.SOURCE_ROUTER_ADDRESS || + process.env.SOURCE_ROUTER_ADDRESS || + process.env.CCIP_ROUTER_CHAIN138 || + process.env.CCIP_ROUTER || + '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', + bridgeAddress: + process.env.SOURCE_BRIDGE_ADDRESS || + process.env.CCIPWETH9_BRIDGE_CHAIN138 || + process.env.CCIPWETH9_BRIDGE_CHAIN138_LINK || + '0xcacfd227A040002e49e2e01626363071324f820a' }, - // Destination chain (Ethereum Mainnet) — deployed relay contracts receive and release WETH + // Destination chain: defaults to Ethereum Mainnet for backward compatibility. + // Override for other chains (e.g. BSC/AVAX) with DEST_* env vars. destinationChain: { - name: 'Ethereum Mainnet', - chainId: 1, - rpcUrl: getMainnetRpcUrl(), - relayRouterAddress: process.env.CCIP_RELAY_ROUTER_MAINNET || process.env.RELAY_ROUTER_MAINNET || '0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb', - relayBridgeAddress: process.env.CCIP_RELAY_BRIDGE_MAINNET || process.env.RELAY_BRIDGE_MAINNET || '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939', - chainSelector: BigInt('5009297550715157269'), - weth9Address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' // WETH9 on Ethereum Mainnet + name: process.env.DEST_CHAIN_NAME || 'Ethereum Mainnet', + chainId: process.env.DEST_CHAIN_ID ? parseInt(process.env.DEST_CHAIN_ID) : 1, + rpcUrl: process.env.DEST_RPC_URL || getMainnetRpcUrl(), + relayRouterAddress: + process.env.DEST_RELAY_ROUTER || + process.env.CCIP_RELAY_ROUTER_MAINNET || + process.env.RELAY_ROUTER_MAINNET || + '0xAd9A228CcEB4cbB612cD165FFB72fE090ff10Afb', + relayBridgeAddress: + getDestinationRelayBridgeAddress(), + deliveryMode: process.env.DEST_DELIVERY_MODE || 'router', + // Optional CSV allowlist for per-message receiver routing. + // When set, relay will only forward to bridges in this list. + relayBridgeAllowlist: (process.env.DEST_RELAY_BRIDGE_ALLOWLIST || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + chainSelector: BigInt(process.env.DEST_CHAIN_SELECTOR || '5009297550715157269'), + weth9Address: process.env.DEST_WETH9_ADDRESS || '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' }, - // Token address mapping (Chain 138 -> Ethereum Mainnet). Source of truth: config/token-mapping.json - // Note: Mainnet CCIPRelayBridge is WETH9-only; other tokens will revert at bridge until bridge is extended. + // Token address mapping for the active relay pair. tokenMapping: getTokenMapping(), - // Chain 138 selector - using chain ID directly for now (uint64 max is 2^64-1) - // Note: Official CCIP chain selectors are calculated differently, but for custom relay we use chain ID - sourceChainSelector: BigInt('138'), // Using chain ID as selector for custom relay + // Relay profiles use explicit selectors; default source selector remains 138 for legacy profiles. + sourceChainSelector: BigInt(process.env.SOURCE_CHAIN_SELECTOR || '138'), // Relayer configuration (dotenv does not expand ${PRIVATE_KEY}; getter uses effective key) relayer: { @@ -109,9 +180,18 @@ export const config = { // Monitoring configuration monitoring: { - startBlock: process.env.START_BLOCK ? parseInt(process.env.START_BLOCK) : 'latest', + startBlock: (() => { + const raw = process.env.START_BLOCK; + if (raw === undefined || raw === '') return 'latest'; + const s = String(raw).trim().toLowerCase(); + if (s === 'latest') return 'latest'; + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : 'latest'; + })(), pollInterval: process.env.POLL_INTERVAL ? parseInt(process.env.POLL_INTERVAL) : 5000, // 5 seconds - confirmationBlocks: process.env.CONFIRMATION_BLOCKS ? parseInt(process.env.CONFIRMATION_BLOCKS) : 1 + confirmationBlocks: process.env.CONFIRMATION_BLOCKS ? parseInt(process.env.CONFIRMATION_BLOCKS) : 1, + finalityDelayBlocks: process.env.FINALITY_DELAY_BLOCKS ? parseInt(process.env.FINALITY_DELAY_BLOCKS) : 2, + replayWindowBlocks: process.env.REPLAY_WINDOW_BLOCKS ? parseInt(process.env.REPLAY_WINDOW_BLOCKS) : 32 }, // Retry configuration @@ -121,13 +201,44 @@ export const config = { } }; +/** + * Relay shedding: pause **destination-chain** relay txs (saves Mainnet gas) while still polling + * the source router so messages can queue until you turn delivery back on. + * + * Toggle via env (restart relay service after edits, or rely on your process manager’s reload): + * - `RELAY_SHEDDING=1` | `true` | `yes` | `on` → shedding **on** (no `relayMessage` / direct `ccipReceive`) + * - `RELAY_DELIVERY_ENABLED=0` | `false` | `no` | `off` → same as shedding on + * + * Default: shedding **off** (normal operation). + * + * Re-reads `process.env` on each call so a future SIGHUP/full restart pattern stays simple. + */ +export function isRelayShedding() { + const shed = String(process.env.RELAY_SHEDDING || '').trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(shed)) return true; + const del = String(process.env.RELAY_DELIVERY_ENABLED ?? '1').trim().toLowerCase(); + if (['0', 'false', 'no', 'off'].includes(del)) return true; + return false; +} + +/** Source `eth_getLogs` poll interval while shedding (ms). Min 5000. Default 60000. */ +export function getRelaySheddingSourcePollIntervalMs() { + const v = parseInt(process.env.RELAY_SHEDDING_SOURCE_POLL_INTERVAL_MS || '60000', 10); + return Number.isFinite(v) && v >= 5000 ? v : 60000; +} + +/** Sleep between queue-processor iterations while shedding (ms). Min 1000. Default 5000. */ +export function getRelaySheddingQueueIdleMs() { + const v = parseInt(process.env.RELAY_SHEDDING_QUEUE_POLL_MS || '5000', 10); + return Number.isFinite(v) && v >= 1000 ? v : 5000; +} + // Validate required configuration (use same helper so we validate the effective key) if (!getEffectivePrivateKey()) { throw new Error('RELAYER_PRIVATE_KEY or PRIVATE_KEY environment variable is required. Set PRIVATE_KEY in smom-dbis-138/.env or RELAYER_PRIVATE_KEY in services/relay/.env'); } // Validate relay addresses (warn but don't fail - they may be set later) -if (!config.destinationChain.relayRouterAddress || !config.destinationChain.relayBridgeAddress) { - console.warn('Warning: Relay router and bridge addresses not configured. Service will not start until configured.'); +if (!config.destinationChain.relayRouterAddress) { + console.warn('Warning: Relay router address not configured. Service will not start until configured.'); } -