WIP: OMNL compliance console, relay rpc pool, zedxion relay service

This commit is contained in:
defiQUG
2026-06-02 06:08:28 -07:00
parent 7c5b4f22aa
commit db517eca80
33 changed files with 2173 additions and 98 deletions

View File

@@ -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 (112). 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`

View File

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

View File

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

View File

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

View 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`);
}
}

View File

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

View File

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

View 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; }
}

View File

@@ -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 &amp; 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>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
})();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
},

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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' },

View File

@@ -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',
};
}

View File

@@ -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',
}

View File

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

View File

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

View File

@@ -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,
},
],
},
};
}

View File

@@ -11,8 +11,8 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,

View 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`.

View 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
View 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
}
}
}
}
}

View 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"
}
}

View 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 });
}
}
}

View 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)",
];

View 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 '';
}