feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs
- CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault. - Token-aggregation service routes, planner, chain config, relay env templates. - Config snapshots and multi-chain deployment markdown updates. - gitignore services/btc-intake/dist/ (tsc output); do not track dist. Run forge build && forge test before deploy (large solc graph). Made-with: Cursor
This commit is contained in:
@@ -31,6 +31,215 @@ export class RelayService {
|
||||
this.messageSentInterface = new ethers.Interface(MessageSentABI);
|
||||
/** @type {number} throttle for shedding warning logs */
|
||||
this._lastSheddingLogTs = 0;
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.startEpochMs = Date.now();
|
||||
this.relayerAddress = '';
|
||||
this.lastSourcePoll = null;
|
||||
this.lastSeenMessage = null;
|
||||
this.lastRelayAttempt = null;
|
||||
this.lastRelaySuccess = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
normalizeAddress(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return ethers.getAddress(value);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getConfiguredSourceBridge() {
|
||||
return this.normalizeAddress(this.config.sourceChain.bridgeAddress);
|
||||
}
|
||||
|
||||
getDestinationBridgeAllowlist() {
|
||||
return (this.config.destinationChain.relayBridgeAllowlist || [])
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
resolveTargetBridge(receiver) {
|
||||
let targetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress);
|
||||
try {
|
||||
if (receiver) {
|
||||
const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver);
|
||||
if (decoded && decoded[0]) {
|
||||
targetBridge = this.normalizeAddress(decoded[0]) || targetBridge;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the configured fallback bridge when receiver decode fails.
|
||||
}
|
||||
return targetBridge;
|
||||
}
|
||||
|
||||
evaluateMessageScope(messageData) {
|
||||
const sourceBridge = this.getConfiguredSourceBridge();
|
||||
const sender = this.normalizeAddress(messageData.sender);
|
||||
const targetBridge = this.resolveTargetBridge(messageData.receiver);
|
||||
const allowlist = this.getDestinationBridgeAllowlist();
|
||||
|
||||
if (sourceBridge && sender && sender.toLowerCase() !== sourceBridge.toLowerCase()) {
|
||||
return {
|
||||
inScope: false,
|
||||
reason: `sender ${sender} does not match worker source bridge ${sourceBridge}`,
|
||||
sourceBridge,
|
||||
sender,
|
||||
targetBridge
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetBridge) {
|
||||
return {
|
||||
inScope: false,
|
||||
reason: 'destination bridge could not be resolved from receiver or config',
|
||||
sourceBridge,
|
||||
sender,
|
||||
targetBridge: ''
|
||||
};
|
||||
}
|
||||
|
||||
if (allowlist.length > 0 && !allowlist.includes(targetBridge.toLowerCase())) {
|
||||
return {
|
||||
inScope: false,
|
||||
reason: `destination bridge ${targetBridge} not in worker allowlist`,
|
||||
sourceBridge,
|
||||
sender,
|
||||
targetBridge
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
inScope: true,
|
||||
reason: '',
|
||||
sourceBridge,
|
||||
sender,
|
||||
targetBridge
|
||||
};
|
||||
}
|
||||
|
||||
static summarizeError(error) {
|
||||
if (!error) return '';
|
||||
if (typeof error === 'string') return error;
|
||||
return String(error.shortMessage || error.message || error);
|
||||
}
|
||||
|
||||
recordError(scope, error, extra = {}) {
|
||||
this.lastError = {
|
||||
at: new Date().toISOString(),
|
||||
scope,
|
||||
message: RelayService.summarizeError(error),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
getHealthStatus() {
|
||||
if (!this.isRunning) {
|
||||
return 'stopped';
|
||||
}
|
||||
if (isRelayShedding()) {
|
||||
return 'paused';
|
||||
}
|
||||
|
||||
if (this.lastSourcePoll && this.lastSourcePoll.ok === false) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
if (!this.lastSourcePoll || !this.lastSourcePoll.at) {
|
||||
return 'starting';
|
||||
}
|
||||
|
||||
const lastPollMs = Date.parse(this.lastSourcePoll.at);
|
||||
const staleAfterMs = Math.max(this.getSourcePollIntervalMs() * 3, 15000);
|
||||
if (Number.isFinite(lastPollMs) && Date.now() - lastPollMs > staleAfterMs) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
const lastErrorMs = this.lastError && this.lastError.at ? Date.parse(this.lastError.at) : 0;
|
||||
const lastSuccessMs = this.lastRelaySuccess && this.lastRelaySuccess.at ? Date.parse(this.lastRelaySuccess.at) : 0;
|
||||
if (
|
||||
this.lastError &&
|
||||
this.lastError.scope === 'relay_message' &&
|
||||
Number.isFinite(lastErrorMs) &&
|
||||
lastErrorMs > 0 &&
|
||||
lastErrorMs >= lastSuccessMs
|
||||
) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
getHealthSnapshot() {
|
||||
const queueStats = this.messageQueue.getStats();
|
||||
const status = this.getHealthStatus();
|
||||
const sourceBridge = this.getConfiguredSourceBridge();
|
||||
const allowlist = this.getDestinationBridgeAllowlist();
|
||||
const defaultTargetBridge = this.normalizeAddress(this.config.destinationChain.relayBridgeAddress);
|
||||
|
||||
return {
|
||||
ok: status === 'operational' || status === 'paused' || status === 'starting',
|
||||
status,
|
||||
service: {
|
||||
name: 'ccip-relay',
|
||||
running: this.isRunning,
|
||||
pid: process.pid,
|
||||
profile: process.env.RELAY_PROFILE || 'default',
|
||||
started_at: this.startedAt,
|
||||
uptime_sec: Math.max(0, Math.floor((Date.now() - this.startEpochMs) / 1000)),
|
||||
relayer_address: this.relayerAddress || this.config.relayer.address || ''
|
||||
},
|
||||
source: {
|
||||
chain_name: this.config.sourceChain.name,
|
||||
chain_id: this.config.sourceChain.chainId,
|
||||
chain_selector: this.config.sourceChainSelector.toString(),
|
||||
router_address: this.config.sourceChain.routerAddress,
|
||||
bridge_address: this.config.sourceChain.bridgeAddress,
|
||||
bridge_filter: sourceBridge || ''
|
||||
},
|
||||
destination: {
|
||||
chain_name: this.config.destinationChain.name,
|
||||
chain_id: this.config.destinationChain.chainId,
|
||||
chain_selector: this.config.destinationChain.chainSelector.toString(),
|
||||
relay_router: this.config.destinationChain.relayRouterAddress,
|
||||
relay_bridge: this.config.destinationChain.relayBridgeAddress,
|
||||
relay_bridge_default: defaultTargetBridge || '',
|
||||
relay_bridge_allowlist: allowlist,
|
||||
delivery_mode: this.config.destinationChain.deliveryMode
|
||||
},
|
||||
scope: {
|
||||
source_bridge: sourceBridge || '',
|
||||
destination_bridge_default: defaultTargetBridge || '',
|
||||
destination_bridge_allowlist: allowlist
|
||||
},
|
||||
monitoring: {
|
||||
start_block: String(this.config.monitoring.startBlock),
|
||||
poll_interval_ms: this.config.monitoring.pollInterval,
|
||||
effective_source_poll_interval_ms: this.getSourcePollIntervalMs(),
|
||||
confirmation_blocks: this.config.monitoring.confirmationBlocks,
|
||||
finality_delay_blocks: this.config.monitoring.finalityDelayBlocks || 0,
|
||||
replay_window_blocks: this.config.monitoring.replayWindowBlocks || 0,
|
||||
shedding: isRelayShedding(),
|
||||
delivery_enabled: !isRelayShedding()
|
||||
},
|
||||
queue: {
|
||||
size: queueStats.queueSize,
|
||||
processed: queueStats.processed,
|
||||
failed: queueStats.failed
|
||||
},
|
||||
last_source_poll: this.lastSourcePoll,
|
||||
last_seen_message: this.lastSeenMessage,
|
||||
last_relay_attempt: this.lastRelayAttempt,
|
||||
last_relay_success: this.lastRelaySuccess,
|
||||
last_error: this.lastError
|
||||
};
|
||||
}
|
||||
|
||||
shouldSkipMessageId(messageId) {
|
||||
const normalized = String(messageId || '').toLowerCase();
|
||||
return normalized && this.config.skipMessageIds && this.config.skipMessageIds.has(normalized);
|
||||
}
|
||||
|
||||
/** Polling interval for source router logs (longer while shedding to cut RPC churn). */
|
||||
@@ -40,6 +249,41 @@ export class RelayService {
|
||||
}
|
||||
return this.config.monitoring.pollInterval;
|
||||
}
|
||||
|
||||
async getDestinationTxOptions() {
|
||||
const txOptions = {
|
||||
gasLimit: BigInt(process.env.RELAY_DEST_GAS_LIMIT || '1000000')
|
||||
};
|
||||
|
||||
if (process.env.RELAY_DEST_LEGACY_TX !== '1') {
|
||||
return txOptions;
|
||||
}
|
||||
|
||||
let gasPrice = process.env.RELAY_DEST_GAS_PRICE_WEI ? BigInt(process.env.RELAY_DEST_GAS_PRICE_WEI) : null;
|
||||
if (gasPrice === null) {
|
||||
try {
|
||||
const feeData = await this.destinationProvider.getFeeData();
|
||||
if (feeData.gasPrice) {
|
||||
gasPrice = feeData.gasPrice;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through to raw RPC fallback below.
|
||||
}
|
||||
}
|
||||
|
||||
if (gasPrice === null) {
|
||||
const rawGasPrice = await this.destinationProvider.send('eth_gasPrice', []);
|
||||
gasPrice = BigInt(rawGasPrice);
|
||||
}
|
||||
|
||||
const bumpPct = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_PCT || '20');
|
||||
const bumpWei = BigInt(process.env.RELAY_DEST_GAS_PRICE_BUMP_WEI || '1000000');
|
||||
gasPrice = gasPrice + (gasPrice * bumpPct) / 100n + bumpWei;
|
||||
|
||||
txOptions.type = 0;
|
||||
txOptions.gasPrice = gasPrice;
|
||||
return txOptions;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.logger.info('Initializing relay service...');
|
||||
@@ -55,8 +299,9 @@ export class RelayService {
|
||||
}
|
||||
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);
|
||||
|
||||
this.logger.info('Relayer address: %s', String(this.destinationSigner.address));
|
||||
this.logger.info('Relayer address: %s', this.relayerAddress);
|
||||
|
||||
// Validate relay router address (bridge can be dynamic from message receiver)
|
||||
if (!this.config.destinationChain.relayRouterAddress ||
|
||||
@@ -213,16 +458,28 @@ export class RelayService {
|
||||
try {
|
||||
const destSelector = destinationChainSelector.toString();
|
||||
const expectedSelector = this.config.destinationChain.chainSelector.toString();
|
||||
|
||||
if (this.shouldSkipMessageId(messageId)) {
|
||||
this.logger.warn(`Skipping MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (destSelector !== expectedSelector) {
|
||||
this.logger.debug(`Ignoring message for different chain: ${destSelector}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = this.evaluateMessageScope({ messageId, sender, receiver });
|
||||
if (!scope.inScope) {
|
||||
this.logger.debug(`Ignoring message ${messageId}; ${scope.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('MessageSent event detected:', {
|
||||
messageId: messageId,
|
||||
destinationChainSelector: destinationChainSelector.toString(),
|
||||
sender: sender,
|
||||
targetBridge: scope.targetBridge,
|
||||
blockNumber: event.blockNumber,
|
||||
transactionHash: event.transactionHash
|
||||
});
|
||||
@@ -245,6 +502,7 @@ export class RelayService {
|
||||
destinationChainSelector,
|
||||
sender,
|
||||
receiver,
|
||||
targetBridge: scope.targetBridge,
|
||||
data,
|
||||
tokenAmounts: formattedTokenAmounts,
|
||||
feeToken,
|
||||
@@ -275,6 +533,16 @@ export class RelayService {
|
||||
this.logger.info(`Polling events from block ${startBlock} to ${toBlock}`);
|
||||
|
||||
const logs = await this.fetchSourceLogsChunked(startBlock, toBlock);
|
||||
this.lastSourcePoll = {
|
||||
at: new Date().toISOString(),
|
||||
ok: true,
|
||||
from_block: startBlock,
|
||||
to_block: toBlock,
|
||||
logs_fetched: logs.length
|
||||
};
|
||||
if (this.lastError && this.lastError.scope === 'source_poll') {
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
this.logger.info(`Fetched ${logs.length} MessageSent log(s) from source router`);
|
||||
|
||||
@@ -292,23 +560,44 @@ export class RelayService {
|
||||
const destSelector = destinationChainSelector.toString();
|
||||
const expectedSelector = this.config.destinationChain.chainSelector.toString();
|
||||
|
||||
if (this.shouldSkipMessageId(messageId)) {
|
||||
this.logger.warn(`Skipping historical MessageSent ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (destSelector !== expectedSelector) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scope = this.evaluateMessageScope({ messageId, sender, receiver });
|
||||
if (!scope.inScope) {
|
||||
this.logger.debug(`Ignoring historical message ${messageId}; ${scope.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.info('Historical MessageSent detected:', {
|
||||
messageId,
|
||||
destinationChainSelector: destSelector,
|
||||
sender,
|
||||
targetBridge: scope.targetBridge,
|
||||
blockNumber: log.blockNumber,
|
||||
transactionHash: log.transactionHash
|
||||
});
|
||||
this.lastSeenMessage = {
|
||||
at: new Date().toISOString(),
|
||||
message_id: messageId,
|
||||
destination_chain_selector: destSelector,
|
||||
sender,
|
||||
block_number: log.blockNumber,
|
||||
transaction_hash: log.transactionHash
|
||||
};
|
||||
|
||||
await this.messageQueue.add({
|
||||
messageId,
|
||||
destinationChainSelector,
|
||||
sender,
|
||||
receiver,
|
||||
targetBridge: scope.targetBridge,
|
||||
data,
|
||||
tokenAmounts: tokenAmounts.map((ta) => ({
|
||||
token: ta.token,
|
||||
@@ -330,6 +619,15 @@ export class RelayService {
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Error polling historical events:', error);
|
||||
this.lastSourcePoll = {
|
||||
at: new Date().toISOString(),
|
||||
ok: false,
|
||||
from_block: startBlock,
|
||||
error: RelayService.summarizeError(error)
|
||||
};
|
||||
this.recordError('source_poll', error, {
|
||||
from_block: startBlock
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, this.getSourcePollIntervalMs()));
|
||||
}
|
||||
}
|
||||
@@ -373,6 +671,19 @@ export class RelayService {
|
||||
|
||||
async relayMessage(messageData) {
|
||||
const { messageId, destinationChainSelector, sender, receiver, data, tokenAmounts } = messageData;
|
||||
|
||||
if (this.shouldSkipMessageId(messageId)) {
|
||||
this.logger.warn(`Skipping queued message ${messageId}; message id is in RELAY_SKIP_MESSAGE_IDS`);
|
||||
await this.messageQueue.markProcessed(messageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = this.evaluateMessageScope(messageData);
|
||||
if (!scope.inScope) {
|
||||
this.logger.info(`Skipping queued message ${messageId}; ${scope.reason}`);
|
||||
await this.messageQueue.markProcessed(messageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.info(`Relaying message ${messageId} to destination chain...`);
|
||||
|
||||
@@ -395,23 +706,26 @@ export class RelayService {
|
||||
}
|
||||
|
||||
// Route to bridge encoded in MessageSent.receiver (bytes). Fallback to static env bridge.
|
||||
let targetBridge = this.config.destinationChain.relayBridgeAddress;
|
||||
try {
|
||||
if (receiver) {
|
||||
const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['address'], receiver);
|
||||
if (decoded && decoded[0]) targetBridge = ethers.getAddress(decoded[0]);
|
||||
}
|
||||
} catch (_) {
|
||||
// keep fallback targetBridge
|
||||
}
|
||||
const targetBridge = scope.targetBridge || this.resolveTargetBridge(receiver);
|
||||
if (!targetBridge) {
|
||||
throw new Error(`No destination bridge for message ${messageId}: receiver decode failed and DEST_RELAY_BRIDGE not set`);
|
||||
}
|
||||
this.lastRelayAttempt = {
|
||||
at: new Date().toISOString(),
|
||||
message_id: messageId,
|
||||
destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '',
|
||||
target_bridge: targetBridge,
|
||||
token_count: tokenAmounts.length
|
||||
};
|
||||
|
||||
// Optional allowlist hardening.
|
||||
const allowlist = this.config.destinationChain.relayBridgeAllowlist || [];
|
||||
const allowlist = this.getDestinationBridgeAllowlist();
|
||||
if (allowlist.length > 0 && !allowlist.includes(String(targetBridge).toLowerCase())) {
|
||||
throw new Error(`Bridge ${targetBridge} not in DEST_RELAY_BRIDGE_ALLOWLIST`);
|
||||
this.logger.info(
|
||||
`Skipping message ${messageId} for bridge ${targetBridge}; not in this worker's DEST_RELAY_BRIDGE_ALLOWLIST`
|
||||
);
|
||||
await this.messageQueue.markProcessed(messageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
let targetBridgeContract = this.destinationBridgeContracts.get(targetBridge.toLowerCase());
|
||||
@@ -494,6 +808,7 @@ export class RelayService {
|
||||
tokenAmountsCount: any2EVMMessage.tokenAmounts.length
|
||||
});
|
||||
|
||||
const txOptions = await this.getDestinationTxOptions();
|
||||
let tx;
|
||||
if (this.config.destinationChain.deliveryMode === 'direct') {
|
||||
this.logger.info(`Direct-delivery mode: calling bridge ${targetBridge} without relay router`);
|
||||
@@ -502,13 +817,13 @@ export class RelayService {
|
||||
RelayBridgeABI,
|
||||
this.destinationSigner
|
||||
);
|
||||
tx = await directBridge.ccipReceive(any2EVMMessage, { gasLimit: 1000000 });
|
||||
tx = await directBridge.ccipReceive(any2EVMMessage, txOptions);
|
||||
} else {
|
||||
// Call relay router with properly formatted struct
|
||||
tx = await this.destinationRelayRouter.relayMessage(
|
||||
targetBridge,
|
||||
any2EVMMessage,
|
||||
{ gasLimit: 1000000 }
|
||||
txOptions
|
||||
);
|
||||
}
|
||||
|
||||
@@ -518,6 +833,16 @@ export class RelayService {
|
||||
const receipt = await tx.wait();
|
||||
|
||||
this.logger.info(`Message ${messageId} relayed successfully. Transaction: ${receipt.hash}`);
|
||||
this.lastRelaySuccess = {
|
||||
at: new Date().toISOString(),
|
||||
message_id: messageId,
|
||||
destination_chain_selector: destinationChainSelector ? destinationChainSelector.toString() : '',
|
||||
target_bridge: targetBridge,
|
||||
tx_hash: receipt.hash
|
||||
};
|
||||
if (this.lastError && this.lastError.scope === 'relay_message') {
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
// Mark message as processed
|
||||
await this.messageQueue.markProcessed(messageId);
|
||||
@@ -526,6 +851,9 @@ export class RelayService {
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Error relaying message ${messageId}:`, error);
|
||||
this.recordError('relay_message', error, {
|
||||
message_id: messageId
|
||||
});
|
||||
|
||||
// Retry logic
|
||||
const retryCount = await this.messageQueue.getRetryCount(messageId);
|
||||
|
||||
Reference in New Issue
Block a user