Flash unwinder contracts and scripts, relay lane tuning, trustless bridge and token-aggregation updates.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-12 06:33:54 -07:00
parent 662b35ad69
commit 6817f53591
40 changed files with 682 additions and 88 deletions

View File

@@ -1,6 +1,7 @@
# Copy to .env.avax and adjust if you need a different AVAX RPC.
# start-relay.sh avax loads this profile before .env.local / .env.
RPC_URL_138=http://192.168.11.211:8545
# Use the public 138 RPC for relay polling so a Core deploy-RPC restart does not strand this lane.
RPC_URL_138=https://rpc-http-pub.d-bis.org
CCIP_ROUTER_CHAIN138=0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817
CCIPWETH9_BRIDGE_CHAIN138=0xcacfd227A040002e49e2e01626363071324f820a
SOURCE_CHAIN_SELECTOR=138

View File

@@ -1,5 +1,6 @@
# Forward relay profile for non-prefunded AVAX cW minting.
RPC_URL_138=http://192.168.11.211:8545
# Use the public 138 RPC for relay polling so a Core deploy-RPC restart does not strand this lane.
RPC_URL_138=https://rpc-http-pub.d-bis.org
CCIP_ROUTER_CHAIN138=0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817
SOURCE_BRIDGE_ADDRESS=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7
SOURCE_CHAIN_SELECTOR=138

View File

@@ -5,6 +5,7 @@ SOURCE_CHAIN_SELECTOR=6433500567565415381
SOURCE_RPC_URL=https://api.avax.network/ext/bc/C/rpc
SOURCE_ROUTER_ADDRESS=0x1773125b280d296354f4f4b958a7cfc4e5975b60
SOURCE_BRIDGE_ADDRESS=0x635002c5fb227160cd2eac926d1baa61847f3c75
SOURCE_LOGS_MAX_BLOCK_RANGE=2048
DEST_CHAIN_NAME=Chain 138
DEST_CHAIN_ID=138

View File

@@ -24,7 +24,9 @@ MAX_RETRIES=3
RETRY_DELAY=5000
# Keep the WETH lane observably alive but safe until the Mainnet release bridge is funded again.
RELAY_SHEDDING=1
RELAY_SHEDDING=0
RELAY_DELIVERY_ENABLED=1
RELAY_ENFORCE_BRIDGE_TOKEN_BALANCE=1
# Park the known oversized WETH release message until the Mainnet bridge inventory is restored.
# 2026-04-05 purge: drop the historical WETH backlog that reloaded into the paused queue so

View File

@@ -9,6 +9,8 @@ export class MessageQueue {
this.processed = new Set();
this.failed = new Set();
this.retryCounts = new Map();
this.messageStore = new Map();
this.inFlight = new Map();
}
async add(messageData) {
@@ -27,6 +29,7 @@ export class MessageQueue {
return;
}
this.messageStore.set(messageId, messageData);
// Add to queue
this.queue.push(messageData);
this.logger.info(`Added message ${messageId} to queue. Queue size: ${this.queue.length}`);
@@ -37,17 +40,25 @@ export class MessageQueue {
return null;
}
return this.queue.shift();
const messageData = this.queue.shift();
if (messageData && messageData.messageId) {
this.inFlight.set(messageData.messageId, messageData);
}
return messageData;
}
async markProcessed(messageId) {
this.processed.add(messageId);
this.retryCounts.delete(messageId);
this.inFlight.delete(messageId);
this.messageStore.delete(messageId);
this.logger.info(`Message ${messageId} marked as processed`);
}
async markFailed(messageId) {
this.failed.add(messageId);
this.retryCounts.delete(messageId);
this.inFlight.delete(messageId);
this.logger.error(`Message ${messageId} marked as failed`);
}
@@ -58,10 +69,22 @@ export class MessageQueue {
async retry(messageId) {
const count = this.retryCounts.get(messageId) || 0;
this.retryCounts.set(messageId, count + 1);
// Find message in queue or re-add it
// In a production system, you'd store the original message data
this.logger.info(`Message ${messageId} retry count: ${count + 1}`);
const existingIndex = this.queue.findIndex(m => m.messageId === messageId);
if (existingIndex >= 0) {
this.logger.info(`Message ${messageId} retry count: ${count + 1}`);
return;
}
const messageData = this.inFlight.get(messageId) || this.messageStore.get(messageId);
if (!messageData) {
this.logger.warn(`Cannot requeue ${messageId}; original message payload is unavailable`);
return;
}
this.inFlight.delete(messageId);
this.queue.push(messageData);
this.logger.info(`Message ${messageId} requeued. Retry count: ${count + 1}. Queue size: ${this.queue.length}`);
}
getStats() {
@@ -72,4 +95,3 @@ export class MessageQueue {
};
}
}

View File

@@ -4,7 +4,7 @@
*/
import { ethers } from 'ethers';
import { MessageSentABI, RelayRouterABI, RelayBridgeABI } from './abis.js';
import { MessageSentABI, RelayRouterABI, RelayBridgeABI, ERC20ABI } from './abis.js';
import { MessageQueue } from './MessageQueue.js';
import {
isRelayShedding,
@@ -161,7 +161,7 @@ export class RelayService {
const lastSuccessMs = this.lastRelaySuccess && this.lastRelaySuccess.at ? Date.parse(this.lastRelaySuccess.at) : 0;
if (
this.lastError &&
this.lastError.scope === 'relay_message' &&
(this.lastError.scope === 'relay_message' || this.lastError.scope === 'bridge_inventory') &&
Number.isFinite(lastErrorMs) &&
lastErrorMs > 0 &&
lastErrorMs >= lastSuccessMs
@@ -172,6 +172,35 @@ export class RelayService {
return 'operational';
}
async ensureTargetBridgeInventory(messageId, targetBridge, tokenAmounts) {
if (process.env.RELAY_ENFORCE_BRIDGE_TOKEN_BALANCE !== '1') {
return { ok: true };
}
for (const tokenAmount of tokenAmounts) {
const tokenAddress = ethers.getAddress(tokenAmount.token);
const requiredAmount = typeof tokenAmount.amount === 'bigint'
? tokenAmount.amount
: BigInt(tokenAmount.amount.toString());
const tokenContract = new ethers.Contract(tokenAddress, ERC20ABI, this.destinationProvider);
const availableAmount = await tokenContract.balanceOf(targetBridge);
if (availableAmount < requiredAmount) {
const shortfall = requiredAmount - availableAmount;
return {
ok: false,
token: tokenAddress,
requiredAmount,
availableAmount,
shortfall,
message: `Insufficient bridge inventory for ${messageId}: ${tokenAddress} available=${availableAmount.toString()} required=${requiredAmount.toString()} shortfall=${shortfall.toString()}`
};
}
}
return { ok: true };
}
getHealthSnapshot() {
const queueStats = this.messageQueue.getStats();
const status = this.getHealthStatus();
@@ -359,7 +388,13 @@ export class RelayService {
return (
msg.includes('maximum RPC range') ||
msg.includes('exceeds maximum') ||
(msg.includes('-32000') && msg.includes('range'))
msg.includes('requested too many blocks') ||
msg.includes('maximum is set to') ||
(msg.includes('-32000') && (
msg.includes('range') ||
msg.includes('too many blocks') ||
msg.includes('maximum is set to')
))
);
}
@@ -771,6 +806,27 @@ export class RelayService {
amountType: Number(ta.amountType) // Ensure it's a number (uint8)
};
});
const inventoryCheck = await this.ensureTargetBridgeInventory(
messageId,
targetBridge,
mappedTokenAmounts
);
if (!inventoryCheck.ok) {
const inventoryError = new Error(inventoryCheck.message);
this.logger.warn(inventoryCheck.message);
this.recordError('bridge_inventory', inventoryError, {
message_id: messageId,
target_bridge: targetBridge,
token: inventoryCheck.token,
available_amount: inventoryCheck.availableAmount.toString(),
required_amount: inventoryCheck.requiredAmount.toString(),
shortfall: inventoryCheck.shortfall.toString()
});
await this.messageQueue.retry(messageId);
await new Promise(resolve => setTimeout(resolve, this.config.retry.retryDelay));
return null;
}
// Optional normalization for legacy bridges that decode 4-field payloads:
// (recipient, amount, sender, nonce). TwoWayTokenBridgeL1/L2 decode 2-field payloads
@@ -840,7 +896,7 @@ export class RelayService {
target_bridge: targetBridge,
tx_hash: receipt.hash
};
if (this.lastError && this.lastError.scope === 'relay_message') {
if (this.lastError && (this.lastError.scope === 'relay_message' || this.lastError.scope === 'bridge_inventory')) {
this.lastError = null;
}

View File

@@ -19,3 +19,7 @@ export const RelayBridgeABI = [
"function processed(bytes32) view returns (bool)",
"function processedTransfers(bytes32) view returns (bool)"
];
export const ERC20ABI = [
"function balanceOf(address account) view returns (uint256)"
];

View File

@@ -11,6 +11,7 @@ import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ethers } from 'ethers';
import { MessageQueue } from './src/MessageQueue.js';
import { RelayService } from './src/RelayService.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -92,5 +93,20 @@ const rejected = relay.evaluateMessageScope({
});
assert(rejected.inScope === false, 'WETH message should be rejected by the cW worker scope');
const queue = new MessageQueue(logger);
const queuedMessage = {
messageId: '0xtest-message',
sender: '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7',
receiver: cwReceiver,
data: '0x',
tokenAmounts: []
};
await queue.add(queuedMessage);
const inFlightMessage = await queue.getNext();
assert(inFlightMessage?.messageId === queuedMessage.messageId, 'getNext should return the queued message');
await queue.retry(queuedMessage.messageId);
const retriedMessage = await queue.getNext();
assert(retriedMessage?.messageId === queuedMessage.messageId, 'retry should requeue the original message payload');
console.log('OK: relay service structure valid');
process.exit(0);

View File

@@ -8,6 +8,17 @@ interface CacheEntry {
const cache = new Map<string, CacheEntry>();
const DEFAULT_TTL = 60 * 1000; // 1 minute
/** Never cache generic API error envelopes (avoids poisoning cache if status/body ever disagree). */
function looksLikeGenericErrorPayload(body: unknown): boolean {
if (body == null || typeof body !== 'object' || Array.isArray(body)) return false;
const o = body as Record<string, unknown>;
if (typeof o.error !== 'string') return false;
// Success shapes we must not treat as errors
if ('pools' in o || 'tokens' in o || 'data' in o || 'chains' in o || 'tree' in o || 'quote' in o) return false;
if (o.status === 'healthy') return false;
return true;
}
export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
return (req: Request, res: Response, next: NextFunction) => {
const bypassCache =
@@ -29,7 +40,9 @@ export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
// Override json method to cache response
res.json = function (body: unknown) {
if (!bypassCache) {
// Only cache successful payloads. Otherwise a 500 body gets replayed on cache hit with HTTP 200.
const okStatus = res.statusCode >= 200 && res.statusCode < 300;
if (!bypassCache && okStatus && !looksLikeGenericErrorPayload(body)) {
cache.set(key, {
data: body,
expiresAt: Date.now() + ttl,

View File

@@ -283,38 +283,57 @@ router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Req
return res.status(400).json({ error: 'chainId is required' });
}
const pools = await getPoolsByTokenWithFallback(chainId, address);
let pools: LiquidityPool[];
try {
pools = await getPoolsByTokenWithFallback(chainId, address);
} catch (error) {
logger.error('Error resolving pools list:', error);
pools = [];
}
res.json({
pools: await Promise.all(
pools.map(async (pool) => {
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, pool.token0Address, pool.token1Address);
return {
address: pool.poolAddress,
dex: pool.dexType,
token0: {
address: pool.token0Address,
symbol: token0.symbol,
name: token0.name,
source: token0.source,
},
token1: {
address: pool.token1Address,
symbol: token1.symbol,
name: token1.name,
source: token1.source,
},
reserves: {
token0: pool.reserve0,
token1: pool.reserve1,
},
tvl: pool.totalLiquidityUsd,
volume24h: pool.volume24h,
feeTier: pool.feeTier,
};
})
),
});
const settled = await Promise.allSettled(
pools.map(async (pool) => {
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, pool.token0Address, pool.token1Address);
return {
address: pool.poolAddress,
dex: String(pool.dexType ?? ''),
token0: {
address: pool.token0Address,
symbol: token0.symbol,
name: token0.name,
source: token0.source,
},
token1: {
address: pool.token1Address,
symbol: token1.symbol,
name: token1.name,
source: token1.source,
},
reserves: {
token0: pool.reserve0,
token1: pool.reserve1,
},
tvl: pool.totalLiquidityUsd,
volume24h: pool.volume24h,
feeTier: pool.feeTier,
};
})
);
const poolsOut = [];
for (const row of settled) {
if (row.status === 'fulfilled') {
poolsOut.push(row.value);
} else {
logger.warn('Skipping pool row in /tokens/:address/pools:', row.reason);
}
}
// BigInt (e.g. from live RPC paths) breaks res.json; stringify replacer keeps Mission Control / E2E stable.
const payload = JSON.parse(
JSON.stringify({ pools: poolsOut }, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))
) as { pools: typeof poolsOut };
res.json(payload);
} catch (error) {
logger.error('Error fetching pools:', error);
res.status(500).json({ error: 'Internal server error' });

View File

@@ -101,14 +101,22 @@ export interface RouteDecisionTreeResponse {
missingQuoteTokenPools: MissingQuoteTokenPool[];
}
function safeBigInt(raw: string | undefined): bigint {
try {
return BigInt(String(raw || '0').trim() || '0');
} catch {
return 0n;
}
}
function normalizedTvlUsd(pool: LiquidityPool): number {
let tvl = Math.max(0, pool.totalLiquidityUsd || 0);
if (pool.chainId === CHAIN_138 && pool.dexType === 'dodo') {
const estimated = estimateChain138DodoLiquidityUsd({
token0Address: pool.token0Address,
token1Address: pool.token1Address,
reserve0: BigInt(pool.reserve0 || '0'),
reserve1: BigInt(pool.reserve1 || '0'),
reserve0: safeBigInt(pool.reserve0),
reserve1: safeBigInt(pool.reserve1),
}).totalLiquidityUsd;
if (estimated > 0) {
tvl = Math.max(tvl, estimated);