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:
25
services/relay/.env.bsc.example
Normal file
25
services/relay/.env.bsc.example
Normal 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
|
||||
@@ -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 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.<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)
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user