relay(BSC): adaptive source logs, START_BLOCK parsing, docs, env example

- RelayService: chunked eth_getLogs + adaptive split for strict RPCs
- config: explicit START_BLOCK=latest vs numeric
- README: topology, START_BLOCK, fund script, cast examples
- .env.bsc.example: committed template (secrets stay in .env.bsc)

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-24 16:17:48 -07:00
parent 1511f33857
commit 4f7b335a4b
4 changed files with 598 additions and 317 deletions

View File

@@ -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

View File

@@ -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 <WETH> "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 bridges `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.<profile>` (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/<PROJECT_ID>` 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)" <bsc_message_id> --rpc-url https://bsc.publicnode.com
cast call 0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F "processedTransfers(bytes32)(bool)" <avax_message_id> --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 <dest_weth> "balanceOf(address)(uint256)" <dest_relay_bridge> --rpc-url <dest_rpc>
```
## Related Documentation
- [Architecture Documentation](../docs/relay/ARCHITECTURE.md)
- [Deployment Guide](DEPLOYMENT_GUIDE.md)
- [Investigation Report](../docs/relay/INVESTIGATION_REPORT.md)

View File

@@ -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 {
}
}
}

View File

@@ -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 managers 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.');
}