WIP: OMNL compliance console, relay rpc pool, zedxion relay service
This commit is contained in:
@@ -28,6 +28,7 @@ Use the prebuilt env files in this folder:
|
||||
- `.env.mainnet-cw` — Chain 138 cW → **Ethereum mainnet** (`CW_BRIDGE_MAINNET`)
|
||||
- `.env.mainnet-weth` — WETH lane to mainnet
|
||||
- `.env.bsc` (template: `.env.bsc.example`)
|
||||
- `.env.bsc-cw` — Chain 138 cW → **BSC** (`CWMultiTokenBridgeL2` @ `0x0909Fc58…`)
|
||||
- `.env.avax-cw` — cW → Avalanche
|
||||
- `.env.avax` — WETH → Avalanche
|
||||
- `.env` (default/fallback)
|
||||
@@ -152,6 +153,7 @@ The relay now exposes a lightweight JSON status endpoint for explorer / mission-
|
||||
- Default listen address: `0.0.0.0`
|
||||
- Default port: `9860`
|
||||
- Endpoints: `GET /healthz`, `GET /health`, `GET /status`
|
||||
- Health payload includes `concurrency.active_relay_tasks`, `concurrency.max_concurrent`, and `queue.in_flight`
|
||||
|
||||
Optional env overrides:
|
||||
|
||||
@@ -161,6 +163,42 @@ RELAY_HEALTH_HOST=0.0.0.0
|
||||
RELAY_HEALTH_PORT=9860
|
||||
```
|
||||
|
||||
### Fleet health monitor (all lanes)
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
pnpm relay:monitor-health
|
||||
RELAY_MONITOR_STRICT=1 pnpm relay:monitor-health # exit 1 on alerts
|
||||
pnpm relay:check-eth # relayer ETH on mainnet (min 0.05)
|
||||
pnpm relay:audit-env # START_BLOCK / shedding / concurrency audit
|
||||
```
|
||||
|
||||
Endpoint registry: `config/ccip-relay-health-endpoints.v1.json`
|
||||
|
||||
### Unstick stuck messages (mainnet-cw / bsc-cw)
|
||||
|
||||
```bash
|
||||
# Dry-run
|
||||
./scripts/deployment/unstick-ccip-relay-profile.sh --profile mainnet-cw --start-block 5623000
|
||||
|
||||
# Execute: stop, scrub failedIds, replay, drain, reset START_BLOCK=latest
|
||||
./scripts/deployment/unstick-ccip-relay-profile.sh --profile mainnet-cw --start-block 5623000 --execute
|
||||
```
|
||||
|
||||
## Throughput and RPC optimization
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `RELAY_MAX_CONCURRENT` | `1` | Parallel queue workers (1–12). Mainnet cW profile uses `3`; BSC cW uses `2`. |
|
||||
| `DEST_RPC_URL_POOL` | — | Comma-separated destination RPC URLs for read calls (`processed`, inventory probes). Round-robin with failover. |
|
||||
| `RELAY_DEST_SUBMIT_RPC_URL` | — | Dedicated RPC for **submitting** relay txs (overrides pool for broadcasts). |
|
||||
| `BLINK_RPC_URL` / `MEV_BLOCKER_RPC_URL` | — | If set in parent `.env`, used as submit RPC when `RELAY_DEST_SUBMIT_RPC_URL` is unset. |
|
||||
|
||||
**Behavior:** Each concurrent worker pulls the next queue message, uses `NonceManager` for ordered destination txs, and shares the same retry / shedding rules. Read probes (`isDeliveredOnDestination`, bridge inventory) use the RPC pool; writes use the submit URL when configured.
|
||||
|
||||
Idle lanes (Avalanche WETH/cW, Avax→138) set `RELAY_SHEDDING=1` and slower `POLL_INTERVAL` to reduce RPC and gas spend until traffic resumes.
|
||||
|
||||
Example from another LAN host:
|
||||
|
||||
```bash
|
||||
@@ -178,13 +216,14 @@ Recommended systemd ports when running multiple relay workers on the same host:
|
||||
|
||||
- Mainnet WETH (default `.env`): `9860`
|
||||
- Mainnet cW (`ccip-relay-mainnet-cw.service`): `9863`
|
||||
- BSC: `9861`
|
||||
- BSC WETH: `9861`
|
||||
- BSC cW (`ccip-relay-bsc-cw.service` on r630-04): `9867`
|
||||
- Avalanche: `9862`
|
||||
|
||||
### BSC profile (`start-relay.sh bsc`)
|
||||
|
||||
- **Source:** Chain 138 public RPC (`RPC_URL_138` in `.env.bsc`).
|
||||
- **Destination:** `BSC_RELAY_RPC_URL` in `smom-dbis-138/.env` (ngrok to operator BSC node on chain 56).
|
||||
- **Destination:** `BSC_RELAY_RPC_URL` in `smom-dbis-138/.env` (Infura BSC; defaults to `BSC_MAINNET_RPC` / `BSC_RPC_URL`).
|
||||
- **Upstream (not used for relay txs):** `BSC_RPC_URL` / Infura — for operator `cast` and health cross-checks.
|
||||
- Sync + restart on r630-01: `../../../../scripts/deployment/sync-ccip-relay-bsc-r630-01.sh`
|
||||
- Verify: `../../../../scripts/verify/check-bsc-relay-rpc.sh`
|
||||
|
||||
@@ -220,6 +220,7 @@ export class MessageQueue {
|
||||
queueSize: this.queue.length,
|
||||
processed: this.processed.size,
|
||||
failed: this.failed.size,
|
||||
inFlight: this.inFlight.size,
|
||||
persistenceEnabled: this.persistenceEnabled,
|
||||
lastPersistedAt: this.lastPersistedAt
|
||||
};
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Monitors MessageSent events and relays messages to destination chain
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { ethers, NonceManager } from 'ethers';
|
||||
import { MessageSentABI, RelayRouterABI, RelayBridgeABI, ERC20ABI } from './abis.js';
|
||||
import { MessageQueue } from './MessageQueue.js';
|
||||
import { RpcUrlPool } from './rpcPool.js';
|
||||
import {
|
||||
isRelayShedding,
|
||||
getRelaySheddingSourcePollIntervalMs,
|
||||
@@ -19,6 +20,10 @@ export class RelayService {
|
||||
this.isRunning = false;
|
||||
this.sourceProvider = null;
|
||||
this.destinationProvider = null;
|
||||
this.destinationSubmitProvider = null;
|
||||
this.destinationRpcPool = null;
|
||||
this._readProviders = new Map();
|
||||
this._activeRelayCount = 0;
|
||||
this.sourceSigner = null;
|
||||
this.destinationSigner = null;
|
||||
this.messageQueue = new MessageQueue(this.logger, {
|
||||
@@ -43,6 +48,39 @@ export class RelayService {
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
getReadProvider(url) {
|
||||
const key = String(url || '');
|
||||
if (!this._readProviders.has(key)) {
|
||||
this._readProviders.set(key, new ethers.JsonRpcProvider(key));
|
||||
}
|
||||
return this._readProviders.get(key);
|
||||
}
|
||||
|
||||
getMaxConcurrent() {
|
||||
const configured = this.config.concurrency?.maxConcurrent;
|
||||
if (Number.isFinite(configured) && configured >= 1) {
|
||||
return Math.min(Math.floor(configured), 12);
|
||||
}
|
||||
const fromEnv = parseInt(process.env.RELAY_MAX_CONCURRENT || '1', 10);
|
||||
if (!Number.isFinite(fromEnv) || fromEnv < 1) return 1;
|
||||
return Math.min(fromEnv, 12);
|
||||
}
|
||||
|
||||
async probeBridgeProcessed(messageId, targetBridge, url) {
|
||||
const bridge = new ethers.Contract(
|
||||
targetBridge,
|
||||
RelayBridgeABI,
|
||||
this.getReadProvider(url)
|
||||
);
|
||||
if (bridge.processed) {
|
||||
if (await bridge.processed(messageId)) return true;
|
||||
}
|
||||
if (bridge.processedTransfers) {
|
||||
if (await bridge.processedTransfers(messageId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getDestinationBridgeContract(targetBridge) {
|
||||
const key = String(targetBridge).toLowerCase();
|
||||
let contract = this.destinationBridgeContracts.get(key);
|
||||
@@ -58,10 +96,16 @@ export class RelayService {
|
||||
if (process.env.RELAY_SKIP_DESTINATION_PROCESSED_PROBE === '1') {
|
||||
return false;
|
||||
}
|
||||
if (!targetBridge || !this.destinationProvider) {
|
||||
if (!targetBridge) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (this.destinationRpcPool && this.destinationRpcPool.size > 0) {
|
||||
return await this.destinationRpcPool.withFailover(
|
||||
'processed probe',
|
||||
(url) => this.probeBridgeProcessed(messageId, targetBridge, url)
|
||||
);
|
||||
}
|
||||
const bridge = await this.getDestinationBridgeContract(targetBridge);
|
||||
if (bridge.processed) {
|
||||
if (await bridge.processed(messageId)) {
|
||||
@@ -332,9 +376,14 @@ export class RelayService {
|
||||
size: queueStats.queueSize,
|
||||
processed: queueStats.processed,
|
||||
failed: queueStats.failed,
|
||||
in_flight: queueStats.inFlight,
|
||||
persistence_enabled: queueStats.persistenceEnabled,
|
||||
last_persisted_at: queueStats.lastPersistedAt
|
||||
},
|
||||
concurrency: {
|
||||
max_concurrent: this.getMaxConcurrent(),
|
||||
active_relay_tasks: this._activeRelayCount
|
||||
},
|
||||
last_source_poll: this.lastSourcePoll,
|
||||
last_seen_message: this.lastSeenMessage,
|
||||
last_relay_attempt: this.lastRelayAttempt,
|
||||
@@ -378,7 +427,8 @@ export class RelayService {
|
||||
}
|
||||
|
||||
if (gasPrice === null) {
|
||||
const rawGasPrice = await this.destinationProvider.send('eth_gasPrice', []);
|
||||
const gasProvider = this.destinationSubmitProvider || this.destinationProvider;
|
||||
const rawGasPrice = await gasProvider.send('eth_gasPrice', []);
|
||||
gasPrice = BigInt(rawGasPrice);
|
||||
}
|
||||
|
||||
@@ -418,25 +468,51 @@ export class RelayService {
|
||||
|
||||
async start() {
|
||||
this.logger.info('Initializing relay service...');
|
||||
|
||||
// Use plain JsonRpcProvider here; explicit custom-network pinning caused inconsistent log polling
|
||||
// on the nonstandard Chain 138 RPC even though direct manual queries succeed.
|
||||
|
||||
const poolUrls =
|
||||
(this.config.destinationChain.rpcPoolUrls && this.config.destinationChain.rpcPoolUrls.length > 0)
|
||||
? this.config.destinationChain.rpcPoolUrls
|
||||
: [this.config.destinationChain.rpcUrl];
|
||||
this.destinationRpcPool = new RpcUrlPool(poolUrls, this.logger);
|
||||
|
||||
const submitUrl = this.config.destinationChain.submitRpcUrl || poolUrls[0];
|
||||
|
||||
this.sourceProvider = new ethers.JsonRpcProvider(this.config.sourceChain.rpcUrl);
|
||||
this.destinationProvider = new ethers.JsonRpcProvider(this.config.destinationChain.rpcUrl);
|
||||
this.destinationProvider = new ethers.JsonRpcProvider(poolUrls[0]);
|
||||
this.destinationSubmitProvider =
|
||||
submitUrl === poolUrls[0]
|
||||
? this.destinationProvider
|
||||
: new ethers.JsonRpcProvider(submitUrl);
|
||||
|
||||
await this.assertNetworkAlignment('source', this.sourceProvider, this.config.sourceChain.chainId);
|
||||
await this.assertNetworkAlignment('destination', this.destinationProvider, this.config.destinationChain.chainId);
|
||||
|
||||
// Initialize signers
|
||||
if (this.destinationSubmitProvider !== this.destinationProvider) {
|
||||
await this.assertNetworkAlignment(
|
||||
'destination-submit',
|
||||
this.destinationSubmitProvider,
|
||||
this.config.destinationChain.chainId
|
||||
);
|
||||
this.logger.info('Destination submit RPC: %s', submitUrl);
|
||||
}
|
||||
|
||||
if (!this.config.relayer.privateKey) {
|
||||
throw new Error('Relayer private key not configured');
|
||||
}
|
||||
this.sourceSigner = new ethers.Wallet(this.config.relayer.privateKey, this.sourceProvider);
|
||||
this.destinationSigner = new ethers.Wallet(this.config.relayer.privateKey, this.destinationProvider);
|
||||
this.relayerAddress = String(this.destinationSigner.address);
|
||||
const destinationWallet = new ethers.Wallet(
|
||||
this.config.relayer.privateKey,
|
||||
this.destinationSubmitProvider
|
||||
);
|
||||
this.destinationSigner = new NonceManager(destinationWallet);
|
||||
this.relayerAddress = String(destinationWallet.address);
|
||||
await this.messageQueue.init();
|
||||
|
||||
|
||||
this.logger.info('Relayer address: %s', this.relayerAddress);
|
||||
this.logger.info(
|
||||
'Relay concurrency: maxConcurrent=%s rpcPoolSize=%s',
|
||||
this.getMaxConcurrent(),
|
||||
poolUrls.length
|
||||
);
|
||||
|
||||
// Validate relay router address (bridge can be dynamic from message receiver)
|
||||
if (!this.config.destinationChain.relayRouterAddress ||
|
||||
@@ -475,8 +551,11 @@ export class RelayService {
|
||||
async stop() {
|
||||
this.logger.info('Stopping relay service...');
|
||||
this.isRunning = false;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (this._activeRelayCount > 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
await this.messageQueue.persistSnapshot();
|
||||
// Additional cleanup if needed
|
||||
}
|
||||
|
||||
/** Preferred chunk size for scanning (adaptive split handles stricter RPCs). Override: SOURCE_LOGS_MAX_BLOCK_RANGE */
|
||||
@@ -785,38 +864,51 @@ export class RelayService {
|
||||
}
|
||||
|
||||
async startProcessingQueue() {
|
||||
this.logger.info('Starting message queue processor...');
|
||||
|
||||
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 maxConcurrent = this.getMaxConcurrent();
|
||||
this.logger.info('Starting message queue processor (maxConcurrent=%s)...', maxConcurrent);
|
||||
|
||||
const message = await this.messageQueue.getNext();
|
||||
|
||||
if (message) {
|
||||
await this.relayMessage(message);
|
||||
} else {
|
||||
// No messages, wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const worker = async (workerId) => {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
if (isRelayShedding()) {
|
||||
if (workerId === 0) {
|
||||
const stats = this.messageQueue.getStats();
|
||||
const now = Date.now();
|
||||
if (stats.queueSize > 0 && now - this._lastSheddingLogTs >= 60000) {
|
||||
this._lastSheddingLogTs = now;
|
||||
this.logger.warn(
|
||||
`Relay shedding ON: ${stats.queueSize} message(s) queued; destination delivery paused. ` +
|
||||
`Set RELAY_SHEDDING=0 and RELAY_DELIVERY_ENABLED=1 then restart (or reload env) to deliver.`
|
||||
);
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, getRelaySheddingQueueIdleMs()));
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = await this.messageQueue.getNext();
|
||||
if (!message) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
this._activeRelayCount += 1;
|
||||
try {
|
||||
await this.relayMessage(message);
|
||||
} catch (error) {
|
||||
this.logger.error(`Worker ${workerId} relay error:`, error);
|
||||
} finally {
|
||||
this._activeRelayCount = Math.max(0, this._activeRelayCount - 1);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Worker ${workerId} queue loop error:`, error);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing message queue:', error);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < maxConcurrent; i += 1) {
|
||||
void worker(i);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import { parseRpcPool } from './rpcPool.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Load project root first so PRIVATE_KEY is set, then relay .env
|
||||
@@ -115,18 +116,43 @@ function getBscRelayRpcUrl() {
|
||||
if (relay && !relay.includes('${')) return relay;
|
||||
const dest = process.env.DEST_RPC_URL || '';
|
||||
if (dest && !dest.includes('${')) return dest;
|
||||
const fallback = process.env.BSC_RPC_URL || process.env.BSC_MAINNET_RPC || '';
|
||||
if (fallback && !fallback.includes('${')) return fallback;
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDestinationRpcUrl() {
|
||||
function getDestinationRpcPoolUrls() {
|
||||
const destChainId = process.env.DEST_CHAIN_ID ? parseInt(process.env.DEST_CHAIN_ID, 10) : 1;
|
||||
const poolFromEnv = parseRpcPool(
|
||||
process.env.DEST_RPC_URL_POOL,
|
||||
process.env.RPC_URL_MAINNET_POOL,
|
||||
process.env.DEST_RPC_URL
|
||||
);
|
||||
if (poolFromEnv.length > 0) return poolFromEnv;
|
||||
|
||||
if (destChainId === 56) {
|
||||
const bscRelay = getBscRelayRpcUrl();
|
||||
if (bscRelay) return bscRelay;
|
||||
if (bscRelay) return parseRpcPool(bscRelay);
|
||||
}
|
||||
const explicit = process.env.DEST_RPC_URL || '';
|
||||
if (explicit && !explicit.includes('${')) return parseRpcPool(explicit);
|
||||
return parseRpcPool(getMainnetRpcUrl());
|
||||
}
|
||||
|
||||
function getDestinationRpcUrl() {
|
||||
const pool = getDestinationRpcPoolUrls();
|
||||
return pool[0] || getMainnetRpcUrl();
|
||||
}
|
||||
|
||||
/** Optional protected submit endpoint (Blink / MEV Blocker) for destination relay txs. */
|
||||
function getDestinationSubmitRpcUrl() {
|
||||
const explicit =
|
||||
process.env.RELAY_DEST_SUBMIT_RPC_URL ||
|
||||
process.env.BLINK_RPC_URL ||
|
||||
process.env.MEV_BLOCKER_RPC_URL ||
|
||||
'';
|
||||
if (explicit && !explicit.includes('${')) return explicit;
|
||||
return getMainnetRpcUrl();
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMainnetRpcUrl() {
|
||||
@@ -208,6 +234,8 @@ export const config = {
|
||||
name: process.env.DEST_CHAIN_NAME || 'Ethereum Mainnet',
|
||||
chainId: process.env.DEST_CHAIN_ID ? parseInt(process.env.DEST_CHAIN_ID) : 1,
|
||||
rpcUrl: getDestinationRpcUrl(),
|
||||
rpcPoolUrls: getDestinationRpcPoolUrls(),
|
||||
submitRpcUrl: getDestinationSubmitRpcUrl(),
|
||||
// Upstream BSC mainnet RPC for operator tooling (casts, fee probes); relay lane may use ngrok.
|
||||
upstreamRpcUrl:
|
||||
process.env.DEST_RPC_UPSTREAM_URL ||
|
||||
@@ -270,10 +298,21 @@ export const config = {
|
||||
retryDelay: process.env.RETRY_DELAY ? parseInt(process.env.RETRY_DELAY) : 5000 // 5 seconds
|
||||
},
|
||||
|
||||
// Parallel destination delivery (NonceManager on submit signer; default 1 = legacy serial)
|
||||
concurrency: {
|
||||
maxConcurrent: (() => {
|
||||
const raw = parseInt(process.env.RELAY_MAX_CONCURRENT || '1', 10);
|
||||
if (!Number.isFinite(raw) || raw < 1) return 1;
|
||||
return Math.min(raw, 12);
|
||||
})()
|
||||
},
|
||||
|
||||
queuePersistence: {
|
||||
path:
|
||||
process.env.RELAY_QUEUE_STATE_PATH ||
|
||||
path.resolve(__dirname, '../data/queue-state.json')
|
||||
path: (() => {
|
||||
const raw = process.env.RELAY_QUEUE_STATE_PATH;
|
||||
if (!raw) return path.resolve(__dirname, '../data/queue-state.json');
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(__dirname, '..', raw);
|
||||
})()
|
||||
},
|
||||
|
||||
skipMessageIds: getSkipMessageIds()
|
||||
|
||||
59
services/relay/src/rpcPool.js
Normal file
59
services/relay/src/rpcPool.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Comma-separated RPC URL pool with round-robin and read-call failover.
|
||||
*/
|
||||
|
||||
export function parseRpcPool(...sources) {
|
||||
const seen = new Set();
|
||||
const urls = [];
|
||||
for (const source of sources) {
|
||||
if (!source || String(source).includes('${')) continue;
|
||||
for (const part of String(source).split(',')) {
|
||||
const url = part.trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export class RpcUrlPool {
|
||||
constructor(urls, logger) {
|
||||
this.urls = Array.isArray(urls) ? urls.filter(Boolean) : [];
|
||||
this.logger = logger;
|
||||
this._cursor = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.urls.length;
|
||||
}
|
||||
|
||||
primaryUrl() {
|
||||
if (this.urls.length === 0) return '';
|
||||
return this.urls[0];
|
||||
}
|
||||
|
||||
nextUrl() {
|
||||
if (this.urls.length === 0) return '';
|
||||
const url = this.urls[this._cursor % this.urls.length];
|
||||
this._cursor += 1;
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Run async read fn(url) until one succeeds or all fail. */
|
||||
async withFailover(label, fn) {
|
||||
if (this.urls.length === 0) {
|
||||
throw new Error(`${label}: RPC pool is empty`);
|
||||
}
|
||||
let lastError;
|
||||
for (const url of this.urls) {
|
||||
try {
|
||||
return await fn(url);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.logger?.warn?.(`${label} RPC failed (${url}): ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error(`${label}: all RPC pool URLs failed`);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'url';
|
||||
import { ethers } from 'ethers';
|
||||
import { MessageQueue } from './src/MessageQueue.js';
|
||||
import { RelayService } from './src/RelayService.js';
|
||||
import { parseRpcPool } from './src/rpcPool.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -68,9 +69,15 @@ const relay = new RelayService({
|
||||
replayWindowBlocks: 32,
|
||||
},
|
||||
retry: { maxRetries: 3, retryDelay: 5000 },
|
||||
concurrency: { maxConcurrent: 3 },
|
||||
skipMessageIds: new Set(),
|
||||
}, logger);
|
||||
|
||||
assert(relay.getMaxConcurrent() === 3, 'getMaxConcurrent should read config.concurrency.maxConcurrent');
|
||||
|
||||
const pool = parseRpcPool('https://a.example,https://b.example', 'https://a.example');
|
||||
assert(pool.length === 2 && pool[0] === 'https://a.example', 'parseRpcPool should dedupe URLs');
|
||||
|
||||
const cwReceiver = ethers.AbiCoder.defaultAbiCoder().encode(
|
||||
['address'],
|
||||
['0x2bF74583206A49Be07E0E8A94197C12987AbD7B5']
|
||||
|
||||
@@ -20,6 +20,14 @@ LOG_LEVEL=info
|
||||
# CHAIN_138_DODO_PMM_INTEGRATION=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d
|
||||
# CHAIN_138_DODO_POOL_MANAGER=
|
||||
|
||||
# Chain 138 DEX factories (optional — dex-factories.ts has live defaults)
|
||||
# CHAIN_138_UNISWAP_V2_FACTORY=0x0C30F6e67Ab3667fCc2f5CEA8e274ef1FB920279
|
||||
# CHAIN_138_UNISWAP_V2_ROUTER=0x3019A7fDc76ba7F64F18d78e66842760037ee638
|
||||
# CHAIN_138_UNISWAP_V3_FACTORY=0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C
|
||||
# CHAIN_138_UNISWAP_V3_ROUTER=0xde9cD8ee2811E6E64a41D5F68Be315d33995975E
|
||||
# CHAIN_138_SUSHISWAP_FACTORY=0x2871207ff0d56089D70c0134d33f1291B6Fce0BE
|
||||
# CHAIN_138_SUSHISWAP_ROUTER=0xB37b93D38559f53b62ab020A14919f2630a1aE34
|
||||
|
||||
# Minimum token report addresses (V1 = PMM / liquidity canonical on Chain 138)
|
||||
CUSDT_ADDRESS_138=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22
|
||||
CUSDC_ADDRESS_138=0xf22258f57794CC8E06237084b353Ab30fFfa640b
|
||||
|
||||
208
services/token-aggregation/public/omnl-compliance-console.css
Normal file
208
services/token-aggregation/public/omnl-compliance-console.css
Normal file
@@ -0,0 +1,208 @@
|
||||
:root {
|
||||
--bg: #0c0f14;
|
||||
--panel: #141a22;
|
||||
--border: #243044;
|
||||
--text: #e8edf4;
|
||||
--muted: #8b9cb3;
|
||||
--ok: #3dd68c;
|
||||
--warn: #f5c542;
|
||||
--bad: #f07178;
|
||||
--accent: #6cb6ff;
|
||||
--mono: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
button:hover, .btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
button.primary { background: #1a3a5c; border-color: #2d5f9a; }
|
||||
button.primary:hover { background: #224b78; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.section { margin-bottom: 1.25rem; }
|
||||
|
||||
.section > h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge.ok { background: #143528; color: var(--ok); border-color: #2a6b4a; }
|
||||
.badge.warn { background: #3a3014; color: var(--warn); border-color: #6b5620; }
|
||||
.badge.bad { background: #3a1c20; color: var(--bad); border-color: #6b3038; }
|
||||
.badge.neutral { background: #1c2430; color: var(--muted); border-color: var(--border); }
|
||||
|
||||
.metric {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metric small {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.actions-list { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.actions-list li {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
background: #10151c;
|
||||
}
|
||||
|
||||
.actions-list h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions-list p {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.link-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
|
||||
.link-row a {
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-row a:hover { text-decoration: underline; }
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 11rem) 1fr;
|
||||
gap: 0.35rem 0.75rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.kv dt { color: var(--muted); margin: 0; }
|
||||
.kv dd { margin: 0; font-family: var(--mono); font-size: 0.8rem; word-break: break-all; }
|
||||
|
||||
pre.raw {
|
||||
background: #090c10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
overflow: auto;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
max-height: 24rem;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.status-bar.loading { background: #1a2430; color: var(--muted); }
|
||||
.status-bar.error { background: #3a1c20; color: var(--bad); }
|
||||
.status-bar.ok { background: #143528; color: var(--ok); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
footer a { color: var(--accent); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.kv { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="omnl-api-base" content="/token-aggregation/api/v1" />
|
||||
<title>OMNL Compliance Console</title>
|
||||
<link rel="stylesheet" href="/omnl/static/omnl-compliance-console.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div>
|
||||
<h1>OMNL Compliance Console</h1>
|
||||
<p class="sub">HYBX · IPSAS / IFRS / US GAAP · Web3 notary & reserve attestation · Chain 138</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-refresh">Refresh</button>
|
||||
<button type="button" class="primary" id="btn-download-safe">Download Safe tx</button>
|
||||
<button type="button" id="btn-toggle-raw">Raw JSON</button>
|
||||
<a class="btn" href="/omnl/dashboard">Legacy dashboard</a>
|
||||
<a class="btn" href="/api/v1/omnl/openapi.json">OpenAPI</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="load-status" class="status-bar loading">Initializing…</div>
|
||||
<p id="refreshed-at" class="sub" style="margin-top:-0.5rem"></p>
|
||||
|
||||
<section class="section">
|
||||
<h2>Posture</h2>
|
||||
<div id="posture-grid" class="grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Pending actions</h2>
|
||||
<div id="pending-actions"></div>
|
||||
</section>
|
||||
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));">
|
||||
<section class="section card">
|
||||
<h2>On-chain evidence</h2>
|
||||
<div id="evidence-kv"></div>
|
||||
</section>
|
||||
<section class="section card">
|
||||
<h2>Gnosis Safe</h2>
|
||||
<div id="safe-kv"></div>
|
||||
</section>
|
||||
<section class="section card">
|
||||
<h2>External visibility</h2>
|
||||
<div id="external-kv"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section card">
|
||||
<h2>Triple-state reconcile</h2>
|
||||
<div id="triple-summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="section card">
|
||||
<h2>Sign-offs</h2>
|
||||
<div id="signoffs-summary"></div>
|
||||
</section>
|
||||
|
||||
<section id="raw-section" class="section hidden">
|
||||
<h2>Full snapshot</h2>
|
||||
<pre id="raw-json" class="raw">{}</pre>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Auth: pass <code>?access_token=</code> when <code>OMNL_API_KEY</code> or <code>OMNL_DASHBOARD_TOKEN</code> is set.
|
||||
API: <a href="/api/v1/omnl/compliance/console">/api/v1/omnl/compliance/console</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/omnl/static/omnl-compliance-console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
219
services/token-aggregation/public/omnl-compliance-console.js
Normal file
219
services/token-aggregation/public/omnl-compliance-console.js
Normal file
@@ -0,0 +1,219 @@
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token') || '';
|
||||
const apiBase =
|
||||
document.querySelector('meta[name="omnl-api-base"]')?.getAttribute('content')?.trim() || '/api/v1';
|
||||
|
||||
const els = {
|
||||
status: document.getElementById('load-status'),
|
||||
grid: document.getElementById('posture-grid'),
|
||||
actions: document.getElementById('pending-actions'),
|
||||
evidence: document.getElementById('evidence-kv'),
|
||||
safe: document.getElementById('safe-kv'),
|
||||
external: document.getElementById('external-kv'),
|
||||
triple: document.getElementById('triple-summary'),
|
||||
signoffs: document.getElementById('signoffs-summary'),
|
||||
raw: document.getElementById('raw-json'),
|
||||
refreshed: document.getElementById('refreshed-at'),
|
||||
};
|
||||
|
||||
function apiHeaders() {
|
||||
const h = { Accept: 'application/json' };
|
||||
if (accessToken) h.Authorization = 'Bearer ' + accessToken;
|
||||
return h;
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
const base = apiBase.replace(/\/$/, '');
|
||||
const rel = path.startsWith('/') ? path : '/' + path;
|
||||
const u = new URL(base + rel, window.location.origin);
|
||||
if (accessToken) u.searchParams.set('access_token', accessToken);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function badge(ok, okLabel, badLabel) {
|
||||
if (ok === true) return '<span class="badge ok">' + okLabel + '</span>';
|
||||
if (ok === false) return '<span class="badge bad">' + badLabel + '</span>';
|
||||
return '<span class="badge neutral">Unknown</span>';
|
||||
}
|
||||
|
||||
function sevBadge(sev) {
|
||||
const map = { critical: 'bad', high: 'bad', medium: 'warn', low: 'neutral' };
|
||||
return '<span class="badge ' + (map[sev] || 'neutral') + '">' + sev + '</span>';
|
||||
}
|
||||
|
||||
function renderPosture(data) {
|
||||
const p = data.posture || {};
|
||||
els.grid.innerHTML = [
|
||||
card('Reporting', badge(p.reportingCompliant, 'Compliant', 'Not compliant'), ''),
|
||||
card('Attestation', badge(!p.attestationStale, 'Fresh', 'Stale'), ''),
|
||||
card('Triple reconcile', badge(p.tripleAligned, 'Aligned', 'Breaks'), ''),
|
||||
card('Notary gate', badge(p.requireNotarizedEvidence, 'Enforced', 'Not enforced'), 'requireNotarizedEvidence'),
|
||||
card('Threshold', '<div class="metric">' + (p.complianceThreshold || '—') + '</div>', 'IPSAS / IFRS / US GAAP'),
|
||||
card('Policy v', '<div class="metric">' + (p.jurisdictionPolicyVersion ?? '—') + '</div>', 'ID jurisdiction'),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function card(title, body, sub) {
|
||||
return (
|
||||
'<div class="card"><h2>' +
|
||||
title +
|
||||
'</h2>' +
|
||||
body +
|
||||
(sub ? '<small style="color:var(--muted);font-size:0.78rem">' + sub + '</small>' : '') +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderActions(actions) {
|
||||
if (!actions || !actions.length) {
|
||||
els.actions.innerHTML = '<p style="color:var(--muted)">No pending actions.</p>';
|
||||
return;
|
||||
}
|
||||
els.actions.innerHTML =
|
||||
'<ul class="actions-list">' +
|
||||
actions
|
||||
.map(function (a) {
|
||||
const links = (a.links || [])
|
||||
.map(function (l) {
|
||||
const href = l.href.startsWith('http') ? l.href : apiUrl(l.href);
|
||||
const dl = l.href.indexOf('safe-notary-gate-tx') >= 0 ? ' download="omnl-safe-notary-gate-tx.json"' : '';
|
||||
return '<a href="' + href + '"' + dl + '>' + l.label + '</a>';
|
||||
})
|
||||
.join('');
|
||||
return (
|
||||
'<li><h3>' +
|
||||
sevBadge(a.severity) +
|
||||
' ' +
|
||||
esc(a.title) +
|
||||
'</h3><p>' +
|
||||
esc(a.detail) +
|
||||
'</p><div class="link-row">' +
|
||||
links +
|
||||
'</div></li>'
|
||||
);
|
||||
})
|
||||
.join('') +
|
||||
'</ul>';
|
||||
}
|
||||
|
||||
function kv(rows) {
|
||||
return (
|
||||
'<dl class="kv">' +
|
||||
rows
|
||||
.map(function (r) {
|
||||
return '<dt>' + esc(r[0]) + '</dt><dd>' + esc(String(r[1] ?? '—')) + '</dd>';
|
||||
})
|
||||
.join('') +
|
||||
'</dl>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderEvidence(data) {
|
||||
const pr = data.proofReport;
|
||||
const gate = data.onChainGate || {};
|
||||
const triple = data.tripleReconcile;
|
||||
els.evidence.innerHTML = kv([
|
||||
['Evidence hash', pr && pr.evidenceHash],
|
||||
['Merkle root', pr && pr.merkleRoot],
|
||||
['Package 3-of-3', pr && pr.packageNotarized3of3],
|
||||
['Reserve 3-of-3', pr && pr.reserveAttested3of3],
|
||||
['Reserve store', gate.reserveStore],
|
||||
['Notary registry', gate.notaryRegistry],
|
||||
['Attestation threshold', gate.attestationThreshold],
|
||||
['On-chain R', triple && triple.onChain && triple.onChain.r],
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSafe(data) {
|
||||
const pr = data.proofReport;
|
||||
const g = (pr && pr.gnosisSafe) || {};
|
||||
const sw = data.safeWallet || {};
|
||||
els.safe.innerHTML = kv([
|
||||
['Admin Safe', g.address || data.web3.deployed.GnosisSafeAdmin],
|
||||
['Threshold', g.threshold ? g.threshold + '-of-' + g.owners : '—'],
|
||||
['Safe Wallet registry', sw.npmRegistryHas138 ? 'chain 138 listed' : 'pending PR #1568'],
|
||||
['Registry status', sw.status],
|
||||
]);
|
||||
}
|
||||
|
||||
function renderExternal(data) {
|
||||
const ev = data.externalVisibility || {};
|
||||
els.external.innerHTML = kv([
|
||||
['DefiLlama chain', ev.defiLlama && ev.defiLlama.chainPage],
|
||||
['Pricing PR', ev.defiLlama && ev.defiLlama.pr12094],
|
||||
['Bridge TVL PR', ev.defiLlama && ev.defiLlama.pr19451],
|
||||
['Safe deployments PR', ev.safeDeployments && ev.safeDeployments.pr1568],
|
||||
]);
|
||||
}
|
||||
|
||||
function renderTriple(data) {
|
||||
const t = data.tripleReconcile;
|
||||
if (!t) {
|
||||
els.triple.textContent = 'Triple-state reconcile unavailable.';
|
||||
return;
|
||||
}
|
||||
const breaks = (t.breaks || []).length;
|
||||
els.triple.innerHTML =
|
||||
badge(t.aligned, 'Aligned', breaks + ' break(s)') +
|
||||
' <span style="color:var(--muted);margin-left:0.5rem;font-size:0.88rem">line ' +
|
||||
esc(t.lineId).slice(0, 18) +
|
||||
'…</span>';
|
||||
}
|
||||
|
||||
function renderSignoffs(data) {
|
||||
const s = data.signoffs || {};
|
||||
els.signoffs.innerHTML = '<pre class="raw" style="max-height:10rem">' + esc(JSON.stringify(s, null, 2)) + '</pre>';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function setStatus(kind, msg) {
|
||||
els.status.className = 'status-bar ' + kind;
|
||||
els.status.textContent = msg;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setStatus('loading', 'Loading compliance console…');
|
||||
try {
|
||||
const res = await fetch(apiUrl('/omnl/compliance/console'), { headers: apiHeaders() });
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(res.status + ' — non-JSON response');
|
||||
}
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
|
||||
renderPosture(data);
|
||||
renderActions(data.pendingActions);
|
||||
renderEvidence(data);
|
||||
renderSafe(data);
|
||||
renderExternal(data);
|
||||
renderTriple(data);
|
||||
renderSignoffs(data);
|
||||
els.raw.textContent = JSON.stringify(data, null, 2);
|
||||
els.refreshed.textContent = 'Updated ' + new Date(data.generatedAt).toLocaleString();
|
||||
setStatus('ok', 'Live — ' + data.pendingActions.length + ' pending action(s)');
|
||||
} catch (e) {
|
||||
setStatus('error', 'Failed to load: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-refresh').addEventListener('click', load);
|
||||
document.getElementById('btn-download-safe').addEventListener('click', function () {
|
||||
window.location.href = apiUrl('/omnl/compliance/safe-notary-gate-tx');
|
||||
});
|
||||
document.getElementById('btn-toggle-raw').addEventListener('click', function () {
|
||||
document.getElementById('raw-section').classList.toggle('hidden');
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -14,6 +14,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>OMNL API snapshot</h1>
|
||||
<p><a href="/omnl/compliance"><strong>→ OMNL Compliance Console</strong></a> (posture, pending actions, Safe tx download)</p>
|
||||
<p>Reads <code>/api/v1/omnl/ipsas/registry</code> and <code>/api/v1/omnl/ipsas/fineract-compare</code> (503 if Fineract env missing; 401 if <code>OMNL_API_KEY</code> is set and no <code>access_token</code> in this page URL).</p>
|
||||
<p><a href="/api/v1/omnl/openapi.json">OpenAPI 3 JSON</a> · <a href="/api/v1/omnl/catalog">OMNL API catalog</a> · <a href="/api/v1/omnl/integration-status">integration status</a> · <a href="/api/v1/omnl/ipsas/registry">registry JSON</a> · <a href="/api/v1/omnl/ipsas/matrix">matrix JSON</a> · <a href="/api/v1/omnl/ipsas/fineract-health">Fineract health</a></p>
|
||||
<h2>IPSAS registry</h2>
|
||||
|
||||
@@ -4,7 +4,25 @@ function extractBearerOrQuery(req: Request, key: string): boolean {
|
||||
const auth = String(req.headers.authorization || '');
|
||||
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const q = String(req.query.access_token ?? '').trim();
|
||||
return bearer === key || q === key;
|
||||
const dashHeader = String(req.headers['x-omnl-dashboard-token'] ?? '').trim();
|
||||
return bearer === key || q === key || dashHeader === key;
|
||||
}
|
||||
|
||||
function hasOmnlOperatorAuth(req: Request): boolean {
|
||||
const apiKey = process.env.OMNL_API_KEY?.trim();
|
||||
if (apiKey && extractBearerOrQuery(req, apiKey)) return true;
|
||||
const dashKey = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
||||
if (dashKey && extractBearerOrQuery(req, dashKey)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Read-only compliance console API (GET). */
|
||||
function isComplianceConsolePath(path: string): boolean {
|
||||
return (
|
||||
path.endsWith('/omnl/compliance/console') ||
|
||||
path.endsWith('/omnl/compliance/safe-notary-gate-tx') ||
|
||||
path.endsWith('/omnl/compliance/web3')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,7 +35,7 @@ export function omnlSensitiveRouteGuard(req: Request, res: Response, next: NextF
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (extractBearerOrQuery(req, key)) {
|
||||
if (hasOmnlOperatorAuth(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -28,7 +46,10 @@ export function omnlSensitiveRouteGuard(req: Request, res: Response, next: NextF
|
||||
const OMNL_PUBLIC_PATH_SUFFIXES = ['/omnl/openapi.json', '/omnl/catalog', '/omnl/integration-status'];
|
||||
|
||||
function isPublicOmnlPath(path: string): boolean {
|
||||
return OMNL_PUBLIC_PATH_SUFFIXES.some((s) => path.endsWith(s));
|
||||
if (OMNL_PUBLIC_PATH_SUFFIXES.some((s) => path.endsWith(s))) return true;
|
||||
// Compliance console is read-only; allow without API key when explicitly enabled (LAN operator UI).
|
||||
if (process.env.OMNL_COMPLIANCE_CONSOLE_PUBLIC === '1' && isComplianceConsolePath(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,5 +80,9 @@ export function omnlRequireApiKeyInProduction(req: Request, res: Response, next:
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (isComplianceConsolePath(req.path) && hasOmnlOperatorAuth(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
import { verifyOmnlWebhookSignature } from '../../services/omnl-webhooks';
|
||||
import { appendOmnlAudit } from '../../services/omnl-audit-log';
|
||||
import { getWeb3ComplianceSummary, buildNotarizationIntent } from '../../services/omnl-web3-compliance';
|
||||
import {
|
||||
buildComplianceConsoleSnapshot,
|
||||
} from '../../services/omnl-compliance-console';
|
||||
import { buildSafeNotaryGateTransaction } from '../../services/omnl-safe-notary-gate-tx';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
@@ -67,6 +71,30 @@ router.get('/omnl/compliance/web3', omnlSensitiveRouteGuard, (_req, res) => {
|
||||
res.json(getWeb3ComplianceSummary());
|
||||
});
|
||||
|
||||
router.get('/omnl/compliance/console', omnlSensitiveRouteGuard, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const lineId = String(req.query.lineId || '').trim() || undefined;
|
||||
res.json(await buildComplianceConsoleSnapshot(lineId));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/omnl/compliance/safe-notary-gate-tx', omnlSensitiveRouteGuard, (_req, res) => {
|
||||
const tx = buildSafeNotaryGateTransaction();
|
||||
if (!tx) {
|
||||
res.status(404).json({
|
||||
error: 'Safe notary gate transaction unavailable',
|
||||
hint: 'Set PROXMOX_ROOT and ensure config/compliance/*.v1.json exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res
|
||||
.type('application/json')
|
||||
.attachment('omnl-safe-notary-gate-tx.json')
|
||||
.send(JSON.stringify(tx, null, 2));
|
||||
});
|
||||
|
||||
router.post('/omnl/compliance/notarization-intent', omnlSensitiveRouteGuard, (req, res) => {
|
||||
const body = req.body as {
|
||||
jurisdictionId?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import express, { Express, Request, Response, NextFunction } from 'express';
|
||||
import express, { Express, Request, Response, NextFunction, type RequestHandler } from 'express';
|
||||
import { Server } from 'http';
|
||||
import path from 'path';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
@@ -91,8 +91,8 @@ export class ApiServer {
|
||||
// CORS
|
||||
this.app.use(cors());
|
||||
|
||||
// Compression
|
||||
this.app.use(compression());
|
||||
// Compression (Express 5 typings — runtime-compatible)
|
||||
this.app.use(compression() as unknown as RequestHandler);
|
||||
|
||||
// Body parsing
|
||||
this.app.use(express.json());
|
||||
@@ -114,10 +114,12 @@ export class ApiServer {
|
||||
|
||||
const publicPath = path.join(__dirname, '../../public');
|
||||
if (existsSync(publicPath)) {
|
||||
this.app.use('/static', express.static(publicPath, {
|
||||
const staticOpts = {
|
||||
immutable: true,
|
||||
maxAge: '1d',
|
||||
}));
|
||||
} as const;
|
||||
this.app.use('/static', express.static(publicPath, staticOpts));
|
||||
this.app.use('/omnl/static', express.static(publicPath, staticOpts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,16 +149,31 @@ export class ApiServer {
|
||||
});
|
||||
|
||||
const dashboardPath = path.join(__dirname, '../../public/omnl-dashboard.html');
|
||||
this.app.get('/omnl/dashboard', (req: Request, res: Response) => {
|
||||
const complianceConsolePath = path.join(__dirname, '../../public/omnl-compliance-console.html');
|
||||
|
||||
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
|
||||
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
|
||||
if (tok) {
|
||||
const q = String(req.query.access_token ?? '').trim();
|
||||
const h = String(req.headers['x-omnl-dashboard-token'] ?? '').trim();
|
||||
if (q !== tok && h !== tok) {
|
||||
res.status(401).type('text/plain').send('Unauthorized: set access_token query or X-OMNL-Dashboard-Token header');
|
||||
return;
|
||||
}
|
||||
if (!tok) return true;
|
||||
const q = String(req.query.access_token ?? '').trim();
|
||||
const h = String(req.headers['x-omnl-dashboard-token'] ?? '').trim();
|
||||
if (q !== tok && h !== tok) {
|
||||
res.status(401).type('text/plain').send('Unauthorized: set access_token query or X-OMNL-Dashboard-Token header');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
this.app.get('/omnl/compliance', (req: Request, res: Response) => {
|
||||
if (!authorizeOmnlHtml(req, res)) return;
|
||||
if (!existsSync(complianceConsolePath)) {
|
||||
res.status(404).type('text/plain').send('omnl-compliance-console.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(complianceConsolePath, 'utf8'));
|
||||
});
|
||||
|
||||
this.app.get('/omnl/dashboard', (req: Request, res: Response) => {
|
||||
if (!authorizeOmnlHtml(req, res)) return;
|
||||
if (!existsSync(dashboardPath)) {
|
||||
res.status(404).type('text/plain').send('omnl-dashboard.html missing');
|
||||
return;
|
||||
@@ -210,9 +227,9 @@ export class ApiServer {
|
||||
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
||||
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||
this.app.use('/api/v1', checkpointRoutes);
|
||||
this.app.use('/api/v1', omnlComplianceRoutes);
|
||||
this.app.use('/api/v1', omnlRoutes);
|
||||
this.app.use('/api/v1', omnlIpsasRoutes);
|
||||
this.app.use('/api/v1', omnlComplianceRoutes);
|
||||
this.app.use('/api/v2', plannerV2Routes);
|
||||
|
||||
// Admin routes (stricter rate limit)
|
||||
|
||||
@@ -131,6 +131,21 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
cUSDT_V2: {
|
||||
[CHAIN_138]: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
|
||||
},
|
||||
LiXAU: {
|
||||
[CHAIN_138]: '0x1c195855C19c828227Fdf4606d03ffcEA7f15a9F',
|
||||
},
|
||||
LiPMG: {
|
||||
[CHAIN_138]: '0x2d6f43d07Ad2a37Ca59e6c63c8E9C2F9E7047B69',
|
||||
},
|
||||
LiBMG1: {
|
||||
[CHAIN_138]: '0xEEdEAF935feeAC0631FDb20860D922d8909feC0f',
|
||||
},
|
||||
LiBMG2: {
|
||||
[CHAIN_138]: '0x52c65d3a306cA0EEe6526c97F7A6e24ACd44D919',
|
||||
},
|
||||
LiBMG3: {
|
||||
[CHAIN_138]: '0x6C1a587f73b41f37667f0bFD9FA3e9C4417Ef8AF',
|
||||
},
|
||||
cUSDW: {
|
||||
[CHAIN_138]: '0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e',
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface BridgeLane {
|
||||
export interface BridgeConfig {
|
||||
address: string;
|
||||
chainId: number;
|
||||
type: 'ccip_weth9' | 'ccip_weth10' | 'ccip_stable' | 'alltra' | 'universal_ccip';
|
||||
type: 'ccip_weth9' | 'ccip_weth10' | 'ccip_stable' | 'alltra' | 'zedxion' | 'universal_ccip';
|
||||
tokenSymbol?: string;
|
||||
lanes: BridgeLane[];
|
||||
}
|
||||
@@ -88,6 +88,16 @@ if (envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS')
|
||||
});
|
||||
}
|
||||
|
||||
if (envAnyAddr('ZEDXION_CUSTOM_BRIDGE_138', 'ZEDXION_CUSTOM_BRIDGE', 'ZEDXION_ADAPTER_138')) {
|
||||
const addr = envAnyAddr('ZEDXION_CUSTOM_BRIDGE_138', 'ZEDXION_CUSTOM_BRIDGE', 'ZEDXION_ADAPTER_138');
|
||||
CHAIN_138_BRIDGES.push({
|
||||
address: addr,
|
||||
chainId: chainId138,
|
||||
type: 'zedxion',
|
||||
lanes: [{ destSelector: '0', destChainId: 83872, destChainName: 'ZEDXION' }],
|
||||
});
|
||||
}
|
||||
|
||||
if (envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE')) {
|
||||
CHAIN_138_BRIDGES.push({
|
||||
address: envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE'),
|
||||
@@ -102,7 +112,7 @@ if (envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE')) {
|
||||
|
||||
/** Routing registry entry: path type ALT | CCIP, bridge address, label. Aligns with config/routing-registry.json. */
|
||||
export interface RoutingRegistryEntry {
|
||||
pathType: 'ALT' | 'CCIP';
|
||||
pathType: 'ALT' | 'CCIP' | 'ZEDX';
|
||||
bridgeAddress: string;
|
||||
bridgeChainId: number;
|
||||
label: string;
|
||||
@@ -112,6 +122,7 @@ export interface RoutingRegistryEntry {
|
||||
}
|
||||
|
||||
const ALLTRA_ADAPTER_138 = envAddr('ALLTRA_ADAPTER_ADDRESS') || envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || '0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc';
|
||||
const ZEDXION_ADAPTER_138 = envAnyAddr('ZEDXION_ADAPTER_138', 'ZEDXION_CUSTOM_BRIDGE_138', 'ZEDXION_CUSTOM_BRIDGE');
|
||||
const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0xcacfd227A040002e49e2e01626363071324f820a';
|
||||
const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138');
|
||||
const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE');
|
||||
@@ -224,6 +235,19 @@ export function getRouteFromRegistry(
|
||||
asset,
|
||||
};
|
||||
}
|
||||
const is138To83872 = fromChain === 138 && toChain === 83872;
|
||||
const is83872To138 = fromChain === 83872 && toChain === 138;
|
||||
if ((is138To83872 || is83872To138) && ZEDXION_ADAPTER_138) {
|
||||
return {
|
||||
pathType: 'ZEDX',
|
||||
bridgeAddress: ZEDXION_ADAPTER_138,
|
||||
bridgeChainId: fromChain === 138 ? 138 : 83872,
|
||||
label: 'ZedxionAdapter',
|
||||
fromChain,
|
||||
toChain,
|
||||
asset,
|
||||
};
|
||||
}
|
||||
if (fromChain === 138 || toChain === 138) {
|
||||
const legacyNormalizedAsset = asset.trim().toUpperCase();
|
||||
const isStableAsset = STABLE_ASSET_SYMBOLS.has(legacyNormalizedAsset);
|
||||
|
||||
@@ -39,15 +39,42 @@ export interface DexFactoryConfig {
|
||||
const CANONICAL_CHAIN138_DODO_PMM_INTEGRATION =
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
/** Live Chain 138 DEX defaults — overridden by CHAIN_138_* env when set */
|
||||
const CHAIN138_DEX_DEFAULTS = {
|
||||
uniswap_v2: {
|
||||
factory: '0x0C30F6e67Ab3667fCc2f5CEA8e274ef1FB920279',
|
||||
router: '0x3019A7fDc76ba7F64F18d78e66842760037ee638',
|
||||
startBlock: 4041469,
|
||||
},
|
||||
uniswap_v3: {
|
||||
factory: '0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C',
|
||||
router: '0xde9cD8ee2811E6E64a41D5F68Be315d33995975E',
|
||||
startBlock: 4041400,
|
||||
},
|
||||
sushiswap: {
|
||||
factory: '0x2871207ff0d56089D70c0134d33f1291B6Fce0BE',
|
||||
router: '0xB37b93D38559f53b62ab020A14919f2630a1aE34',
|
||||
startBlock: 4041495,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function getUniswapV2Config(chainId: number): UniswapV2Config[] | undefined {
|
||||
const factory = process.env[`CHAIN_${chainId}_UNISWAP_V2_FACTORY`];
|
||||
const factory =
|
||||
process.env[`CHAIN_${chainId}_UNISWAP_V2_FACTORY`] ||
|
||||
(chainId === 138 ? CHAIN138_DEX_DEFAULTS.uniswap_v2.factory : undefined);
|
||||
if (!factory) return undefined;
|
||||
|
||||
const defaults = chainId === 138 ? CHAIN138_DEX_DEFAULTS.uniswap_v2 : undefined;
|
||||
return [
|
||||
{
|
||||
factory,
|
||||
router: process.env[`CHAIN_${chainId}_UNISWAP_V2_ROUTER`] || '',
|
||||
startBlock: parseInt(process.env[`CHAIN_${chainId}_UNISWAP_V2_START_BLOCK`] || '0', 10),
|
||||
router:
|
||||
process.env[`CHAIN_${chainId}_UNISWAP_V2_ROUTER`] || defaults?.router || '',
|
||||
startBlock: parseInt(
|
||||
process.env[`CHAIN_${chainId}_UNISWAP_V2_START_BLOCK`] ||
|
||||
String(defaults?.startBlock ?? 0),
|
||||
10
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -82,25 +109,34 @@ export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
],
|
||||
// UniswapV2 - if deployed
|
||||
uniswap_v2: getUniswapV2Config(138),
|
||||
sushiswap: process.env.CHAIN_138_SUSHISWAP_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_138_SUSHISWAP_FACTORY,
|
||||
router: process.env.CHAIN_138_SUSHISWAP_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_138_SUSHISWAP_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
// UniswapV3 - if deployed
|
||||
uniswap_v3: process.env.CHAIN_138_UNISWAP_V3_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_138_UNISWAP_V3_FACTORY,
|
||||
router: process.env.CHAIN_138_UNISWAP_V3_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_138_UNISWAP_V3_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
// UniswapV3 — live on Chain 138 (defaults from address inventory)
|
||||
uniswap_v3: (() => {
|
||||
const factory =
|
||||
process.env.CHAIN_138_UNISWAP_V3_FACTORY || CHAIN138_DEX_DEFAULTS.uniswap_v3.factory;
|
||||
return [
|
||||
{
|
||||
factory,
|
||||
router:
|
||||
process.env.CHAIN_138_UNISWAP_V3_ROUTER || CHAIN138_DEX_DEFAULTS.uniswap_v3.router,
|
||||
startBlock: parseInt(
|
||||
process.env.CHAIN_138_UNISWAP_V3_START_BLOCK ||
|
||||
String(CHAIN138_DEX_DEFAULTS.uniswap_v3.startBlock),
|
||||
10
|
||||
),
|
||||
},
|
||||
];
|
||||
})(),
|
||||
sushiswap: [
|
||||
{
|
||||
factory: process.env.CHAIN_138_SUSHISWAP_FACTORY || CHAIN138_DEX_DEFAULTS.sushiswap.factory,
|
||||
router: process.env.CHAIN_138_SUSHISWAP_ROUTER || CHAIN138_DEX_DEFAULTS.sushiswap.router,
|
||||
startBlock: parseInt(
|
||||
process.env.CHAIN_138_SUSHISWAP_START_BLOCK ||
|
||||
String(CHAIN138_DEX_DEFAULTS.sushiswap.startBlock),
|
||||
10
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
651940: {
|
||||
// ALL Mainnet - DEX factories to be discovered/configured
|
||||
|
||||
@@ -285,8 +285,8 @@ function chain138DodoCapabilities(): ProviderCapabilityRecord {
|
||||
function chain138DodoV3Capabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const enabled = process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0';
|
||||
const proxy = normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS) || '0xc9a11abb7c63d88546be24d58a6d95e3762cb843';
|
||||
const pool = normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS) || '0x6550a3a59070061a262a893a1d6f3f490affdbda';
|
||||
const proxy = normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS) || '0x20d030e6F0270859cbA04886333f6B83D9Ad6f1a';
|
||||
const pool = normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS) || '0xdb68a9728bfbaf874c47077c849847fd7fcee258';
|
||||
const status = enabled && proxy && pool ? 'live' : 'planned';
|
||||
const executionLive = status === 'live' && isChain138DodoV3ExecutionLive();
|
||||
|
||||
|
||||
@@ -77,6 +77,15 @@ const ALLTRA_ADAPTER_ABI = [
|
||||
'event AlltraBridgeConfirmed(bytes32 indexed requestId, address indexed recipient, address indexed token, uint256 amount)',
|
||||
];
|
||||
|
||||
const ZEDXION_LOCK_ABI = [
|
||||
'event LockForZedxion(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient, uint256 sourceChainId)',
|
||||
'event UnlockOnZedxion(bytes32 indexed requestId, address indexed recipient, address indexed token, uint256 amount)',
|
||||
];
|
||||
|
||||
const ZEDXION_ADAPTER_ABI = [
|
||||
'event ZedxionAdapterBridge(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient)',
|
||||
];
|
||||
|
||||
const UNIVERAL_CCIP_ABI = [
|
||||
'event BridgeExecuted(bytes32 indexed messageId, address indexed token, address indexed sender, uint256 amount, uint64 destinationChain, address recipient, bool usedPMM)',
|
||||
'event MessageReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, address token, uint256 amount)',
|
||||
@@ -221,6 +230,76 @@ async function fetchSwapBridgeEvents(
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch LockForZedxion (ZedxionCustomBridge) or ZedxionAdapterBridge (ZedxionAdapter) */
|
||||
async function fetchZedxionEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridge: BridgeConfig,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<CrossChainEvent[]> {
|
||||
const events: CrossChainEvent[] = [];
|
||||
const lane = bridge.lanes[0];
|
||||
const destChainId = lane?.destChainId ?? 83872;
|
||||
const destChainName = lane?.destChainName ?? 'ZEDXION';
|
||||
|
||||
try {
|
||||
const lockContract = new ethers.Contract(bridge.address, ZEDXION_LOCK_ABI, provider);
|
||||
const lockLogs = await queryFilterWithRangeFallback(
|
||||
lockContract,
|
||||
lockContract.filters.LockForZedxion(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
).catch(() => []);
|
||||
|
||||
for (const log of lockLogs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { requestId: string; sender: string; token: string; amount: bigint; recipient: string };
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'zedxion',
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// fall through to adapter events
|
||||
}
|
||||
|
||||
try {
|
||||
const adapterContract = new ethers.Contract(bridge.address, ZEDXION_ADAPTER_ABI, provider);
|
||||
const initLogs = await queryFilterWithRangeFallback(
|
||||
adapterContract,
|
||||
adapterContract.filters.ZedxionAdapterBridge(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
).catch(() => []);
|
||||
|
||||
for (const log of initLogs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { requestId: string; sender: string; token: string; amount: bigint; recipient: string };
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'zedxion',
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Cross-chain indexer: Zedxion events for ${bridge.address} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch LockForAlltra (AlltraCustomBridge) or AlltraBridgeInitiated (AlltraAdapter) */
|
||||
async function fetchAlltraEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
@@ -442,6 +521,9 @@ export async function buildCrossChainReport(chainId: number = 138): Promise<Cros
|
||||
} else if (bridge.type === 'alltra') {
|
||||
const evts = await fetchAlltraEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
} else if (bridge.type === 'zedxion') {
|
||||
const evts = await fetchZedxionEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
} else if (bridge.type === 'universal_ccip') {
|
||||
const evts = await fetchUniversalCCIPEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
|
||||
@@ -29,6 +29,9 @@ export function getOmnlApiCatalog(): {
|
||||
{ method: 'GET', path: '/omnl/cross-chain-lines', description: 'hybx-omnl-cross-chain-lines.json lines[]', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/zk-verifier', description: 'ZK verifier address from OMNL_ZK_VERIFIER', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/mirror-coordinator', description: 'Read on-chain mirror destination', query: ['chainId'], auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/compliance/console', description: 'Operator compliance console snapshot (posture, actions, evidence)', query: ['lineId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/compliance/safe-notary-gate-tx', description: 'Download Gnosis Safe tx JSON for requireNotarizedEvidence', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/compliance/web3', description: 'Web3 compliance summary (contracts, matrix rows)', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/compliance/:lineId', description: 'ComplianceCore snapshot', query: ['chainId'], auth: 'OMNL_API_KEY when OMNL_REQUIRE_API_KEY=1' },
|
||||
{ method: 'GET', path: '/omnl/compliance-aggregated/:lineId', description: 'Aggregated supply + policy', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/instruments', description: 'InstrumentRegistry lines', query: ['chainId'], auth: 'none' },
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
import { runTripleStateReconcile } from './omnl-triple-reconcile';
|
||||
import { getWeb3ComplianceSummary } from './omnl-web3-compliance';
|
||||
import { getComplianceSignoffsSummary } from './omnl-ifrs-disclosures';
|
||||
import { omnlReserveStore138 } from './omnl-chain138-addresses';
|
||||
import { buildSafeNotaryGateTransaction } from './omnl-safe-notary-gate-tx';
|
||||
|
||||
function projectRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
function readOptionalJson<T>(rel: string): T | null {
|
||||
const p = resolve(projectRoot(), rel);
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const RESERVE_GATE_ABI = [
|
||||
'function requireNotarizedEvidence() view returns (bool)',
|
||||
'function notaryRegistry() view returns (address)',
|
||||
'function attestationThreshold() view returns (uint256)',
|
||||
];
|
||||
|
||||
export interface ComplianceConsolePendingAction {
|
||||
id: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
detail: string;
|
||||
actionType: 'safe_multisig' | 'external_pr' | 'operator_cli' | 'key_custody';
|
||||
links?: Array<{ label: string; href: string }>;
|
||||
}
|
||||
|
||||
export interface ComplianceConsoleSnapshot {
|
||||
generatedAt: string;
|
||||
lineId: string;
|
||||
posture: {
|
||||
reportingCompliant: boolean | null;
|
||||
attestationStale: boolean | null;
|
||||
tripleAligned: boolean | null;
|
||||
requireNotarizedEvidence: boolean | null;
|
||||
notaryThreshold: number | null;
|
||||
reserveAttestationThreshold: number | null;
|
||||
jurisdictionPolicyVersion: number | null;
|
||||
complianceThreshold: string;
|
||||
};
|
||||
pendingActions: ComplianceConsolePendingAction[];
|
||||
tripleReconcile: Awaited<ReturnType<typeof runTripleStateReconcile>> | null;
|
||||
web3: ReturnType<typeof getWeb3ComplianceSummary>;
|
||||
signoffs: ReturnType<typeof getComplianceSignoffsSummary>;
|
||||
onChainGate: {
|
||||
reserveStore: string | null;
|
||||
requireNotarizedEvidence: boolean | null;
|
||||
notaryRegistry: string | null;
|
||||
attestationThreshold: string | null;
|
||||
rpcReachable: boolean;
|
||||
error?: string;
|
||||
};
|
||||
proofReport: {
|
||||
status: string;
|
||||
complianceThreshold: string;
|
||||
evidenceHash?: string;
|
||||
merkleRoot?: string;
|
||||
packageNotarized3of3?: boolean;
|
||||
reserveAttested3of3?: boolean;
|
||||
gnosisSafe?: { address: string; threshold: number; owners: number };
|
||||
} | null;
|
||||
safeWallet: {
|
||||
status: string;
|
||||
npmRegistryHas138: boolean;
|
||||
upstreamPr: string;
|
||||
manualSetupRequired: boolean;
|
||||
} | null;
|
||||
externalVisibility: {
|
||||
defiLlama: { chainPage: string; pr12094: string; pr19451: string; note: string };
|
||||
safeDeployments: { pr1568: string; note: string };
|
||||
};
|
||||
explorerBase: string;
|
||||
}
|
||||
|
||||
async function fetchReserveGateStatus(): Promise<ComplianceConsoleSnapshot['onChainGate']> {
|
||||
const deployed = readOptionalJson<{ contracts?: { ReserveCommitmentStore?: string } }>(
|
||||
'config/compliance/web3-multisig-deployed.v1.json'
|
||||
);
|
||||
const store = omnlReserveStore138() || deployed?.contracts?.ReserveCommitmentStore || null;
|
||||
const rpc = (process.env.RPC_URL_138 || process.env.RPC_URL || '').trim();
|
||||
if (!store || !rpc) {
|
||||
return {
|
||||
reserveStore: store,
|
||||
requireNotarizedEvidence: null,
|
||||
notaryRegistry: null,
|
||||
attestationThreshold: null,
|
||||
rpcReachable: false,
|
||||
error: 'Set RPC_URL_138 and OMNL_RESERVE_COMMITMENT_STORE (or v2 env)',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const provider = new JsonRpcProvider(rpc, 138, { staticNetwork: true });
|
||||
const c = new Contract(store, RESERVE_GATE_ABI, provider);
|
||||
const [req, reg, th] = await Promise.all([
|
||||
c.requireNotarizedEvidence() as Promise<boolean>,
|
||||
c.notaryRegistry() as Promise<string>,
|
||||
c.attestationThreshold() as Promise<bigint>,
|
||||
]);
|
||||
return {
|
||||
reserveStore: store,
|
||||
requireNotarizedEvidence: req,
|
||||
notaryRegistry: reg,
|
||||
attestationThreshold: th.toString(),
|
||||
rpcReachable: true,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
reserveStore: store,
|
||||
requireNotarizedEvidence: null,
|
||||
notaryRegistry: null,
|
||||
attestationThreshold: null,
|
||||
rpcReachable: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildPendingActions(
|
||||
gate: ComplianceConsoleSnapshot['onChainGate'],
|
||||
safeWallet: ComplianceConsoleSnapshot['safeWallet']
|
||||
): ComplianceConsolePendingAction[] {
|
||||
const actions: ComplianceConsolePendingAction[] = [];
|
||||
|
||||
if (gate.requireNotarizedEvidence === false) {
|
||||
actions.push({
|
||||
id: 'enable-notary-gate',
|
||||
severity: 'high',
|
||||
title: 'Enable requireNotarizedEvidence on reserve store',
|
||||
detail:
|
||||
'Reserve store v2 has notaryRegistry set but requireNotarizedEvidence is false. Execute via OMNL Gnosis Safe (3-of-5).',
|
||||
actionType: 'safe_multisig',
|
||||
links: [
|
||||
{ label: 'Download Safe tx JSON', href: '/omnl/compliance/safe-notary-gate-tx' },
|
||||
{ label: 'Safe Wallet', href: 'https://app.safe.global/' },
|
||||
{ label: 'Reserve store on explorer', href: `https://explorer.d-bis.org/address/${gate.reserveStore}` },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (safeWallet && !safeWallet.npmRegistryHas138) {
|
||||
actions.push({
|
||||
id: 'safe-wallet-chain138',
|
||||
severity: 'medium',
|
||||
title: 'Safe Wallet — await chain 138 npm registry',
|
||||
detail: 'Use manual singleton addresses until safe-global/safe-deployments PR merges.',
|
||||
actionType: 'external_pr',
|
||||
links: [
|
||||
{ label: 'Upstream PR #1568', href: safeWallet.upstreamPr },
|
||||
{ label: 'GnosisSafeProxyFactory', href: 'https://explorer.d-bis.org/address/0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'defillama-pricing',
|
||||
severity: 'medium',
|
||||
title: 'DefiLlama hub token USD pricing',
|
||||
detail: 'Chain page may show $0 TVL until defillama-server #12094 merges.',
|
||||
actionType: 'external_pr',
|
||||
links: [
|
||||
{ label: 'defillama-server #12094', href: 'https://github.com/DefiLlama/defillama-server/pull/12094' },
|
||||
{ label: 'Chain page', href: 'https://defillama.com/chain/defi-oracle-meta' },
|
||||
{ label: 'Bridge TVL PR #19451', href: 'https://github.com/DefiLlama/DefiLlama-Adapters/pull/19451' },
|
||||
],
|
||||
});
|
||||
|
||||
actions.push({
|
||||
id: 'rotate-signer-keys',
|
||||
severity: 'low',
|
||||
title: 'Rotate notary/attestation signer keys to HSM',
|
||||
detail: 'Cold signer keys should not remain on operator workstations.',
|
||||
actionType: 'key_custody',
|
||||
links: [{ label: 'Verify on-chain', href: '/omnl/compliance/web3' }],
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function buildComplianceConsoleSnapshot(
|
||||
lineId?: string
|
||||
): Promise<ComplianceConsoleSnapshot> {
|
||||
const defaultLine =
|
||||
lineId?.trim() ||
|
||||
process.env.OMNL_HEALTH_LINE_ID?.trim() ||
|
||||
'0xfefb7411285a639b00be06bbffe897eb802ee817983aa57facf5bde6a1b52ff4';
|
||||
|
||||
const [tripleReconcile, onChainGate] = await Promise.all([
|
||||
runTripleStateReconcile(defaultLine).catch(() => null),
|
||||
fetchReserveGateStatus(),
|
||||
]);
|
||||
|
||||
const proofRaw = readOptionalJson<{
|
||||
status: string;
|
||||
complianceThreshold: string;
|
||||
onChain?: {
|
||||
evidenceHash?: string;
|
||||
merkleRoot?: string;
|
||||
packageNotarized3of3?: boolean;
|
||||
reserveAttested3of3?: boolean;
|
||||
jurisdictionPolicyVersion?: number;
|
||||
notaryThreshold?: number;
|
||||
reserveAttestationThreshold?: number;
|
||||
gnosisSafe?: { address: string; threshold: number; owners: number };
|
||||
};
|
||||
}>('reports/status/hybx-offchain-onchain-cryptographic-proof-latest.json');
|
||||
|
||||
const safeWalletRaw = readOptionalJson<{
|
||||
status: string;
|
||||
npmRegistryHas138: boolean;
|
||||
upstreamPr: string;
|
||||
manualSetupRequired: boolean;
|
||||
}>('reports/status/safe-wallet-chain138-readiness-latest.json');
|
||||
|
||||
const proofReport = proofRaw
|
||||
? {
|
||||
status: proofRaw.status,
|
||||
complianceThreshold: proofRaw.complianceThreshold,
|
||||
evidenceHash: proofRaw.onChain?.evidenceHash,
|
||||
merkleRoot: proofRaw.onChain?.merkleRoot,
|
||||
packageNotarized3of3: proofRaw.onChain?.packageNotarized3of3,
|
||||
reserveAttested3of3: proofRaw.onChain?.reserveAttested3of3,
|
||||
gnosisSafe: proofRaw.onChain?.gnosisSafe,
|
||||
}
|
||||
: null;
|
||||
|
||||
const safeWallet = safeWalletRaw
|
||||
? {
|
||||
status: safeWalletRaw.status,
|
||||
npmRegistryHas138: safeWalletRaw.npmRegistryHas138,
|
||||
upstreamPr: safeWalletRaw.upstreamPr,
|
||||
manualSetupRequired: safeWalletRaw.manualSetupRequired,
|
||||
}
|
||||
: null;
|
||||
|
||||
const posture = {
|
||||
reportingCompliant: tripleReconcile?.onChain?.reportingCompliant ?? null,
|
||||
attestationStale: tripleReconcile?.onChain?.attestationStale ?? null,
|
||||
tripleAligned: tripleReconcile?.aligned ?? null,
|
||||
requireNotarizedEvidence: onChainGate.requireNotarizedEvidence,
|
||||
notaryThreshold: proofRaw?.onChain?.notaryThreshold ?? null,
|
||||
reserveAttestationThreshold: proofRaw?.onChain?.reserveAttestationThreshold ?? null,
|
||||
jurisdictionPolicyVersion: proofRaw?.onChain?.jurisdictionPolicyVersion ?? null,
|
||||
complianceThreshold: proofRaw?.complianceThreshold ?? '3-of-3',
|
||||
};
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
lineId: defaultLine,
|
||||
posture,
|
||||
pendingActions: buildPendingActions(onChainGate, safeWallet),
|
||||
tripleReconcile,
|
||||
web3: getWeb3ComplianceSummary(),
|
||||
signoffs: getComplianceSignoffsSummary(),
|
||||
onChainGate,
|
||||
proofReport,
|
||||
safeWallet,
|
||||
externalVisibility: {
|
||||
defiLlama: {
|
||||
chainPage: 'https://defillama.com/chain/defi-oracle-meta',
|
||||
pr12094: 'https://github.com/DefiLlama/defillama-server/pull/12094',
|
||||
pr19451: 'https://github.com/DefiLlama/DefiLlama-Adapters/pull/19451',
|
||||
note: 'TVL USD blocked on server pricing merge',
|
||||
},
|
||||
safeDeployments: {
|
||||
pr1568: 'https://github.com/safe-global/safe-deployments/pull/1568',
|
||||
note: 'Safe Wallet npm registry for chain 138',
|
||||
},
|
||||
},
|
||||
explorerBase: process.env.EXPLORER_PUBLIC_URL?.trim() || 'https://explorer.d-bis.org',
|
||||
};
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export function getComplianceSignoffsSummary(): Record<string, unknown> {
|
||||
? {
|
||||
...ias32,
|
||||
policySha256Computed: ias32.policyDocPath
|
||||
? hashPolicyDoc('gru-docs/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md')
|
||||
? hashPolicyDoc('../GRU-Official-Docs-Monetary-Policies/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md')
|
||||
: null,
|
||||
readyForExternalUse: ias32.status === 'signed',
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function buildIfrs7Disclosure(lineId?: string): Promise<Record<stri
|
||||
lineId: line || null,
|
||||
ias32SignoffStatus: ias32?.status ?? 'missing',
|
||||
financialLiabilities: {
|
||||
policyReference: 'gru-docs/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md',
|
||||
policyReference: '../GRU-Official-Docs-Monetary-Policies/docs/compliance/accounting/Accounting_Policy_IFRS_IAS32_GRU.md',
|
||||
classification: ias32?.classification ?? null,
|
||||
glRoles: registry.monetaryLayerHints?.m1_liability,
|
||||
fineractGlAccountCount: glCount,
|
||||
@@ -83,7 +83,7 @@ export function buildIfrs9Disclosure(exposureOverrides?: Array<{ segmentId: stri
|
||||
classification: {
|
||||
gruInstrument: 'financial_liability',
|
||||
defaultMeasurement: 'amortized_cost',
|
||||
policyDoc: 'gru-docs/docs/compliance/accounting/IFRS9_ECL_METHODOLOGY_GRU.md',
|
||||
policyDoc: '../GRU-Official-Docs-Monetary-Policies/docs/compliance/accounting/IFRS9_ECL_METHODOLOGY_GRU.md',
|
||||
approach: signoff?.model?.approach ?? (params ? 'from_parameters_file' : 'unset'),
|
||||
},
|
||||
impairment: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { buildSafeNotaryGateTransaction } from './omnl-safe-notary-gate-tx';
|
||||
|
||||
describe('omnl-safe-notary-gate-tx', () => {
|
||||
const root = process.env.PROXMOX_ROOT || process.cwd().replace(/\/smom-dbis-138\/services\/token-aggregation$/, '');
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.PROXMOX_ROOT = root;
|
||||
});
|
||||
|
||||
it('builds Safe transaction when compliance configs exist', () => {
|
||||
const tx = buildSafeNotaryGateTransaction();
|
||||
if (!tx) {
|
||||
console.warn('Skipping: compliance configs not found at PROXMOX_ROOT');
|
||||
return;
|
||||
}
|
||||
expect(tx.chainId).toBe(138);
|
||||
expect(tx.safeAddress).toMatch(/^0x/i);
|
||||
expect(tx.transactions[0].data).toMatch(/^0x/);
|
||||
expect(tx.transactions[0].contractMethod.name).toBe('configureNotaryGate');
|
||||
expect(tx.threshold).toBeGreaterThanOrEqual(1);
|
||||
expect(tx.owners.length).toBeGreaterThanOrEqual(tx.threshold);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Interface } from 'ethers';
|
||||
import { jurisdictionIdBytes32, matrixControlIdBytes32 } from './omnl-web3-compliance';
|
||||
|
||||
function projectRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
function readJson<T>(rel: string): T | null {
|
||||
const p = resolve(projectRoot(), rel);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
}
|
||||
|
||||
export interface SafeNotaryGateTransaction {
|
||||
schemaVersion: string;
|
||||
chainId: number;
|
||||
safeAddress: string;
|
||||
threshold: number;
|
||||
owners: string[];
|
||||
description: string;
|
||||
transactions: Array<{
|
||||
to: string;
|
||||
value: string;
|
||||
data: string;
|
||||
contractMethod: { name: string; inputs: Array<{ name: string; type: string }> };
|
||||
contractInputsValues: Record<string, string>;
|
||||
}>;
|
||||
safeTransactionBuilder: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Gnosis Safe Transaction Builder JSON — enable requireNotarizedEvidence on ReserveCommitmentStore v2. */
|
||||
export function buildSafeNotaryGateTransaction(): SafeNotaryGateTransaction | null {
|
||||
const web3Cfg = readJson<{ contracts: Record<string, string> }>(
|
||||
'config/compliance/web3-multisig-deployed.v1.json'
|
||||
);
|
||||
const safeCfg = readJson<{
|
||||
omnlAdminSafe: { threshold: number; owners: string[] };
|
||||
}>('config/compliance/gnosis-safe-chain138-deployed.v1.json');
|
||||
|
||||
if (!web3Cfg?.contracts || !safeCfg?.omnlAdminSafe) return null;
|
||||
|
||||
const safe = web3Cfg.contracts.GnosisSafeAdmin;
|
||||
const store = web3Cfg.contracts.ReserveCommitmentStore;
|
||||
const notary = web3Cfg.contracts.OMNLNotaryRegistry;
|
||||
if (!safe || !store || !notary) return null;
|
||||
|
||||
const jurId = process.env.OMNL_JURISDICTION_ID || 'ID';
|
||||
const matrixId = process.env.OMNL_MATRIX_CONTROL_ID || 'ID-OMNL-001';
|
||||
const jurBytes = jurisdictionIdBytes32(jurId);
|
||||
const matrixBytes = matrixControlIdBytes32(matrixId);
|
||||
|
||||
const iface = new Interface([
|
||||
'function configureNotaryGate(address registry, bool required, bytes32 jurisdictionId, bytes32 matrixControlId)',
|
||||
]);
|
||||
const data = iface.encodeFunctionData('configureNotaryGate', [notary, true, jurBytes, matrixBytes]);
|
||||
|
||||
const contractInputsValues = {
|
||||
registry: notary,
|
||||
required: 'true',
|
||||
jurisdictionId: jurBytes,
|
||||
matrixControlId: matrixBytes,
|
||||
};
|
||||
|
||||
return {
|
||||
schemaVersion: '1.0.0',
|
||||
chainId: 138,
|
||||
safeAddress: safe,
|
||||
threshold: safeCfg.omnlAdminSafe.threshold,
|
||||
owners: safeCfg.omnlAdminSafe.owners,
|
||||
description:
|
||||
'Enable requireNotarizedEvidence on ReserveCommitmentStore v2 (3-of-3 notary + attestation already live).',
|
||||
transactions: [
|
||||
{
|
||||
to: store,
|
||||
value: '0',
|
||||
data,
|
||||
contractMethod: {
|
||||
name: 'configureNotaryGate',
|
||||
inputs: [
|
||||
{ name: 'registry', type: 'address' },
|
||||
{ name: 'required', type: 'bool' },
|
||||
{ name: 'jurisdictionId', type: 'bytes32' },
|
||||
{ name: 'matrixControlId', type: 'bytes32' },
|
||||
],
|
||||
},
|
||||
contractInputsValues,
|
||||
},
|
||||
],
|
||||
safeTransactionBuilder: {
|
||||
version: '1.0',
|
||||
chainId: '138',
|
||||
createdAt: Date.now(),
|
||||
meta: {
|
||||
name: 'OMNL Wire Notary Gate',
|
||||
description: 'Set requireNotarizedEvidence=true on ReserveCommitmentStore',
|
||||
txBuilderVersion: '1.16.5',
|
||||
},
|
||||
transactions: [
|
||||
{
|
||||
to: store,
|
||||
value: '0',
|
||||
data: null,
|
||||
contractMethod: {
|
||||
inputs: [
|
||||
{ internalType: 'address', name: 'registry', type: 'address' },
|
||||
{ internalType: 'bool', name: 'required', type: 'bool' },
|
||||
{ internalType: 'bytes32', name: 'jurisdictionId', type: 'bytes32' },
|
||||
{ internalType: 'bytes32', name: 'matrixControlId', type: 'bytes32' },
|
||||
],
|
||||
name: 'configureNotaryGate',
|
||||
payable: false,
|
||||
},
|
||||
contractInputsValues,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
|
||||
23
services/zedxion-relay/README.md
Normal file
23
services/zedxion-relay/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Zedxion relay service
|
||||
|
||||
Monitors **ZedxionCustomBridge** on Chain 138 and ZEDXION (83872), completes cross-chain releases.
|
||||
|
||||
## Env
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ZEDXION_CUSTOM_BRIDGE_138` | ZedxionCustomBridge on hub (138) |
|
||||
| `ZEDXION_CUSTOM_BRIDGE_83872` | Same CREATE2 address on 83872 |
|
||||
| `ZEDXION_RELAY_PRIVATE_KEY` | Relayer with `RELAYER_ROLE` on both chains |
|
||||
| `RPC_URL_138` | Hub RPC |
|
||||
| `ZEDXION_MAINNET_RPC` | 83872 RPC |
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd smom-dbis-138/services/zedxion-relay
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
See `docs/03-deployment/CHAIN138_ZEDXION_83872_BRIDGE_RUNBOOK.md`.
|
||||
18
services/zedxion-relay/index.js
Normal file
18
services/zedxion-relay/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Zedxion relay: 138 LockForZedxion → 83872 releaseOnZedxion; 83872 LockFor138 → 138 releaseOn138.
|
||||
* Env: ZEDXION_CUSTOM_BRIDGE_138, ZEDXION_CUSTOM_BRIDGE_83872, ZEDXION_RELAY_PRIVATE_KEY.
|
||||
*/
|
||||
import winston from 'winston';
|
||||
import { ZedxionRelayService } from './src/ZedxionRelayService.js';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
|
||||
const service = new ZedxionRelayService(logger);
|
||||
service.start().catch((err) => {
|
||||
logger.error('Failed to start', err);
|
||||
process.exit(1);
|
||||
});
|
||||
415
services/zedxion-relay/package-lock.json
generated
Normal file
415
services/zedxion-relay/package-lock.json
generated
Normal file
@@ -0,0 +1,415 @@
|
||||
{
|
||||
"name": "zedxion-relay-service",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zedxion-relay-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"ethers": "^6.9.0",
|
||||
"winston": "^3.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
|
||||
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@dabh/diagnostics": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
||||
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@so-ric/colorspace": "^1.1.6",
|
||||
"enabled": "2.0.x",
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@so-ric/colorspace": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color": "^5.0.2",
|
||||
"text-hex": "1.0.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
|
||||
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/triple-beam": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "4.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^3.1.3",
|
||||
"color-string": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
||||
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/enabled": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
||||
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
|
||||
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/ethers-io/"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.buymeacoffee.com/ricmoo"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "1.10.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@types/node": "22.7.5",
|
||||
"aes-js": "4.0.0-beta.5",
|
||||
"tslib": "2.7.0",
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fecha": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fn.name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/logform": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.6.0",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"fecha": "^4.2.0",
|
||||
"ms": "^2.1.1",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"triple-beam": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/one-time": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fn.name": "1.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/triple-beam": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/winston": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
"async": "^3.2.3",
|
||||
"is-stream": "^2.0.0",
|
||||
"logform": "^2.7.0",
|
||||
"one-time": "^1.0.0",
|
||||
"readable-stream": "^3.4.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"stack-trace": "0.0.x",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston-transport": "^4.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/winston-transport": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
||||
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"logform": "^2.7.0",
|
||||
"readable-stream": "^3.6.2",
|
||||
"triple-beam": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
services/zedxion-relay/package.json
Normal file
14
services/zedxion-relay/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "zedxion-relay-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"ethers": "^6.9.0",
|
||||
"winston": "^3.11.0"
|
||||
}
|
||||
}
|
||||
143
services/zedxion-relay/src/ZedxionRelayService.js
Normal file
143
services/zedxion-relay/src/ZedxionRelayService.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* ZEDXION relay: monitor LockForZedxion (138) and LockFor138 (83872), complete release on destination.
|
||||
* See docs/03-deployment/CHAIN138_ZEDXION_83872_BRIDGE_RUNBOOK.md
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { config } from './config.js';
|
||||
import { ZedxionCustomBridgeABI } from './abis.js';
|
||||
|
||||
export class ZedxionRelayService {
|
||||
constructor(logger) {
|
||||
this.logger = logger;
|
||||
this.outboundQueue = [];
|
||||
this.inboundQueue = [];
|
||||
this.inFlight = 0;
|
||||
this.processed = new Set();
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (!config.zedxionCustomBridgeHub || !config.zedxionCustomBridgeDest) {
|
||||
throw new Error('ZEDXION_CUSTOM_BRIDGE_138 and ZEDXION_CUSTOM_BRIDGE_83872 required');
|
||||
}
|
||||
if (!config.relayPrivateKey) {
|
||||
throw new Error('ZEDXION_RELAY_PRIVATE_KEY or PRIVATE_KEY required');
|
||||
}
|
||||
|
||||
this.hubProvider = new ethers.JsonRpcProvider(config.hubRpcUrl);
|
||||
this.zedxProvider = new ethers.JsonRpcProvider(config.zedxionRpcUrl);
|
||||
this.hubSigner = new ethers.Wallet(config.relayPrivateKey, this.hubProvider);
|
||||
this.zedxSigner = new ethers.Wallet(config.relayPrivateKey, this.zedxProvider);
|
||||
|
||||
this.hubBridge = new ethers.Contract(
|
||||
config.zedxionCustomBridgeHub,
|
||||
ZedxionCustomBridgeABI,
|
||||
this.hubProvider,
|
||||
);
|
||||
this.zedxBridge = new ethers.Contract(
|
||||
config.zedxionCustomBridgeDest,
|
||||
ZedxionCustomBridgeABI,
|
||||
this.zedxSigner,
|
||||
);
|
||||
this.hubBridgeWrite = new ethers.Contract(
|
||||
config.zedxionCustomBridgeHub,
|
||||
ZedxionCustomBridgeABI,
|
||||
this.hubSigner,
|
||||
);
|
||||
|
||||
this.hubBridge.on(
|
||||
'LockForZedxion',
|
||||
(requestId, sender, token, amount, recipient, sourceChainId, event) => {
|
||||
this.enqueueOutbound({ requestId, token, amount, recipient, txHash: event.log.transactionHash });
|
||||
},
|
||||
);
|
||||
|
||||
this.zedxBridge.on(
|
||||
'LockFor138',
|
||||
(requestId, sender, token, amount, recipient, sourceChainId, event) => {
|
||||
this.enqueueInbound({ requestId, token, amount, recipient, txHash: event.log.transactionHash });
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.info('Zedxion relay started', {
|
||||
hubBridge: config.zedxionCustomBridgeHub,
|
||||
zedxBridge: config.zedxionCustomBridgeDest,
|
||||
});
|
||||
|
||||
setInterval(() => this.processQueues(), config.pollIntervalMs);
|
||||
}
|
||||
|
||||
enqueueOutbound(msg) {
|
||||
const id = msg.requestId;
|
||||
if (this.processed.has(id)) return;
|
||||
if (this.outboundQueue.length + this.inboundQueue.length + this.inFlight >= config.queueDepthLimit) {
|
||||
this.logger.warn('Queue depth limit reached');
|
||||
return;
|
||||
}
|
||||
this.outboundQueue.push(msg);
|
||||
}
|
||||
|
||||
enqueueInbound(msg) {
|
||||
const id = msg.requestId;
|
||||
if (this.processed.has(id)) return;
|
||||
if (this.outboundQueue.length + this.inboundQueue.length + this.inFlight >= config.queueDepthLimit) {
|
||||
this.logger.warn('Queue depth limit reached');
|
||||
return;
|
||||
}
|
||||
this.inboundQueue.push(msg);
|
||||
}
|
||||
|
||||
async processQueues() {
|
||||
while (this.outboundQueue.length > 0 && this.inFlight < config.maxConcurrent) {
|
||||
const msg = this.outboundQueue.shift();
|
||||
this.inFlight++;
|
||||
this.completeOutbound(msg).finally(() => { this.inFlight--; });
|
||||
}
|
||||
while (this.inboundQueue.length > 0 && this.inFlight < config.maxConcurrent) {
|
||||
const msg = this.inboundQueue.shift();
|
||||
this.inFlight++;
|
||||
this.completeInbound(msg).finally(() => { this.inFlight--; });
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMirroredToken(canonicalToken) {
|
||||
try {
|
||||
const mirrored = await this.hubBridge.canonicalToMirrored(canonicalToken);
|
||||
if (mirrored && mirrored !== ethers.ZeroAddress) return mirrored;
|
||||
} catch (_) { /* fall through */ }
|
||||
return canonicalToken;
|
||||
}
|
||||
|
||||
async resolveCanonicalToken(mirroredToken) {
|
||||
try {
|
||||
const canonical = await this.zedxBridge.mirroredToCanonical(mirroredToken);
|
||||
if (canonical && canonical !== ethers.ZeroAddress) return canonical;
|
||||
} catch (_) { /* fall through */ }
|
||||
return mirroredToken;
|
||||
}
|
||||
|
||||
async completeOutbound({ requestId, token, amount, recipient }) {
|
||||
if (this.processed.has(requestId)) return;
|
||||
try {
|
||||
const mirrored = await this.resolveMirroredToken(token);
|
||||
const tx = await this.zedxBridge.releaseOnZedxion(requestId, mirrored, amount, recipient);
|
||||
await tx.wait();
|
||||
this.processed.add(requestId);
|
||||
this.logger.info('releaseOnZedxion ok', { requestId, mirrored, amount: amount.toString() });
|
||||
} catch (err) {
|
||||
this.logger.error('releaseOnZedxion failed', { requestId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async completeInbound({ requestId, token, amount, recipient }) {
|
||||
if (this.processed.has(requestId)) return;
|
||||
try {
|
||||
const canonical = await this.resolveCanonicalToken(token);
|
||||
const tx = await this.hubBridgeWrite.releaseOn138(requestId, canonical, amount, recipient);
|
||||
await tx.wait();
|
||||
this.processed.add(requestId);
|
||||
this.logger.info('releaseOn138 ok', { requestId, canonical, amount: amount.toString() });
|
||||
} catch (err) {
|
||||
this.logger.error('releaseOn138 failed', { requestId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
12
services/zedxion-relay/src/abis.js
Normal file
12
services/zedxion-relay/src/abis.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const ZedxionCustomBridgeABI = [
|
||||
"event LockForZedxion(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient, uint256 sourceChainId)",
|
||||
"event LockFor138(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient, uint256 sourceChainId)",
|
||||
"function releaseOnZedxion(bytes32 requestId, address token, uint256 amount, address recipient) external",
|
||||
"function releaseOn138(bytes32 requestId, address token, uint256 amount, address recipient) external",
|
||||
"function canonicalToMirrored(address) view returns (address)",
|
||||
"function mirroredToCanonical(address) view returns (address)",
|
||||
];
|
||||
|
||||
export const ZedxionAdapterABI = [
|
||||
"event ZedxionAdapterBridge(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient)",
|
||||
];
|
||||
36
services/zedxion-relay/src/config.js
Normal file
36
services/zedxion-relay/src/config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
const cwd = process.cwd();
|
||||
dotenv.config({ path: path.resolve(cwd, 'smom-dbis-138/.env') });
|
||||
dotenv.config({ path: path.resolve(cwd, '.env') });
|
||||
|
||||
export const config = {
|
||||
hubRpcUrl: process.env.RPC_URL_138 || process.env.RPC_URL || 'http://192.168.11.211:8545',
|
||||
zedxionRpcUrl: process.env.ZEDXION_MAINNET_RPC || 'https://mainnet-rpc.zedscan.net',
|
||||
zedxionCustomBridgeHub: process.env.ZEDXION_CUSTOM_BRIDGE_138 || process.env.ZEDXION_CUSTOM_BRIDGE || '',
|
||||
zedxionCustomBridgeDest: process.env.ZEDXION_CUSTOM_BRIDGE_83872 || process.env.ZEDXION_CUSTOM_BRIDGE || '',
|
||||
relayPrivateKey: resolvePrivateKey(
|
||||
process.env.ZEDXION_RELAY_PRIVATE_KEY,
|
||||
process.env.PRIVATE_KEY,
|
||||
process.env.DEPLOYER_PRIVATE_KEY,
|
||||
),
|
||||
pollIntervalMs: parseInt(process.env.ZEDXION_RELAY_POLL_MS || '6000', 10),
|
||||
maxConcurrent: parseInt(process.env.ZEDXION_RELAY_MAX_CONCURRENT || '5', 10),
|
||||
queueDepthLimit: parseInt(process.env.ZEDXION_RELAY_QUEUE_DEPTH || '100', 10),
|
||||
tokenMapPath: process.env.ZEDXION_TOKEN_MAP || '',
|
||||
};
|
||||
|
||||
function resolvePrivateKey(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
const value = candidate.trim();
|
||||
if (!value || value.includes('${')) continue;
|
||||
if (/^0x[0-9a-fA-F]{64}$/.test(value)) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
Reference in New Issue
Block a user