From 2f9cb2016a7d6759823e99ebc1651889294179c0 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 19:52:13 -0700 Subject: [PATCH 01/21] ~complete getCCTPMessageEvents Signed-off-by: Ihor Farion --- src/common/abi/HubPool.json | 19 +++ src/utils/CCTPUtils.ts | 256 ++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) diff --git a/src/common/abi/HubPool.json b/src/common/abi/HubPool.json index 6b6081a681..f29c9bbc01 100644 --- a/src/common/abi/HubPool.json +++ b/src/common/abi/HubPool.json @@ -9,5 +9,24 @@ ], "name": "TokensRelayed", "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "message", + "type": "bytes" + } + ], + "name": "MessageRelayed", + "type": "event" } ] diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 691ee073b6..8172c8f36e 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -10,6 +10,23 @@ import { getCachedProvider } from "./ProviderUtils"; import { EventSearchConfig, paginatedEventQuery } from "./EventUtils"; import { findLast } from "lodash"; import { Log } from "../interfaces"; +import { assert } from "."; + +type CommonMessageData = { + version: number; // 0 == v1, 1 == v2. This is how Circle assigns them + sourceDomain: number; + destinationDomain: number; + sender: string; + recipient: string; + + messageHash: string; + messageBytes: string; + nonceHash: string; +}; +// Common data + auxilary data from depositForBurn event +type DepositForBurnMessageData = CommonMessageData & { amount: string; mintRecipient: string; burnToken: string }; +type CommonMessageEvent = CommonMessageData & { log: Log }; +type DepositForBurnMessageEvent = DepositForBurnMessageData & { log: Log }; type CCTPDeposit = { nonceHash: string; @@ -38,8 +55,20 @@ function isCctpV2ApiResponse( } export type CCTPMessageStatus = "finalized" | "ready" | "pending"; export type AttestedCCTPDepositEvent = CCTPDepositEvent & { log: Log; status: CCTPMessageStatus; attestation?: string }; +export type CCTPMessageEvent = CommonMessageEvent | DepositForBurnMessageEvent; +export type AttestedCCTPMessageEvent = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string }; +function isDepositForBurnMessageEvent(event: CCTPMessageEvent): event is DepositForBurnMessageEvent { + return "amount" in event && "mintRecipient" in event && "burnToken" in event; +} const CCTP_MESSAGE_SENT_TOPIC_HASH = ethers.utils.id("MessageSent(bytes)"); +// TODO: check these +const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1 = ethers.utils.id( + "DepositForBurn(uint64,address,uint256,address,bytes32,uint32,bytes32,bytes32)" +); +const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 = ethers.utils.id( + "DepositForBurn(address,uint256,address,bytes32,uint32,bytes32,bytes32,uint256,uint32,bytes)" +); const CCTP_V2_L2_CHAINS = [CHAIN_IDs.LINEA]; @@ -114,6 +143,141 @@ export function getCctpDomainForChainId(chainId: number): number { return cctpDomain; } +async function getCCTPMessageEvents( + senderAddresses: string[], + sourceChainId: number, + destinationChainId: number, + l2ChainId: number, + sourceEventSearchConfig: EventSearchConfig +): Promise { + const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + const srcProvider = getCachedProvider(sourceChainId); + + // Step 1: Get all HubPool MessageRelayed and TokensRelayed events + const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; + if (!hubPoolAddress) { + throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); + } + + const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); + + const messageRelayedFilter = hubPool.filters.MessageRelayed(); + const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); + + const tokensRelayedFilter = hubPool.filters.TokensRelayed(); + const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + + const uniqueTxHashes = new Set([ + ...messageRelayedEvents.map((e) => e.transactionHash), + ...tokensRelayedEvents.map((e) => e.transactionHash), + ]); + + if (uniqueTxHashes.size === 0) { + return []; + } + + const receipts = await Promise.all(Array.from(uniqueTxHashes).map((hash) => srcProvider.getTransactionReceipt(hash))); + + const _isMessageSentEvent = (log: ethers.providers.Log): boolean => { + return log.topics[0] === CCTP_MESSAGE_SENT_TOPIC_HASH; + }; + + // returns 0 for v1 `DepositForBurn` event, 1 for v2, -1 for other events + const _getDepositForBurnVersion = (log: ethers.providers.Log): number => { + const topic = log.topics[0]; + switch (topic) { + case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1: + return 0; + case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2: + return 1; + default: + return -1; + } + }; + + const sourceDomainId = getCctpDomainForChainId(sourceChainId); + const destinationDomainId = getCctpDomainForChainId(destinationChainId); + // TODO: how come does `usdcAddress` have type `string` here? What if there's no `sourceChainId` entry? + const usdcAddress = TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId]; + assert(isDefined(usdcAddress), `USDC address not defined for chain ${sourceChainId}`); + + const _isRelevantEvent = (event: CCTPMessageEvent): boolean => { + const baseConditions = + event.sourceDomain === sourceDomainId && + event.destinationDomain === destinationDomainId && + senderAddresses.some((sender) => compareAddressesSimple(sender, event.sender)); + + return ( + baseConditions && + (!isDepositForBurnMessageEvent(event) || + // if DepositForBurnMessageEvent, check token too + compareAddressesSimple(event.burnToken, usdcAddress)) + ); + }; + + const relevantEvents: CCTPMessageEvent[] = []; + const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log) => { + const eventData = isCctpV2 ? _decodeCommonMessageDataV1(log) : _decodeCommonMessageDataV2(log); + const event: CommonMessageEvent = { + ...eventData, + log: log as Log, + }; + if (_isRelevantEvent(event)) { + relevantEvents.push(event); + } + }; + for (const receipt of receipts) { + let lastMessageSentEventIdx = -1; + let i = 0; + for (const log of receipt.logs) { + if (_isMessageSentEvent(log)) { + if (lastMessageSentEventIdx == -1) { + lastMessageSentEventIdx = i; + } else { + _addCommonMessageEventIfRelevant(log); + } + } else { + const logCctpVersion = _getDepositForBurnVersion(log); + if (logCctpVersion == -1) { + // skip non-`DepositForBurn` events + continue; + } + + const matchingCctpVersion = (logCctpVersion == 0 && !isCctpV2) || (logCctpVersion == 1 && isCctpV2); + if (matchingCctpVersion) { + // if DepositForBurn event matches our "desired version", assess if it matches our search parameters + const correspondingMessageSentLog = receipt.logs[lastMessageSentEventIdx]; + const eventData = isCctpV2 + ? _decodeDepositForBurnMessageDataV2(correspondingMessageSentLog) + : _decodeDepositForBurnMessageDataV1(correspondingMessageSentLog); + const event: DepositForBurnMessageEvent = { + ...eventData, + // TODO: how to turn ethers.providers.Log into Log ???? :(((( + // ! todo. This will not work. But do we even need log here? + log: log as Log, + }; + if (_isRelevantEvent(event)) { + // TODO: Check correspondence between DepositForBurn and MessageSent events + // TODO: how to turn ethers.providers.Log into Log ???? :(((( + relevantEvents.push(event); + } + lastMessageSentEventIdx = -1; + } else { + // reset `lastMessageSentEventIdx`, because we found a matching `DepositForBurn` event + lastMessageSentEventIdx = -1; + } + } + i += 1; + } + // After the loop over all logs, we might have an unmatched `MessageSent` event. Try to add it to `relevantEvents` + if (lastMessageSentEventIdx != -1) { + _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx]); + } + } + + return relevantEvents; +} + async function getCCTPDepositEvents( senderAddresses: string[], sourceChainId: number, @@ -345,6 +509,98 @@ async function _hasCCTPMessageBeenProcessed(nonceHash: string, contract: ethers. return (resultingCall ?? bnZero).toNumber() === 1; } +function _decodeCommonMessageDataV1(message: { data: string }): CommonMessageData { + // Source: https://developers.circle.com/stablecoins/message-format + const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; + const messageBytesArray = ethers.utils.arrayify(messageBytes); + const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); // sourceDomain 4 bytes starting index 4 + const destinationDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(8, 12))); // destinationDomain 4 bytes starting index 8 + const nonce = BigNumber.from(ethers.utils.hexlify(messageBytesArray.slice(12, 20))).toNumber(); // nonce 8 bytes starting index 12 + const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(20, 52))); // sender 20 bytes32 32 Address of MessageTransmitter caller on source domain + const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(52, 84))); // recipient 52 bytes32 32 Address to handle message body on destination domain + + // V1 nonce hash is a simple hash of the nonce emitted in Deposit event with the source domain ID. + const nonceHash = ethers.utils.keccak256(ethers.utils.solidityPack(["uint32", "uint64"], [sourceDomain, nonce])); + + return { + version: 0, + sourceDomain, + destinationDomain, + sender, + recipient, + nonceHash, + messageHash: ethers.utils.keccak256(messageBytes), + messageBytes, + }; +} + +function _decodeCommonMessageDataV2(message: { data: string }): CommonMessageData { + // Source: https://developers.circle.com/stablecoins/message-format + const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; + const messageBytesArray = ethers.utils.arrayify(messageBytes); + const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); // sourceDomain 4 bytes starting index 4 + const destinationDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(8, 12))); // destinationDomain 4 bytes starting index 8 + const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(44, 76))); // sender 44 bytes32 32 Address of MessageTransmitterV2 caller on source domain + const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(76, 108))); // recipient 76 bytes32 32 Address to handle message body on destination domain + + return { + version: 0, + sourceDomain, + destinationDomain, + sender, + recipient, + // For v2, we rely on Circle's API to find nonceHash + nonceHash: ethers.constants.HashZero, + messageHash: ethers.utils.keccak256(messageBytes), + messageBytes, + }; +} + +function _decodeDepositForBurnMessageDataV1(message: { data: string }): DepositForBurnMessageData { + // Source: https://developers.circle.com/stablecoins/message-format + const commonDataV1 = _decodeCommonMessageDataV1(message); + const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; + const messageBytesArray = ethers.utils.arrayify(messageBytes); + + // Values specific to `DepositForBurn`. These are values contained withing `messageBody` bytes (the last of the message.data fields) + const burnToken = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(120, 152))); // burnToken 4 bytes32 32 Address of burned token on source domain + const mintRecipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(152, 184))); // mintRecipient 32 bytes starting index 152 (idx 36 of body after idx 116 which ends the header) + const amount = ethers.utils.hexlify(messageBytesArray.slice(184, 216)); // amount 32 bytes starting index 184 (idx 68 of body after idx 116 which ends the header) + const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(216, 248))); // sender 32 bytes starting index 216 (idx 100 of body after idx 116 which ends the header) + + return { + ...commonDataV1, + burnToken, + amount: BigNumber.from(amount).toString(), + // override sender and recipient from `DepositForBurn`-specific values + sender: sender, + recipient: mintRecipient, + mintRecipient, + }; +} + +function _decodeDepositForBurnMessageDataV2(message: { data: string }): DepositForBurnMessageData { + // Source: https://developers.circle.com/stablecoins/message-format + const commonData = _decodeCommonMessageDataV2(message); + const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; + const messageBytesArray = ethers.utils.arrayify(messageBytes); + + const burnToken = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(152, 184))); // burnToken: Address of burned token on source domain. 32 bytes starting index 152 (idx 4 of body after idx 148 which ends the header) + const mintRecipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(184, 216))); // recipient 32 bytes starting index 184 (idx 36 of body after idx 148 which ends the header) + const amount = ethers.utils.hexlify(messageBytesArray.slice(216, 248)); // amount 32 bytes starting index 216 (idx 68 of body after idx 148 which ends the header) + const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(248, 280))); // sender 32 bytes starting index 248 (idx 100 of body after idx 148 which ends the header) + + return { + ...commonData, + burnToken, + amount: BigNumber.from(amount).toString(), + // override sender and recipient from `DepositForBurn`-specific values + sender: sender, + recipient: mintRecipient, + mintRecipient, + }; +} + function _decodeCCTPV1Message(message: { data: string }): CCTPDeposit { // Source: https://developers.circle.com/stablecoins/message-format const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; From 1c8a175681626264d412caabf81e39e426e4380c Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 20:30:07 -0700 Subject: [PATCH 02/21] fix Log handling: produce *typed* logs Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 61 +++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 8172c8f36e..3e966853c1 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -7,7 +7,7 @@ import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; import { getCachedProvider } from "./ProviderUtils"; -import { EventSearchConfig, paginatedEventQuery } from "./EventUtils"; +import { EventSearchConfig, paginatedEventQuery, spreadEvent } from "./EventUtils"; import { findLast } from "lodash"; import { Log } from "../interfaces"; import { assert } from "."; @@ -151,6 +151,24 @@ async function getCCTPMessageEvents( sourceEventSearchConfig: EventSearchConfig ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + // TODO: separate into a function `getContractInterfaces` + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { address: _tokenMessengerAddr, abi: tokenMessengerAbi } = getCctpTokenMessenger(l2ChainId, sourceChainId); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { address: _messageTransmitterAddr, abi: messageTransmitterAbi } = getCctpMessageTransmitter( + l2ChainId, + sourceChainId + ); + const tokenMessengerInterface = new ethers.utils.Interface(tokenMessengerAbi); + const messageTransmitterInterface = new ethers.utils.Interface(messageTransmitterAbi); + + const depositForBurnTopic = tokenMessengerInterface.getEventTopic("DepositForBurn"); + assert( + depositForBurnTopic === (isCctpV2 ? CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 : CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1) + ); + const messegeSentTopic = messageTransmitterInterface.getEventTopic("MessageSent"); + assert(messegeSentTopic === CCTP_MESSAGE_SENT_TOPIC_HASH); + const srcProvider = getCachedProvider(sourceChainId); // Step 1: Get all HubPool MessageRelayed and TokensRelayed events @@ -159,6 +177,7 @@ async function getCCTPMessageEvents( throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); } + // TODO: separate into a function `getRelevantTxHashes`: `const uniqueTxHashes = await getRelevantTxHashes();` const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); const messageRelayedFilter = hubPool.filters.MessageRelayed(); @@ -218,9 +237,16 @@ async function getCCTPMessageEvents( const relevantEvents: CCTPMessageEvent[] = []; const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log) => { const eventData = isCctpV2 ? _decodeCommonMessageDataV1(log) : _decodeCommonMessageDataV2(log); + const logDescription = messageTransmitterInterface.parseLog(log); + const spreadArgs = spreadEvent(logDescription.args); + const eventName = logDescription.name; const event: CommonMessageEvent = { ...eventData, - log: log as Log, + log: { + ...log, + event: eventName, + args: spreadArgs, + }, }; if (_isRelevantEvent(event)) { relevantEvents.push(event); @@ -239,26 +265,43 @@ async function getCCTPMessageEvents( } else { const logCctpVersion = _getDepositForBurnVersion(log); if (logCctpVersion == -1) { - // skip non-`DepositForBurn` events + // Skip non-`DepositForBurn` events continue; } const matchingCctpVersion = (logCctpVersion == 0 && !isCctpV2) || (logCctpVersion == 1 && isCctpV2); if (matchingCctpVersion) { - // if DepositForBurn event matches our "desired version", assess if it matches our search parameters + // If DepositForBurn event matches our "desired version", assess if it matches our search parameters const correspondingMessageSentLog = receipt.logs[lastMessageSentEventIdx]; const eventData = isCctpV2 ? _decodeDepositForBurnMessageDataV2(correspondingMessageSentLog) : _decodeDepositForBurnMessageDataV1(correspondingMessageSentLog); + const logDescription = tokenMessengerInterface.parseLog(log); + const spreadArgs = spreadEvent(logDescription.args); + const eventName = logDescription.name; + + // Verify that decoded message matches the DepositForBurn event. We can skip this step if we find this reduces performance + if ( + !compareAddressesSimple(eventData.sender, spreadArgs.depositor) || + !compareAddressesSimple(eventData.recipient, cctpBytes32ToAddress(spreadArgs.mintRecipient)) || + !BigNumber.from(eventData.amount).eq(spreadArgs.amount) + ) { + const { transactionHash: txnHash, logIndex } = log; + throw new Error( + `Decoded message at log index ${correspondingMessageSentLog.logIndex}` + + ` does not match the DepositForBurn event in ${txnHash} at log index ${logIndex}` + ); + } + const event: DepositForBurnMessageEvent = { ...eventData, - // TODO: how to turn ethers.providers.Log into Log ???? :(((( - // ! todo. This will not work. But do we even need log here? - log: log as Log, + log: { + ...log, + event: eventName, + args: spreadArgs, + }, }; if (_isRelevantEvent(event)) { - // TODO: Check correspondence between DepositForBurn and MessageSent events - // TODO: how to turn ethers.providers.Log into Log ???? :(((( relevantEvents.push(event); } lastMessageSentEventIdx = -1; From 835db3ea6ba4995a85603b1ea28aa91c4f73071c Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 20:53:38 -0700 Subject: [PATCH 03/21] complete implementation Signed-off-by: Ihor Farion --- src/finalizer/utils/cctp/l1ToL2.ts | 36 +++-- src/finalizer/utils/cctp/l2ToL1.ts | 10 +- src/utils/CCTPUtils.ts | 210 ++++++----------------------- 3 files changed, 70 insertions(+), 186 deletions(-) diff --git a/src/finalizer/utils/cctp/l1ToL2.ts b/src/finalizer/utils/cctp/l1ToL2.ts index ea756c0b78..bf8ffa4cc4 100644 --- a/src/finalizer/utils/cctp/l1ToL2.ts +++ b/src/finalizer/utils/cctp/l1ToL2.ts @@ -15,10 +15,11 @@ import { isEVMSpokePoolClient, } from "../../../utils"; import { - AttestedCCTPDepositEvent, + AttestedCCTPMessage, CCTPMessageStatus, - getAttestationsForCCTPDepositEvents, + getAttestedCCTPMessages, getCctpMessageTransmitter, + isDepositForBurnEvent, } from "../../../utils/CCTPUtils"; import { FinalizerPromise, CrossChainMessage } from "../../types"; @@ -36,7 +37,7 @@ export async function cctpL1toL2Finalizer( to: l1SpokePoolClient.latestHeightSearched, maxLookBack: l1SpokePoolClient.eventSearchConfig.maxLookBack, }; - const outstandingDeposits = await getAttestationsForCCTPDepositEvents( + const outstandingDeposits = await getAttestedCCTPMessages( senderAddresses, hubPoolClient.chainId, l2SpokePoolClient.chainId, @@ -77,7 +78,7 @@ export async function cctpL1toL2Finalizer( */ async function generateMultiCallData( messageTransmitter: Contract, - messages: Pick[] + messages: Pick[] ): Promise { assert(messages.every(({ attestation }) => isDefined(attestation) && attestation !== "PENDING")); return Promise.all( @@ -102,15 +103,26 @@ async function generateMultiCallData( * @returns A list of valid withdrawals for a given list of CCTP messages. */ async function generateDepositData( - messages: Pick[], + messages: AttestedCCTPMessage[], originationChainId: number, destinationChainId: number ): Promise { - return messages.map((message) => ({ - l1TokenSymbol: "USDC", // Always USDC b/c that's the only token we support on CCTP - amount: convertFromWei(message.amount, TOKEN_SYMBOLS_MAP.USDC.decimals), // Format out to 6 decimal places for USDC - type: "deposit", - originationChainId, - destinationChainId, - })); + return messages.map((message) => { + if (isDepositForBurnEvent(message)) { + return { + l1TokenSymbol: "USDC", // Always USDC b/c that's the only token we support on CCTP + amount: convertFromWei(message.amount, TOKEN_SYMBOLS_MAP.USDC.decimals), // Format out to 6 decimal places for USDC + type: "deposit", + originationChainId, + destinationChainId, + }; + } else { + return { + type: "misc", + miscReason: `Finalization of CCTP crosschain message ${message.log.transactionHash} ; log index ${message.log.logIndex}`, + originationChainId, + destinationChainId, + }; + } + }); } diff --git a/src/finalizer/utils/cctp/l2ToL1.ts b/src/finalizer/utils/cctp/l2ToL1.ts index 8273a4f825..db20bf4092 100644 --- a/src/finalizer/utils/cctp/l2ToL1.ts +++ b/src/finalizer/utils/cctp/l2ToL1.ts @@ -14,9 +14,9 @@ import { EventSearchConfig, } from "../../../utils"; import { - AttestedCCTPDepositEvent, + AttestedCCTPDeposit, CCTPMessageStatus, - getAttestationsForCCTPDepositEvents, + getAttestedCCTPDeposits, getCctpMessageTransmitter, } from "../../../utils/CCTPUtils"; import { FinalizerPromise, CrossChainMessage } from "../../types"; @@ -34,7 +34,7 @@ export async function cctpL2toL1Finalizer( to: spokePoolClient.latestHeightSearched, maxLookBack: spokePoolClient.eventSearchConfig.maxLookBack, }; - const outstandingDeposits = await getAttestationsForCCTPDepositEvents( + const outstandingDeposits = await getAttestedCCTPDeposits( senderAddresses, spokePoolClient.chainId, hubPoolClient.chainId, @@ -76,7 +76,7 @@ export async function cctpL2toL1Finalizer( */ async function generateMultiCallData( messageTransmitter: Contract, - messages: Pick[] + messages: Pick[] ): Promise { assert(messages.every((message) => isDefined(message.attestation))); return Promise.all( @@ -101,7 +101,7 @@ async function generateMultiCallData( * @returns A list of valid withdrawals for a given list of CCTP messages. */ async function generateWithdrawalData( - messages: Pick[], + messages: Pick[], originationChainId: number, destinationChainId: number ): Promise { diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 3e966853c1..c2e7b925f0 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -8,7 +8,6 @@ import { bnZero, compareAddressesSimple } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; import { getCachedProvider } from "./ProviderUtils"; import { EventSearchConfig, paginatedEventQuery, spreadEvent } from "./EventUtils"; -import { findLast } from "lodash"; import { Log } from "../interfaces"; import { assert } from "."; @@ -28,17 +27,6 @@ type DepositForBurnMessageData = CommonMessageData & { amount: string; mintRecip type CommonMessageEvent = CommonMessageData & { log: Log }; type DepositForBurnMessageEvent = DepositForBurnMessageData & { log: Log }; -type CCTPDeposit = { - nonceHash: string; - amount: string; - sourceDomain: number; - destinationDomain: number; - sender: string; - recipient: string; - messageHash: string; - messageBytes: string; -}; -type CCTPDepositEvent = CCTPDeposit & { log: Log }; type CCTPAPIGetAttestationResponse = { status: string; attestation: string }; type CCTPV2APIAttestation = { status: string; @@ -54,10 +42,10 @@ function isCctpV2ApiResponse( return (obj as CCTPV2APIGetAttestationResponse).messages !== undefined; } export type CCTPMessageStatus = "finalized" | "ready" | "pending"; -export type AttestedCCTPDepositEvent = CCTPDepositEvent & { log: Log; status: CCTPMessageStatus; attestation?: string }; export type CCTPMessageEvent = CommonMessageEvent | DepositForBurnMessageEvent; -export type AttestedCCTPMessageEvent = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string }; -function isDepositForBurnMessageEvent(event: CCTPMessageEvent): event is DepositForBurnMessageEvent { +export type AttestedCCTPMessage = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string }; +export type AttestedCCTPDeposit = DepositForBurnMessageEvent & { status: CCTPMessageStatus; attestation?: string }; +export function isDepositForBurnEvent(event: CCTPMessageEvent): event is DepositForBurnMessageEvent { return "amount" in event && "mintRecipient" in event && "burnToken" in event; } @@ -228,7 +216,7 @@ async function getCCTPMessageEvents( return ( baseConditions && - (!isDepositForBurnMessageEvent(event) || + (!isDepositForBurnEvent(event) || // if DepositForBurnMessageEvent, check token too compareAddressesSimple(event.burnToken, usdcAddress)) ); @@ -321,101 +309,15 @@ async function getCCTPMessageEvents( return relevantEvents; } -async function getCCTPDepositEvents( - senderAddresses: string[], - sourceChainId: number, - destinationChainId: number, - l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig -): Promise { - const isCctpV2 = isCctpV2L2ChainId(l2ChainId); - - // Step 1: Get all DepositForBurn events matching the senderAddress and source chain. - const srcProvider = getCachedProvider(sourceChainId); - const { address, abi } = getCctpTokenMessenger(l2ChainId, sourceChainId); - const srcTokenMessenger = new Contract(address, abi, srcProvider); - const eventFilterParams = isCctpV2 - ? [TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses] - : [undefined, TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses]; - const eventFilter = srcTokenMessenger.filters.DepositForBurn(...eventFilterParams); - const depositForBurnEvents = ( - await paginatedEventQuery(srcTokenMessenger, eventFilter, sourceEventSearchConfig) - ).filter((e) => e.args.destinationDomain === getCctpDomainForChainId(destinationChainId)); - - // Step 2: Get TransactionReceipt for all these events, which we'll use to find the accompanying MessageSent events. - const receipts = await Promise.all( - depositForBurnEvents.map((event) => srcProvider.getTransactionReceipt(event.transactionHash)) - ); - const messageSentEvents = depositForBurnEvents.map(({ transactionHash: txnHash, logIndex }, i) => { - // The MessageSent event should always precede the DepositForBurn event in the same transaction. We should - // search from right to left so we find the nearest preceding MessageSent event. - const _messageSentEvent = findLast( - receipts[i].logs, - (l) => l.topics[0] === CCTP_MESSAGE_SENT_TOPIC_HASH && l.logIndex < logIndex - ); - if (!isDefined(_messageSentEvent)) { - throw new Error( - `Could not find MessageSent event for DepositForBurn event in ${txnHash} at log index ${logIndex}` - ); - } - return _messageSentEvent; - }); - - // Step 3. Decode the MessageSent events to find the message nonce, which we can use to query the deposit status, - // get the attestation, and further filter the events. - const decodedMessages = messageSentEvents.map((_messageSentEvent, i) => { - const { - sender: decodedSender, - nonceHash, - amount: decodedAmount, - sourceDomain: decodedSourceDomain, - destinationDomain: decodedDestinationDomain, - recipient: decodedRecipient, - messageHash, - messageBytes, - } = isCctpV2 ? _decodeCCTPV2Message(_messageSentEvent) : _decodeCCTPV1Message(_messageSentEvent); - const _depositEvent = depositForBurnEvents[i]; - - // Step 4. [Optional] Verify that decoded message matches the DepositForBurn event. We can skip this step - // if we find this reduces performance. - if ( - !compareAddressesSimple(decodedSender, _depositEvent.args.depositor) || - !compareAddressesSimple(decodedRecipient, cctpBytes32ToAddress(_depositEvent.args.mintRecipient)) || - !BigNumber.from(decodedAmount).eq(_depositEvent.args.amount) || - decodedSourceDomain !== getCctpDomainForChainId(sourceChainId) || - decodedDestinationDomain !== getCctpDomainForChainId(destinationChainId) - ) { - const { transactionHash: txnHash, logIndex } = _depositEvent; - throw new Error( - `Decoded message at log index ${_messageSentEvent.logIndex}` + - ` does not match the DepositForBurn event in ${txnHash} at log index ${logIndex}` - ); - } - return { - nonceHash, - sender: decodedSender, - recipient: decodedRecipient, - amount: decodedAmount, - sourceDomain: decodedSourceDomain, - destinationDomain: decodedDestinationDomain, - messageHash, - messageBytes, - log: _depositEvent, - }; - }); - - return decodedMessages; -} - -async function getCCTPDepositEventsWithStatus( +async function getCCTPMessageEventsWithStatus( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig -): Promise { +): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); - const deposits = await getCCTPDepositEvents( + const cctpMessages = await getCCTPMessageEvents( senderAddresses, sourceChainId, destinationChainId, @@ -426,7 +328,7 @@ async function getCCTPDepositEventsWithStatus( const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); return await Promise.all( - deposits.map(async (deposit) => { + cctpMessages.map(async (deposit) => { // @dev Currently we have no way to recreate the V2 nonce hash until after we've received the attestation, // so skip this step for V2 since we have no way of knowing whether the message is finalized until after we // query the Attestation API service. @@ -452,6 +354,25 @@ async function getCCTPDepositEventsWithStatus( ); } +// same as `getAttestedCCTPMessages`, but filters out all non-deposit CCTP messages +export async function getAttestedCCTPDeposits( + senderAddresses: string[], + sourceChainId: number, + destinationChainId: number, + l2ChainId: number, + sourceEventSearchConfig: EventSearchConfig +): Promise { + const messages = await getAttestedCCTPMessages( + senderAddresses, + sourceChainId, + destinationChainId, + l2ChainId, + sourceEventSearchConfig + ); + // only return deposit messages + return messages.filter((message) => isDepositForBurnEvent(message)) as AttestedCCTPDeposit[]; +} + /** * @notice Return all non-finalized CCTPDeposit events with their attestations attached. Attestations will be undefined * if the attestationn "status" is not "ready". @@ -462,16 +383,16 @@ async function getCCTPDepositEventsWithStatus( * which is the chain where the Deposit was created. This is used to identify whether the CCTP deposit is V1 or V2. * @param sourceEventSearchConfig */ -export async function getAttestationsForCCTPDepositEvents( +export async function getAttestedCCTPMessages( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig -): Promise { +): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); - const depositsWithStatus = await getCCTPDepositEventsWithStatus( + const messagesWithStatus = await getCCTPMessageEventsWithStatus( senderAddresses, sourceChainId, destinationChainId, @@ -485,20 +406,20 @@ export async function getAttestationsForCCTPDepositEvents( const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); - const attestedDeposits = await Promise.all( - depositsWithStatus.map(async (deposit) => { + const attestedMessages = await Promise.all( + messagesWithStatus.map(async (message) => { // If deposit is already finalized, we won't change its attestation status: - if (deposit.status === "finalized") { - return deposit; + if (message.status === "finalized") { + return message; } // Otherwise, update the deposit's status after loading its attestation. - const { transactionHash } = deposit.log; + const { transactionHash } = message.log; const count = (txnReceiptHashCount[transactionHash] ??= 0); ++txnReceiptHashCount[transactionHash]; const attestation = isCctpV2 - ? await _generateCCTPV2AttestationProof(deposit.sourceDomain, transactionHash, isMainnet) - : await _generateCCTPAttestationProof(deposit.messageHash, isMainnet); + ? await _generateCCTPV2AttestationProof(message.sourceDomain, transactionHash, isMainnet) + : await _generateCCTPAttestationProof(message.messageHash, isMainnet); // For V2 we now have the nonceHash and we can query whether the message has been processed. // We can remove this V2 custom logic once we can derive nonceHashes locally and filter out already "finalized" @@ -511,12 +432,12 @@ export async function getAttestationsForCCTPDepositEvents( ); if (processed) { return { - ...deposit, + ...message, status: "finalized" as CCTPMessageStatus, }; } else { return { - ...deposit, + ...message, // For CCTPV2, the message is different than the one emitted in the Deposit because it includes the nonce, and // we need the messageBytes to submit the receiveMessage call successfully. We don't overwrite the messageHash // because its not needed for V2 @@ -528,14 +449,14 @@ export async function getAttestationsForCCTPDepositEvents( } } else { return { - ...deposit, + ...message, attestation: attestation?.attestation, // Will be undefined if status is "pending" status: _getPendingAttestationStatus(attestation.status), }; } }) ); - return attestedDeposits; + return attestedMessages; } function _getPendingV2AttestationStatus(attestation: string): CCTPMessageStatus { @@ -644,55 +565,6 @@ function _decodeDepositForBurnMessageDataV2(message: { data: string }): DepositF }; } -function _decodeCCTPV1Message(message: { data: string }): CCTPDeposit { - // Source: https://developers.circle.com/stablecoins/message-format - const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; - const messageBytesArray = ethers.utils.arrayify(messageBytes); - const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); // sourceDomain 4 bytes starting index 4 - const destinationDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(8, 12))); // destinationDomain 4 bytes starting index 8 - const nonce = BigNumber.from(ethers.utils.hexlify(messageBytesArray.slice(12, 20))).toNumber(); // nonce 8 bytes starting index 12 - // V1 nonce hash is a simple hash of the nonce emitted in Deposit event with the source domain ID. - const nonceHash = ethers.utils.keccak256(ethers.utils.solidityPack(["uint32", "uint64"], [sourceDomain, nonce])); // - const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(152, 184))); // recipient 32 bytes starting index 152 (idx 36 of body after idx 116 which ends the header) - const amount = ethers.utils.hexlify(messageBytesArray.slice(184, 216)); // amount 32 bytes starting index 184 (idx 68 of body after idx 116 which ends the header) - const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(216, 248))); // sender 32 bytes starting index 216 (idx 100 of body after idx 116 which ends the header) - - return { - nonceHash, - amount: BigNumber.from(amount).toString(), - sourceDomain, - destinationDomain, - sender, - recipient, - messageHash: ethers.utils.keccak256(messageBytes), - messageBytes, - }; -} - -function _decodeCCTPV2Message(message: { data: string; transactionHash: string; logIndex: number }): CCTPDeposit { - // Source: https://developers.circle.com/stablecoins/message-format - const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], message.data)[0]; - const messageBytesArray = ethers.utils.arrayify(messageBytes); - const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); // sourceDomain 4 bytes starting index 4 - const destinationDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(8, 12))); // destinationDomain 4 bytes starting index 8 - const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(184, 216))); // recipient 32 bytes starting index 184 (idx 36 of body after idx 148 which ends the header) - const amount = ethers.utils.hexlify(messageBytesArray.slice(216, 248)); // amount 32 bytes starting index 216 (idx 68 of body after idx 148 which ends the header) - const sender = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(248, 280))); // sender 32 bytes starting index 248 (idx 100 of body after idx 148 which ends the header) - - return { - // Nonce is hardcoded to bytes32(0) in the V2 DepositForBurn event, so we either need to compute it here or get it - // the API service. For now, we cannot compute it here as Circle will not disclose the hashing algorithm. - nonceHash: ethers.constants.HashZero, - amount: BigNumber.from(amount).toString(), - sourceDomain, - destinationDomain, - sender, - recipient, - messageHash: ethers.utils.keccak256(messageBytes), - messageBytes, - }; -} - /** * Generates an attestation proof for a given message hash. This is required to finalize a CCTP message. * @param messageHash The message hash to generate an attestation proof for. This is generated by taking the keccak256 hash of the message bytes of the initial transaction log. From 09727cff3bf3e5e307cf36e3096b4146d285b706 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 21:10:01 -0700 Subject: [PATCH 04/21] refactor progress Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 88 +++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index c2e7b925f0..e70ec4512f 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -6,7 +6,7 @@ import { CONTRACT_ADDRESSES } from "../common"; import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; -import { getCachedProvider } from "./ProviderUtils"; +import { RetryProvider, getCachedProvider } from "./ProviderUtils"; import { EventSearchConfig, paginatedEventQuery, spreadEvent } from "./EventUtils"; import { Log } from "../interfaces"; import { assert } from "."; @@ -50,7 +50,6 @@ export function isDepositForBurnEvent(event: CCTPMessageEvent): event is Deposit } const CCTP_MESSAGE_SENT_TOPIC_HASH = ethers.utils.id("MessageSent(bytes)"); -// TODO: check these const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1 = ethers.utils.id( "DepositForBurn(uint64,address,uint256,address,bytes32,uint32,bytes32,bytes32)" ); @@ -131,41 +130,62 @@ export function getCctpDomainForChainId(chainId: number): number { return cctpDomain; } -async function getCCTPMessageEvents( - senderAddresses: string[], - sourceChainId: number, - destinationChainId: number, +/** + * Creates and validates CCTP contract interfaces for TokenMessenger and MessageTransmitter contracts. + * Asserts that the event topic hashes match expected values to ensure interface correctness. + */ +function getContractInterfaces( l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig -): Promise { - const isCctpV2 = isCctpV2L2ChainId(l2ChainId); - // TODO: separate into a function `getContractInterfaces` - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { address: _tokenMessengerAddr, abi: tokenMessengerAbi } = getCctpTokenMessenger(l2ChainId, sourceChainId); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { address: _messageTransmitterAddr, abi: messageTransmitterAbi } = getCctpMessageTransmitter( - l2ChainId, - sourceChainId - ); + sourceChainId: number, + isCctpV2: boolean +): { + tokenMessengerInterface: ethers.utils.Interface; + messageTransmitterInterface: ethers.utils.Interface; +} { + // Get contract ABIs + const { abi: tokenMessengerAbi } = getCctpTokenMessenger(l2ChainId, sourceChainId); + const { abi: messageTransmitterAbi } = getCctpMessageTransmitter(l2ChainId, sourceChainId); + + // Create interfaces const tokenMessengerInterface = new ethers.utils.Interface(tokenMessengerAbi); const messageTransmitterInterface = new ethers.utils.Interface(messageTransmitterAbi); + // Validate event topic hashes to ensure interface correctness const depositForBurnTopic = tokenMessengerInterface.getEventTopic("DepositForBurn"); + const expectedDepositForBurnTopic = isCctpV2 + ? CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 + : CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1; assert( - depositForBurnTopic === (isCctpV2 ? CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 : CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1) + depositForBurnTopic === expectedDepositForBurnTopic, + `DepositForBurn topic mismatch: expected ${expectedDepositForBurnTopic}, got ${depositForBurnTopic}` ); - const messegeSentTopic = messageTransmitterInterface.getEventTopic("MessageSent"); - assert(messegeSentTopic === CCTP_MESSAGE_SENT_TOPIC_HASH); - const srcProvider = getCachedProvider(sourceChainId); + const messageSentTopic = messageTransmitterInterface.getEventTopic("MessageSent"); + assert( + messageSentTopic === CCTP_MESSAGE_SENT_TOPIC_HASH, + `MessageSent topic mismatch: expected ${CCTP_MESSAGE_SENT_TOPIC_HASH}, got ${messageSentTopic}` + ); - // Step 1: Get all HubPool MessageRelayed and TokensRelayed events + return { + tokenMessengerInterface, + messageTransmitterInterface, + }; +} + +/** + * Gets all tx hashes that may be relevant for CCTP finalization: all txs where HubPool was sending money / messages *and* + * all tx hashes where `DepositForBurn` events happened + */ +async function getRelevantCCTPTxHashes( + srcProvider: RetryProvider, + sourceChainId: number, + sourceEventSearchConfig: EventSearchConfig +) { const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; if (!hubPoolAddress) { throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); } - // TODO: separate into a function `getRelevantTxHashes`: `const uniqueTxHashes = await getRelevantTxHashes();` const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); const messageRelayedFilter = hubPool.filters.MessageRelayed(); @@ -179,6 +199,28 @@ async function getCCTPMessageEvents( ...tokensRelayedEvents.map((e) => e.transactionHash), ]); + // TODO: also query some DepositForBurn events to be able to finalize not only HubPool-initiated token deposits + + return uniqueTxHashes; +} + +async function getCCTPMessageEvents( + senderAddresses: string[], + sourceChainId: number, + destinationChainId: number, + l2ChainId: number, + sourceEventSearchConfig: EventSearchConfig +): Promise { + const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + const { tokenMessengerInterface, messageTransmitterInterface } = getContractInterfaces( + l2ChainId, + sourceChainId, + isCctpV2 + ); + + const srcProvider = getCachedProvider(sourceChainId); + const uniqueTxHashes = await getRelevantCCTPTxHashes(srcProvider, sourceChainId, sourceEventSearchConfig); + if (uniqueTxHashes.size === 0) { return []; } From 38fc4d80f36bc69ccbc90ea3f973cbbb623282b2 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 21:16:58 -0700 Subject: [PATCH 05/21] handle edge case Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index e70ec4512f..06b37e48fb 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -179,8 +179,11 @@ function getContractInterfaces( async function getRelevantCCTPTxHashes( srcProvider: RetryProvider, sourceChainId: number, + destinationChainId: number, + l2ChainId: number, + senderAddresses: string[], sourceEventSearchConfig: EventSearchConfig -) { +): Promise> { const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; if (!hubPoolAddress) { throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); @@ -194,16 +197,31 @@ async function getRelevantCCTPTxHashes( const tokensRelayedFilter = hubPool.filters.TokensRelayed(); const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + // Step 2: Get all DepositForBurn events matching the senderAddress and source chain + const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + const { address, abi } = getCctpTokenMessenger(l2ChainId, sourceChainId); + const srcTokenMessenger = new Contract(address, abi, srcProvider); + + const eventFilterParams = isCctpV2 + ? [TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses] + : [undefined, TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses]; + const eventFilter = srcTokenMessenger.filters.DepositForBurn(...eventFilterParams); + const depositForBurnEvents = ( + await paginatedEventQuery(srcTokenMessenger, eventFilter, sourceEventSearchConfig) + ).filter((e) => e.args.destinationDomain === getCctpDomainForChainId(destinationChainId)); + const uniqueTxHashes = new Set([ ...messageRelayedEvents.map((e) => e.transactionHash), ...tokensRelayedEvents.map((e) => e.transactionHash), + ...depositForBurnEvents.map((e) => e.transactionHash), ]); - // TODO: also query some DepositForBurn events to be able to finalize not only HubPool-initiated token deposits - return uniqueTxHashes; } +/** + * Gets all CCTP messages that we can finalize: any CCTP token sends + HubPool message sends to SpokePools + */ async function getCCTPMessageEvents( senderAddresses: string[], sourceChainId: number, @@ -219,7 +237,14 @@ async function getCCTPMessageEvents( ); const srcProvider = getCachedProvider(sourceChainId); - const uniqueTxHashes = await getRelevantCCTPTxHashes(srcProvider, sourceChainId, sourceEventSearchConfig); + const uniqueTxHashes = await getRelevantCCTPTxHashes( + srcProvider, + sourceChainId, + destinationChainId, + l2ChainId, + senderAddresses, + sourceEventSearchConfig + ); if (uniqueTxHashes.size === 0) { return []; From d4cf83c73042f913605a79a2796c2ae5e4b86a01 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 21:43:19 -0700 Subject: [PATCH 06/21] fix nuanced L2 -> L1 behavior Signed-off-by: Ihor Farion --- src/finalizer/utils/cctp/l1ToL2.ts | 3 +- src/utils/CCTPUtils.ts | 127 +++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/src/finalizer/utils/cctp/l1ToL2.ts b/src/finalizer/utils/cctp/l1ToL2.ts index bf8ffa4cc4..e8f059790f 100644 --- a/src/finalizer/utils/cctp/l1ToL2.ts +++ b/src/finalizer/utils/cctp/l1ToL2.ts @@ -42,7 +42,8 @@ export async function cctpL1toL2Finalizer( hubPoolClient.chainId, l2SpokePoolClient.chainId, l2SpokePoolClient.chainId, - searchConfig + searchConfig, + true ); const unprocessedMessages = outstandingDeposits.filter( (message) => message.status === "ready" && message.attestation !== "PENDING" diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 06b37e48fb..eb516926fd 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -173,8 +173,19 @@ function getContractInterfaces( } /** - * Gets all tx hashes that may be relevant for CCTP finalization: all txs where HubPool was sending money / messages *and* - * all tx hashes where `DepositForBurn` events happened + * Gets all tx hashes that may be relevant for CCTP finalization. + * This includes: + * - All tx hashes where `DepositForBurn` events happened (for USDC transfers). + * - If `includeHubPoolMessages` is true, all txs where HubPool on `sourceChainId` emitted `MessageRelayed` or `TokensRelayed` events. + * + * @param srcProvider - Provider for the source chain. + * @param sourceChainId - Chain ID where the messages/deposits originated. + * @param destinationChainId - Chain ID where the messages/deposits are targeted. + * @param l2ChainId - Chain ID of the L2 chain involved (can be same as `sourceChainId`), used for CCTP versioning. + * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. If `includeHubPoolMessages` is true, the HubPool address itself is implicitly a sender for its messages. + * @param sourceEventSearchConfig - Configuration for event searching on the source chain. + * @param includeHubPoolMessages - If true, includes messages relayed by the HubPool. **WARNING:** If true, this function assumes a HubPool contract is configured for `sourceChainId`; otherwise, it will throw an error. + * @returns A Set of unique transaction hashes. */ async function getRelevantCCTPTxHashes( srcProvider: RetryProvider, @@ -182,20 +193,27 @@ async function getRelevantCCTPTxHashes( destinationChainId: number, l2ChainId: number, senderAddresses: string[], - sourceEventSearchConfig: EventSearchConfig + sourceEventSearchConfig: EventSearchConfig, + includeHubPoolMessages: boolean ): Promise> { - const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; - if (!hubPoolAddress) { - throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); - } + const txHashesFromHubPool = new Set(); - const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); + if (includeHubPoolMessages) { + const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; + if (!hubPoolAddress) { + throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); + } - const messageRelayedFilter = hubPool.filters.MessageRelayed(); - const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); + const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); - const tokensRelayedFilter = hubPool.filters.TokensRelayed(); - const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + const messageRelayedFilter = hubPool.filters.MessageRelayed(); + const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); + messageRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + + const tokensRelayedFilter = hubPool.filters.TokensRelayed(); + const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + tokensRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + } // Step 2: Get all DepositForBurn events matching the senderAddress and source chain const isCctpV2 = isCctpV2L2ChainId(l2ChainId); @@ -210,24 +228,33 @@ async function getRelevantCCTPTxHashes( await paginatedEventQuery(srcTokenMessenger, eventFilter, sourceEventSearchConfig) ).filter((e) => e.args.destinationDomain === getCctpDomainForChainId(destinationChainId)); - const uniqueTxHashes = new Set([ - ...messageRelayedEvents.map((e) => e.transactionHash), - ...tokensRelayedEvents.map((e) => e.transactionHash), - ...depositForBurnEvents.map((e) => e.transactionHash), - ]); + const uniqueTxHashes = new Set([...txHashesFromHubPool, ...depositForBurnEvents.map((e) => e.transactionHash)]); return uniqueTxHashes; } /** - * Gets all CCTP messages that we can finalize: any CCTP token sends + HubPool message sends to SpokePools + * Gets all CCTP message events (both `DepositForBurn` for token transfers and potentially raw `MessageSent` from HubPool) + * that can be finalized. + * + * It first fetches relevant transaction hashes using `getRelevantCCTPTxHashes` and then processes receipts to extract CCTP events. + * + * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. For HubPool messages, the HubPool address is the sender. + * @param sourceChainId - Chain ID where the messages/deposits originated. + * @param destinationChainId - Chain ID where the messages/deposits are targeted. + * @param l2ChainId - Chain ID of the L2 chain involved, used for CCTP versioning. + * @param sourceEventSearchConfig - Configuration for event searching on the source chain. + * @param includeHubPoolMessages - If true, includes `MessageSent` events relayed by the HubPool on `sourceChainId`. + * **WARNING:** If true, assumes HubPool exists on `sourceChainId`; otherwise, it will error. + * @returns An array of `CCTPMessageEvent` objects. */ async function getCCTPMessageEvents( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig + sourceEventSearchConfig: EventSearchConfig, + includeHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const { tokenMessengerInterface, messageTransmitterInterface } = getContractInterfaces( @@ -243,7 +270,8 @@ async function getCCTPMessageEvents( destinationChainId, l2ChainId, senderAddresses, - sourceEventSearchConfig + sourceEventSearchConfig, + includeHubPoolMessages ); if (uniqueTxHashes.size === 0) { @@ -381,7 +409,8 @@ async function getCCTPMessageEventsWithStatus( sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig + sourceEventSearchConfig: EventSearchConfig, + includeHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const cctpMessages = await getCCTPMessageEvents( @@ -389,7 +418,8 @@ async function getCCTPMessageEventsWithStatus( sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig + sourceEventSearchConfig, + includeHubPoolMessages ); const dstProvider = getCachedProvider(destinationChainId); const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); @@ -422,6 +452,21 @@ async function getCCTPMessageEventsWithStatus( } // same as `getAttestedCCTPMessages`, but filters out all non-deposit CCTP messages +/** + * Fetches attested CCTP messages using `getAttestedCCTPMessages` (with HubPool messages explicitly excluded) + * and then filters them to return only `DepositForBurn` events (i.e., token deposits). + * + * This function is specifically for retrieving CCTP deposits and always calls the underlying + * `getAttestedCCTPMessages` with the `includeHubPoolMessages` flag set to `false`, + * ensuring that only potential `DepositForBurn` related transactions are initially considered. + * + * @param senderAddresses - List of sender addresses to filter the `DepositForBurn` query. + * @param sourceChainId - Chain ID where the Deposit was created. + * @param destinationChainId - Chain ID where the Deposit is being sent to. + * @param l2ChainId - Chain ID of the L2 chain involved in the CCTP deposit. This is used to identify whether the CCTP deposit is V1 or V2. + * @param sourceEventSearchConfig - Configuration for event searching. + * @returns A promise that resolves to an array of `AttestedCCTPDeposit` objects. + */ export async function getAttestedCCTPDeposits( senderAddresses: string[], sourceChainId: number, @@ -434,28 +479,43 @@ export async function getAttestedCCTPDeposits( sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig + sourceEventSearchConfig, + false ); // only return deposit messages return messages.filter((message) => isDepositForBurnEvent(message)) as AttestedCCTPDeposit[]; } /** - * @notice Return all non-finalized CCTPDeposit events with their attestations attached. Attestations will be undefined - * if the attestationn "status" is not "ready". - * @param senderAddresses List of sender addresses to filter the DepositForBurn query - * @param sourceChainId Chain ID where the Deposit was created. - * @param destinationChainid Chain ID where the Deposit is being sent to. - * @param l2ChainId Chain ID of the L2 chain involved in the CCTP deposit. This can be the same as the source chain ID, - * which is the chain where the Deposit was created. This is used to identify whether the CCTP deposit is V1 or V2. - * @param sourceEventSearchConfig + * @notice Returns all non-finalized CCTP messages (both `DepositForBurn` and potentially raw `MessageSent` from HubPool) + * with their attestations attached. Attestations will be undefined if the attestation "status" is not "ready". + * + * If `includeHubPoolMessages` is true, this function will also look for `MessageSent` events that were initiated by the `HubPool` + * contract on the `sourceChainId` and are directed towards a `SpokePool` on the `destinationChainId`. These are considered "raw" + * CCTP messages with custom payloads, distinct from the standard `DepositForBurn` token transfers. + * + * **WARNING:** If `includeHubPoolMessages` is set to `true`, this function critically assumes that a `HubPool` contract address + * is configured and available on the `sourceChainId`. If `sourceChainId` is an L2 (or any chain without a configured HubPool) + * and this flag is `true`, the function will throw an error during the attempt to fetch HubPool-related events. + * + * @param senderAddresses - List of sender addresses to filter `DepositForBurn` events. For `MessageSent` events from HubPool, + * the HubPool address itself acts as the sender and is used implicitly if `includeHubPoolMessages` is true. + * @param sourceChainId - Chain ID where the CCTP messages originated (e.g., an L2 for deposits, or L1 for HubPool messages). + * @param destinationChainId - Chain ID where the CCTP messages are being sent to. + * @param l2ChainId - Chain ID of the L2 chain involved in the CCTP interaction. This is used to determine CCTP versioning (V1 vs V2) + * for contract ABIs and event decoding logic, especially when `sourceChainId` itself might be an L1. + * @param sourceEventSearchConfig - Configuration for event searching on the `sourceChainId`. + * @param includeHubPoolMessages - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. + * Set to `false` if `sourceChainId` does not have a HubPool (e.g., when finalizing L2->L1 CCTP deposits). + * @returns A promise that resolves to an array of `AttestedCCTPMessage` objects. These can be `AttestedCCTPDeposit` or common `AttestedCCTPMessage` types. */ export async function getAttestedCCTPMessages( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig + sourceEventSearchConfig: EventSearchConfig, + includeHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); @@ -464,7 +524,8 @@ export async function getAttestedCCTPMessages( sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig + sourceEventSearchConfig, + includeHubPoolMessages ); // Temporary structs we'll need until we can derive V2 nonce hashes: From 1199e1a11ec5f808fd75265d23e00878f1f1af54 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Fri, 23 May 2025 21:52:15 -0700 Subject: [PATCH 07/21] add todo comments Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index eb516926fd..43b90a747e 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -198,12 +198,17 @@ async function getRelevantCCTPTxHashes( ): Promise> { const txHashesFromHubPool = new Set(); + // TODO: instead of this hack, we can instead utilize `senderAddresses` to exlude HubPool events. + // TODO: we should do that actually. if (includeHubPoolMessages) { - const hubPoolAddress = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]?.address; + const { address: hubPoolAddress } = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]; + // TODO: this seems like incorrect error handling if (!hubPoolAddress) { + // TODO: do we really have to throw here actually? Can't we just skip these events then? throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); } + // TODO: import require("../common/abi/HubPool.json") const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); const messageRelayedFilter = hubPool.filters.MessageRelayed(); From 9afda58e32567b225ca873f4dae70a9c9a766e5e Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Mon, 26 May 2025 17:54:46 -0700 Subject: [PATCH 08/21] address todos Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 43b90a747e..db02820308 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -198,29 +198,30 @@ async function getRelevantCCTPTxHashes( ): Promise> { const txHashesFromHubPool = new Set(); - // TODO: instead of this hack, we can instead utilize `senderAddresses` to exlude HubPool events. - // TODO: we should do that actually. if (includeHubPoolMessages) { - const { address: hubPoolAddress } = CONTRACT_ADDRESSES[sourceChainId]["hubPool"]; - // TODO: this seems like incorrect error handling - if (!hubPoolAddress) { - // TODO: do we really have to throw here actually? Can't we just skip these events then? - throw new Error(`No HubPool address found for chainId: ${sourceChainId}`); + const { address: hubPoolAddress, abi } = CONTRACT_ADDRESSES[sourceChainId]?.hubPool; + if (!isDefined(hubPoolAddress) || !isDefined(abi)) { + throw new Error(`No HubPool address or abi found for chainId: ${sourceChainId}`); } - // TODO: import require("../common/abi/HubPool.json") - const hubPool = new Contract(hubPoolAddress, require("../common/abi/HubPool.json"), srcProvider); + const isHubPoolAmongSenders = senderAddresses.some((senderAddr) => + compareAddressesSimple(senderAddr, hubPoolAddress) + ); + + if (isHubPoolAmongSenders) { + const hubPool = new Contract(hubPoolAddress, abi, srcProvider); - const messageRelayedFilter = hubPool.filters.MessageRelayed(); - const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); - messageRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + const messageRelayedFilter = hubPool.filters.MessageRelayed(); + const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); + messageRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); - const tokensRelayedFilter = hubPool.filters.TokensRelayed(); - const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); - tokensRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + const tokensRelayedFilter = hubPool.filters.TokensRelayed(); + const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + tokensRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + } } - // Step 2: Get all DepositForBurn events matching the senderAddress and source chain + // Step 2: Get all DepositForBurn events matching the senderAddresses and source chain const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const { address, abi } = getCctpTokenMessenger(l2ChainId, sourceChainId); const srcTokenMessenger = new Contract(address, abi, srcProvider); @@ -304,7 +305,6 @@ async function getCCTPMessageEvents( const sourceDomainId = getCctpDomainForChainId(sourceChainId); const destinationDomainId = getCctpDomainForChainId(destinationChainId); - // TODO: how come does `usdcAddress` have type `string` here? What if there's no `sourceChainId` entry? const usdcAddress = TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId]; assert(isDefined(usdcAddress), `USDC address not defined for chain ${sourceChainId}`); From ec02f3788c896af5646b34398f90e908a3398891 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Mon, 26 May 2025 17:56:37 -0700 Subject: [PATCH 09/21] update naming Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index db02820308..dfaf66977d 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -176,15 +176,15 @@ function getContractInterfaces( * Gets all tx hashes that may be relevant for CCTP finalization. * This includes: * - All tx hashes where `DepositForBurn` events happened (for USDC transfers). - * - If `includeHubPoolMessages` is true, all txs where HubPool on `sourceChainId` emitted `MessageRelayed` or `TokensRelayed` events. + * - If `includeTokenlessHubPoolMessages` is true, all txs where HubPool on `sourceChainId` emitted `MessageRelayed` or `TokensRelayed` events. * * @param srcProvider - Provider for the source chain. * @param sourceChainId - Chain ID where the messages/deposits originated. * @param destinationChainId - Chain ID where the messages/deposits are targeted. * @param l2ChainId - Chain ID of the L2 chain involved (can be same as `sourceChainId`), used for CCTP versioning. - * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. If `includeHubPoolMessages` is true, the HubPool address itself is implicitly a sender for its messages. + * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. If `includeTokenlessHubPoolMessages` is true, the HubPool address itself is implicitly a sender for its messages. * @param sourceEventSearchConfig - Configuration for event searching on the source chain. - * @param includeHubPoolMessages - If true, includes messages relayed by the HubPool. **WARNING:** If true, this function assumes a HubPool contract is configured for `sourceChainId`; otherwise, it will throw an error. + * @param includeTokenlessHubPoolMessages - If true, includes messages relayed by the HubPool. **WARNING:** If true, this function assumes a HubPool contract is configured for `sourceChainId`; otherwise, it will throw an error. * @returns A Set of unique transaction hashes. */ async function getRelevantCCTPTxHashes( @@ -194,11 +194,11 @@ async function getRelevantCCTPTxHashes( l2ChainId: number, senderAddresses: string[], sourceEventSearchConfig: EventSearchConfig, - includeHubPoolMessages: boolean + includeTokenlessHubPoolMessages: boolean ): Promise> { const txHashesFromHubPool = new Set(); - if (includeHubPoolMessages) { + if (includeTokenlessHubPoolMessages) { const { address: hubPoolAddress, abi } = CONTRACT_ADDRESSES[sourceChainId]?.hubPool; if (!isDefined(hubPoolAddress) || !isDefined(abi)) { throw new Error(`No HubPool address or abi found for chainId: ${sourceChainId}`); @@ -250,7 +250,7 @@ async function getRelevantCCTPTxHashes( * @param destinationChainId - Chain ID where the messages/deposits are targeted. * @param l2ChainId - Chain ID of the L2 chain involved, used for CCTP versioning. * @param sourceEventSearchConfig - Configuration for event searching on the source chain. - * @param includeHubPoolMessages - If true, includes `MessageSent` events relayed by the HubPool on `sourceChainId`. + * @param includeTokenlessHubPoolMessages - If true, includes `MessageSent` events relayed by the HubPool on `sourceChainId`. * **WARNING:** If true, assumes HubPool exists on `sourceChainId`; otherwise, it will error. * @returns An array of `CCTPMessageEvent` objects. */ @@ -260,7 +260,7 @@ async function getCCTPMessageEvents( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeHubPoolMessages: boolean + includeTokenlessHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const { tokenMessengerInterface, messageTransmitterInterface } = getContractInterfaces( @@ -277,7 +277,7 @@ async function getCCTPMessageEvents( l2ChainId, senderAddresses, sourceEventSearchConfig, - includeHubPoolMessages + includeTokenlessHubPoolMessages ); if (uniqueTxHashes.size === 0) { @@ -415,7 +415,7 @@ async function getCCTPMessageEventsWithStatus( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeHubPoolMessages: boolean + includeTokenlessHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const cctpMessages = await getCCTPMessageEvents( @@ -424,7 +424,7 @@ async function getCCTPMessageEventsWithStatus( destinationChainId, l2ChainId, sourceEventSearchConfig, - includeHubPoolMessages + includeTokenlessHubPoolMessages ); const dstProvider = getCachedProvider(destinationChainId); const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); @@ -462,7 +462,7 @@ async function getCCTPMessageEventsWithStatus( * and then filters them to return only `DepositForBurn` events (i.e., token deposits). * * This function is specifically for retrieving CCTP deposits and always calls the underlying - * `getAttestedCCTPMessages` with the `includeHubPoolMessages` flag set to `false`, + * `getAttestedCCTPMessages` with the `includeTokenlessHubPoolMessages` flag set to `false`, * ensuring that only potential `DepositForBurn` related transactions are initially considered. * * @param senderAddresses - List of sender addresses to filter the `DepositForBurn` query. @@ -495,22 +495,22 @@ export async function getAttestedCCTPDeposits( * @notice Returns all non-finalized CCTP messages (both `DepositForBurn` and potentially raw `MessageSent` from HubPool) * with their attestations attached. Attestations will be undefined if the attestation "status" is not "ready". * - * If `includeHubPoolMessages` is true, this function will also look for `MessageSent` events that were initiated by the `HubPool` + * If `includeTokenlessHubPoolMessages` is true, this function will also look for `MessageSent` events that were initiated by the `HubPool` * contract on the `sourceChainId` and are directed towards a `SpokePool` on the `destinationChainId`. These are considered "raw" * CCTP messages with custom payloads, distinct from the standard `DepositForBurn` token transfers. * - * **WARNING:** If `includeHubPoolMessages` is set to `true`, this function critically assumes that a `HubPool` contract address + * **WARNING:** If `includeTokenlessHubPoolMessages` is set to `true`, this function critically assumes that a `HubPool` contract address * is configured and available on the `sourceChainId`. If `sourceChainId` is an L2 (or any chain without a configured HubPool) * and this flag is `true`, the function will throw an error during the attempt to fetch HubPool-related events. * * @param senderAddresses - List of sender addresses to filter `DepositForBurn` events. For `MessageSent` events from HubPool, - * the HubPool address itself acts as the sender and is used implicitly if `includeHubPoolMessages` is true. + * the HubPool address itself acts as the sender and is used implicitly if `includeTokenlessHubPoolMessages` is true. * @param sourceChainId - Chain ID where the CCTP messages originated (e.g., an L2 for deposits, or L1 for HubPool messages). * @param destinationChainId - Chain ID where the CCTP messages are being sent to. * @param l2ChainId - Chain ID of the L2 chain involved in the CCTP interaction. This is used to determine CCTP versioning (V1 vs V2) * for contract ABIs and event decoding logic, especially when `sourceChainId` itself might be an L1. * @param sourceEventSearchConfig - Configuration for event searching on the `sourceChainId`. - * @param includeHubPoolMessages - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. + * @param includeTokenlessHubPoolMessages - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. * Set to `false` if `sourceChainId` does not have a HubPool (e.g., when finalizing L2->L1 CCTP deposits). * @returns A promise that resolves to an array of `AttestedCCTPMessage` objects. These can be `AttestedCCTPDeposit` or common `AttestedCCTPMessage` types. */ @@ -520,7 +520,7 @@ export async function getAttestedCCTPMessages( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeHubPoolMessages: boolean + includeTokenlessHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); @@ -530,7 +530,7 @@ export async function getAttestedCCTPMessages( destinationChainId, l2ChainId, sourceEventSearchConfig, - includeHubPoolMessages + includeTokenlessHubPoolMessages ); // Temporary structs we'll need until we can derive V2 nonce hashes: From 5824574e62432fce3f4ae1f4c314ac933ca33c06 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Mon, 26 May 2025 21:13:12 -0700 Subject: [PATCH 10/21] polish logic + update v2 indexing logic Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 154 ++++++++++++++++++++++++----------------- 1 file changed, 91 insertions(+), 63 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index dfaf66977d..ebd3aec9f0 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -1,7 +1,7 @@ import { utils } from "@across-protocol/sdk"; import { PUBLIC_NETWORKS, CHAIN_IDs, TOKEN_SYMBOLS_MAP, CCTP_NO_DOMAIN } from "@across-protocol/constants"; import axios from "axios"; -import { Contract, ethers } from "ethers"; +import { Contract, ethers, EventFilter } from "ethers"; import { CONTRACT_ADDRESSES } from "../common"; import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; @@ -21,6 +21,11 @@ type CommonMessageData = { messageHash: string; messageBytes: string; nonceHash: string; + + // Index of cctp message within one txn among other cctp messages. We rely on this to match + // messages to attestation responses from Cirle's API correctly. When we learn to derive v2 + // nonceHash, we can instead move to querying the attestation per message directly + cctpMessageIndex: number; }; // Common data + auxilary data from depositForBurn event type DepositForBurnMessageData = CommonMessageData & { amount: string; mintRecipient: string; burnToken: string }; @@ -36,6 +41,7 @@ type CCTPV2APIAttestation = { cctpVersion: number; }; type CCTPV2APIGetAttestationResponse = { messages: CCTPV2APIAttestation[] }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars function isCctpV2ApiResponse( obj: CCTPAPIGetAttestationResponse | CCTPV2APIGetAttestationResponse ): obj is CCTPV2APIGetAttestationResponse { @@ -196,7 +202,7 @@ async function getRelevantCCTPTxHashes( sourceEventSearchConfig: EventSearchConfig, includeTokenlessHubPoolMessages: boolean ): Promise> { - const txHashesFromHubPool = new Set(); + const txHashesFromHubPool: string[] = []; if (includeTokenlessHubPoolMessages) { const { address: hubPoolAddress, abi } = CONTRACT_ADDRESSES[sourceChainId]?.hubPool; @@ -211,13 +217,14 @@ async function getRelevantCCTPTxHashes( if (isHubPoolAmongSenders) { const hubPool = new Contract(hubPoolAddress, abi, srcProvider); - const messageRelayedFilter = hubPool.filters.MessageRelayed(); - const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); - messageRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + // Create a combined filter for both MessageRelayed and TokensRelayed events + const combinedFilter: EventFilter = { + address: hubPoolAddress, + topics: [[hubPool.interface.getEventTopic("MessageRelayed"), hubPool.interface.getEventTopic("TokensRelayed")]], + }; - const tokensRelayedFilter = hubPool.filters.TokensRelayed(); - const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); - tokensRelayedEvents.forEach((e) => txHashesFromHubPool.add(e.transactionHash)); + const hubPoolEvents = await paginatedEventQuery(hubPool, combinedFilter, sourceEventSearchConfig); + hubPoolEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); } } @@ -309,22 +316,22 @@ async function getCCTPMessageEvents( assert(isDefined(usdcAddress), `USDC address not defined for chain ${sourceChainId}`); const _isRelevantEvent = (event: CCTPMessageEvent): boolean => { - const baseConditions = + const relevant = event.sourceDomain === sourceDomainId && event.destinationDomain === destinationDomainId && senderAddresses.some((sender) => compareAddressesSimple(sender, event.sender)); - return ( - baseConditions && - (!isDepositForBurnEvent(event) || - // if DepositForBurnMessageEvent, check token too - compareAddressesSimple(event.burnToken, usdcAddress)) - ); + if (isDepositForBurnEvent(event)) { + return relevant && compareAddressesSimple(event.burnToken, usdcAddress); + } else { + return relevant; + } }; const relevantEvents: CCTPMessageEvent[] = []; - const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log) => { - const eventData = isCctpV2 ? _decodeCommonMessageDataV1(log) : _decodeCommonMessageDataV2(log); + const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log, cctpMessageIndex: number) => { + const eventData = isCctpV2 ? _decodeCommonMessageDataV2(log) : _decodeCommonMessageDataV1(log); + eventData.cctpMessageIndex = cctpMessageIndex; const logDescription = messageTransmitterInterface.parseLog(log); const spreadArgs = spreadEvent(logDescription.args); const eventName = logDescription.name; @@ -342,28 +349,38 @@ async function getCCTPMessageEvents( }; for (const receipt of receipts) { let lastMessageSentEventIdx = -1; - let i = 0; - for (const log of receipt.logs) { + let cctpMessageIndex = 0; + receipt.logs.forEach((log, i) => { if (_isMessageSentEvent(log)) { if (lastMessageSentEventIdx == -1) { lastMessageSentEventIdx = i; } else { - _addCommonMessageEventIfRelevant(log); + _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx], cctpMessageIndex); + lastMessageSentEventIdx = i; + cctpMessageIndex++; } } else { - const logCctpVersion = _getDepositForBurnVersion(log); - if (logCctpVersion == -1) { + const depositForBurnVersion = _getDepositForBurnVersion(log); + if (depositForBurnVersion == -1) { // Skip non-`DepositForBurn` events - continue; + return; + } + if (lastMessageSentEventIdx == -1) { + throw new Error( + "DepositForBurn event found without corresponding MessageSent event. " + + "Each DepositForBurn event must have a preceding MessageSent event in the same transaction. " + + `Transaction: ${receipt.transactionHash}, DepositForBurn log index: ${i}` + ); } - const matchingCctpVersion = (logCctpVersion == 0 && !isCctpV2) || (logCctpVersion == 1 && isCctpV2); - if (matchingCctpVersion) { - // If DepositForBurn event matches our "desired version", assess if it matches our search parameters + const matchingCCTPVersion = + (depositForBurnVersion == 0 && !isCctpV2) || (depositForBurnVersion == 1 && isCctpV2); + if (matchingCCTPVersion) { const correspondingMessageSentLog = receipt.logs[lastMessageSentEventIdx]; const eventData = isCctpV2 ? _decodeDepositForBurnMessageDataV2(correspondingMessageSentLog) : _decodeDepositForBurnMessageDataV1(correspondingMessageSentLog); + eventData.cctpMessageIndex = cctpMessageIndex; const logDescription = tokenMessengerInterface.parseLog(log); const spreadArgs = spreadEvent(logDescription.args); const eventName = logDescription.name; @@ -393,23 +410,25 @@ async function getCCTPMessageEvents( relevantEvents.push(event); } lastMessageSentEventIdx = -1; + cctpMessageIndex++; } else { - // reset `lastMessageSentEventIdx`, because we found a matching `DepositForBurn` event + // reset `lastMessageSentEventIdx`, because we found a matching `DepositForBurn` event for it, completing the (MessageSent, DepositForBurn) sequence lastMessageSentEventIdx = -1; + // increment `cctpMessageIndex` as this message, albeit not relevant to our current search, will impact the response from Circle's api + cctpMessageIndex++; } } - i += 1; - } + }); // After the loop over all logs, we might have an unmatched `MessageSent` event. Try to add it to `relevantEvents` if (lastMessageSentEventIdx != -1) { - _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx]); + _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx], cctpMessageIndex); } } return relevantEvents; } -async function getCCTPMessageEventsWithStatus( +async function getCCTPMessagesWithStatus( senderAddresses: string[], sourceChainId: number, destinationChainId: number, @@ -418,7 +437,7 @@ async function getCCTPMessageEventsWithStatus( includeTokenlessHubPoolMessages: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); - const cctpMessages = await getCCTPMessageEvents( + const cctpMessageEvents = await getCCTPMessageEvents( senderAddresses, sourceChainId, destinationChainId, @@ -430,7 +449,7 @@ async function getCCTPMessageEventsWithStatus( const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); return await Promise.all( - cctpMessages.map(async (deposit) => { + cctpMessageEvents.map(async (deposit) => { // @dev Currently we have no way to recreate the V2 nonce hash until after we've received the attestation, // so skip this step for V2 since we have no way of knowing whether the message is finalized until after we // query the Attestation API service. @@ -524,7 +543,7 @@ export async function getAttestedCCTPMessages( ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); - const messagesWithStatus = await getCCTPMessageEventsWithStatus( + const messagesWithStatus = await getCCTPMessagesWithStatus( senderAddresses, sourceChainId, destinationChainId, @@ -534,10 +553,25 @@ export async function getAttestedCCTPMessages( ); // Temporary structs we'll need until we can derive V2 nonce hashes: - const txnReceiptHashCount: { [hash: string]: number } = {}; const dstProvider = getCachedProvider(destinationChainId); const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); + const attestationResponses: Map = new Map(); + + // TODO: as our CCTP v2 chain list grows, we might start hitting an API limit of 35 reqs/second here + // Consider implementing rate limiting. For v2 this is more important because we can't tell which messages + // are finalized before hitting the API, meaning that if our lookback window is long enough, it might contain > 35 tx hashes + if (isCctpV2) { + // For v2, we fetch an API response for every txn hash we have. API returns an array of both v1 and v2 attestations + const sourceDomainId = getCctpDomainForChainId(sourceChainId); + const uniqueTxHashes = new Set([...messagesWithStatus.map((message) => message.log.transactionHash)]); + await Promise.all( + Array.from(uniqueTxHashes).map(async (txHash) => { + const attestations = await _fetchAttestationsForTxn(sourceDomainId, txHash, isMainnet); + attestationResponses.set(txHash, attestations); + }) + ); + } const attestedMessages = await Promise.all( messagesWithStatus.map(async (message) => { @@ -545,22 +579,16 @@ export async function getAttestedCCTPMessages( if (message.status === "finalized") { return message; } + // Otherwise, update the deposit's status after loading its attestation. - const { transactionHash } = message.log; - const count = (txnReceiptHashCount[transactionHash] ??= 0); - ++txnReceiptHashCount[transactionHash]; - - const attestation = isCctpV2 - ? await _generateCCTPV2AttestationProof(message.sourceDomain, transactionHash, isMainnet) - : await _generateCCTPAttestationProof(message.messageHash, isMainnet); - - // For V2 we now have the nonceHash and we can query whether the message has been processed. - // We can remove this V2 custom logic once we can derive nonceHashes locally and filter out already "finalized" - // deposits in getCCTPDepositEventsWithStatus(). - if (isCctpV2ApiResponse(attestation)) { - const attestationForDeposit = attestation.messages[count]; + if (isCctpV2) { + const attestations = attestationResponses.get(message.log.transactionHash); + + // TODO: does `message.cctpMessageIndex` work correctly here? + // Pick `messageAttestation` by `cctpMessageIndex` + const messageAttestation = attestations.messages[message.cctpMessageIndex]; const processed = await _hasCCTPMessageBeenProcessed( - attestationForDeposit.eventNonce, + messageAttestation.eventNonce, destinationMessageTransmitter ); if (processed) { @@ -574,13 +602,15 @@ export async function getAttestedCCTPMessages( // For CCTPV2, the message is different than the one emitted in the Deposit because it includes the nonce, and // we need the messageBytes to submit the receiveMessage call successfully. We don't overwrite the messageHash // because its not needed for V2 - nonceHash: attestationForDeposit.eventNonce, - messageBytes: attestationForDeposit.message, - attestation: attestationForDeposit?.attestation, // Will be undefined if status is "pending" - status: _getPendingV2AttestationStatus(attestationForDeposit.status), + nonceHash: messageAttestation.eventNonce, + messageBytes: messageAttestation.message, + attestation: messageAttestation?.attestation, // Will be undefined if status is "pending" + status: _getPendingV2AttestationStatus(messageAttestation.status), }; } } else { + // For v1 messages, fetch attestation by messageHash -> receive a single attestation in response + const attestation = await _fetchV1Attestation(message.messageHash, isMainnet); return { ...message, attestation: attestation?.attestation, // Will be undefined if status is "pending" @@ -596,6 +626,7 @@ function _getPendingV2AttestationStatus(attestation: string): CCTPMessageStatus return attestation === "pending_confirmation" ? "pending" : "ready"; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars function _getPendingAttestationStatus(attestation: string): CCTPMessageStatus { return attestation === "pending_confirmations" ? "pending" : "ready"; } @@ -628,6 +659,7 @@ function _decodeCommonMessageDataV1(message: { data: string }): CommonMessageDat nonceHash, messageHash: ethers.utils.keccak256(messageBytes), messageBytes, + cctpMessageIndex: 0, // to be set separately }; } @@ -650,6 +682,7 @@ function _decodeCommonMessageDataV2(message: { data: string }): CommonMessageDat nonceHash: ethers.constants.HashZero, messageHash: ethers.utils.keccak256(messageBytes), messageBytes, + cctpMessageIndex: 0, // to be set separately }; } @@ -706,10 +739,7 @@ function _decodeDepositForBurnMessageDataV2(message: { data: string }): DepositF * then the proof will be null according to the CCTP dev docs. * @link https://developers.circle.com/stablecoins/reference/getattestation */ -async function _generateCCTPAttestationProof( - messageHash: string, - isMainnet: boolean -): Promise { +async function _fetchV1Attestation(messageHash: string, isMainnet: boolean): Promise { const httpResponse = await axios.get( `https://iris-api${isMainnet ? "" : "-sandbox"}.circle.com/attestations/${messageHash}` ); @@ -718,7 +748,8 @@ async function _generateCCTPAttestationProof( } // @todo: We can pass in a nonceHash here once we know how to recreate the nonceHash. -async function _generateCCTPV2AttestationProof( +// Returns both v1 and v2 attestations +async function _fetchAttestationsForTxn( sourceDomainId: number, transactionHash: string, isMainnet: boolean @@ -728,9 +759,6 @@ async function _generateCCTPV2AttestationProof( isMainnet ? "" : "-sandbox" }.circle.com/v2/messages/${sourceDomainId}?transactionHash=${transactionHash}` ); - // Only leave v2 attestations in the response - const filteredMessages = httpResponse.data.messages.filter((message) => message.cctpVersion === 2); - return { - messages: filteredMessages, - }; + + return httpResponse.data; } From 6b9d64d3db16ef2e64916c5a6a236cd4a6dad277 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Mon, 26 May 2025 21:18:17 -0700 Subject: [PATCH 11/21] address rate-limit concern Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index ebd3aec9f0..136c598368 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -558,19 +558,29 @@ export async function getAttestedCCTPMessages( const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); const attestationResponses: Map = new Map(); - // TODO: as our CCTP v2 chain list grows, we might start hitting an API limit of 35 reqs/second here - // Consider implementing rate limiting. For v2 this is more important because we can't tell which messages - // are finalized before hitting the API, meaning that if our lookback window is long enough, it might contain > 35 tx hashes if (isCctpV2) { // For v2, we fetch an API response for every txn hash we have. API returns an array of both v1 and v2 attestations const sourceDomainId = getCctpDomainForChainId(sourceChainId); - const uniqueTxHashes = new Set([...messagesWithStatus.map((message) => message.log.transactionHash)]); - await Promise.all( - Array.from(uniqueTxHashes).map(async (txHash) => { - const attestations = await _fetchAttestationsForTxn(sourceDomainId, txHash, isMainnet); - attestationResponses.set(txHash, attestations); - }) - ); + const uniqueTxHashes = Array.from(new Set([...messagesWithStatus.map((message) => message.log.transactionHash)])); + + // Circle rate limit is 35 requests / second. To avoid getting banned, batch calls into chunks with 1 second delay between chunks + // For v2, this is actually required because we don't know if message is finalized or not before hitting the API. Therefore as our + // CCTP v2 list of chains grows, we might require more than 35 calls here to fetch all attestations + const chunkSize = 8; + for (let i = 0; i < uniqueTxHashes.length; i += chunkSize) { + const chunk = uniqueTxHashes.slice(i, i + chunkSize); + + await Promise.all( + chunk.map(async (txHash) => { + const attestations = await _fetchAttestationsForTxn(sourceDomainId, txHash, isMainnet); + attestationResponses.set(txHash, attestations); + }) + ); + + if (i + chunkSize < uniqueTxHashes.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } } const attestedMessages = await Promise.all( From 499a34ec46329d4aabadfe85f2128cbd913eefa1 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 09:02:15 -0700 Subject: [PATCH 12/21] fix some testing findings Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 136c598368..f3443f80a2 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -1,7 +1,7 @@ import { utils } from "@across-protocol/sdk"; import { PUBLIC_NETWORKS, CHAIN_IDs, TOKEN_SYMBOLS_MAP, CCTP_NO_DOMAIN } from "@across-protocol/constants"; import axios from "axios"; -import { Contract, ethers, EventFilter } from "ethers"; +import { Contract, ethers } from "ethers"; import { CONTRACT_ADDRESSES } from "../common"; import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; @@ -217,14 +217,13 @@ async function getRelevantCCTPTxHashes( if (isHubPoolAmongSenders) { const hubPool = new Contract(hubPoolAddress, abi, srcProvider); - // Create a combined filter for both MessageRelayed and TokensRelayed events - const combinedFilter: EventFilter = { - address: hubPoolAddress, - topics: [[hubPool.interface.getEventTopic("MessageRelayed"), hubPool.interface.getEventTopic("TokensRelayed")]], - }; + const messageRelayedFilter = hubPool.filters.MessageRelayed(); + const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); + messageRelayedEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); - const hubPoolEvents = await paginatedEventQuery(hubPool, combinedFilter, sourceEventSearchConfig); - hubPoolEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); + const tokensRelayedFilter = hubPool.filters.TokensRelayed(); + const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + tokensRelayedEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); } } @@ -332,9 +331,11 @@ async function getCCTPMessageEvents( const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log, cctpMessageIndex: number) => { const eventData = isCctpV2 ? _decodeCommonMessageDataV2(log) : _decodeCommonMessageDataV1(log); eventData.cctpMessageIndex = cctpMessageIndex; - const logDescription = messageTransmitterInterface.parseLog(log); - const spreadArgs = spreadEvent(logDescription.args); - const eventName = logDescription.name; + const eventFragment = messageTransmitterInterface.getEvent(CCTP_MESSAGE_SENT_TOPIC_HASH); + // underlying lib should throw error if parsing is unsuccessful + const args = messageTransmitterInterface.decodeEventLog(eventFragment, log.data, log.topics); + const spreadArgs = spreadEvent(args); + const eventName = eventFragment.name; const event: CommonMessageEvent = { ...eventData, log: { @@ -683,7 +684,7 @@ function _decodeCommonMessageDataV2(message: { data: string }): CommonMessageDat const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(76, 108))); // recipient 76 bytes32 32 Address to handle message body on destination domain return { - version: 0, + version: 1, sourceDomain, destinationDomain, sender, @@ -712,7 +713,8 @@ function _decodeDepositForBurnMessageDataV1(message: { data: string }): DepositF ...commonDataV1, burnToken, amount: BigNumber.from(amount).toString(), - // override sender and recipient from `DepositForBurn`-specific values + // override sender and recipient from `DepositForBurn`-specific values. This is required because raw sender / recipient for a message like this + // are CCTP's TokenMessenger contracts rather than the addrs sending / receiving tokens sender: sender, recipient: mintRecipient, mintRecipient, @@ -734,7 +736,8 @@ function _decodeDepositForBurnMessageDataV2(message: { data: string }): DepositF ...commonData, burnToken, amount: BigNumber.from(amount).toString(), - // override sender and recipient from `DepositForBurn`-specific values + // override sender and recipient from `DepositForBurn`-specific values. This is required because raw sender / recipient for a message like this + // are CCTP's TokenMessenger contracts rather than the addrs sending / receiving tokens sender: sender, recipient: mintRecipient, mintRecipient, From 4f9c40bc6955ed27ecf259150e219360aff38a07 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 11:51:21 -0700 Subject: [PATCH 13/21] change cctpVersion according to real API outputs, same with determining status Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index f3443f80a2..bf9cd57feb 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -12,7 +12,7 @@ import { Log } from "../interfaces"; import { assert } from "."; type CommonMessageData = { - version: number; // 0 == v1, 1 == v2. This is how Circle assigns them + cctpVersion: number; // 1 == v1, 2 == v2. Circle's docs say 0 and 1, but real endpoints return 1 and 2. :) sourceDomain: number; destinationDomain: number; sender: string; @@ -595,6 +595,8 @@ export async function getAttestedCCTPMessages( if (isCctpV2) { const attestations = attestationResponses.get(message.log.transactionHash); + // TODO: here, find a matching attestation by .message field(a hex-string, careful) + // TODO: does `message.cctpMessageIndex` work correctly here? // Pick `messageAttestation` by `cctpMessageIndex` const messageAttestation = attestations.messages[message.cctpMessageIndex]; @@ -616,7 +618,7 @@ export async function getAttestedCCTPMessages( nonceHash: messageAttestation.eventNonce, messageBytes: messageAttestation.message, attestation: messageAttestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingV2AttestationStatus(messageAttestation.status), + status: _getPendingV2AttestationStatus(messageAttestation), }; } } else { @@ -625,7 +627,7 @@ export async function getAttestedCCTPMessages( return { ...message, attestation: attestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingAttestationStatus(attestation.status), + status: _getPendingAttestationStatus(attestation), }; } }) @@ -633,13 +635,24 @@ export async function getAttestedCCTPMessages( return attestedMessages; } -function _getPendingV2AttestationStatus(attestation: string): CCTPMessageStatus { - return attestation === "pending_confirmation" ? "pending" : "ready"; +function _getPendingV2AttestationStatus(attestation: CCTPV2APIAttestation): CCTPMessageStatus { + if (!isDefined(attestation.attestation)) { + return "pending"; + } else { + return attestation.status === "pending_confirmations" || attestation.attestation === "PENDING" + ? "pending" + : "ready"; + } } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function _getPendingAttestationStatus(attestation: string): CCTPMessageStatus { - return attestation === "pending_confirmations" ? "pending" : "ready"; +function _getPendingAttestationStatus(attestation: CCTPAPIGetAttestationResponse): CCTPMessageStatus { + if (!isDefined(attestation.attestation)) { + return "pending"; + } else { + return attestation.status === "pending_confirmations" || attestation.attestation === "PENDING" + ? "pending" + : "ready"; + } } async function _hasCCTPMessageBeenProcessed(nonceHash: string, contract: ethers.Contract): Promise { @@ -662,7 +675,7 @@ function _decodeCommonMessageDataV1(message: { data: string }): CommonMessageDat const nonceHash = ethers.utils.keccak256(ethers.utils.solidityPack(["uint32", "uint64"], [sourceDomain, nonce])); return { - version: 0, + cctpVersion: 1, sourceDomain, destinationDomain, sender, @@ -684,7 +697,7 @@ function _decodeCommonMessageDataV2(message: { data: string }): CommonMessageDat const recipient = cctpBytes32ToAddress(ethers.utils.hexlify(messageBytesArray.slice(76, 108))); // recipient 76 bytes32 32 Address to handle message body on destination domain return { - version: 1, + cctpVersion: 2, sourceDomain, destinationDomain, sender, From 8152bd7321e21507f9586c899a7cb62e91c6cd4b Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 12:31:00 -0700 Subject: [PATCH 14/21] match message to attestation by comparing the message itself, not relying on cctpMessageIndex Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 62 +++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index bf9cd57feb..d77d45ff44 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -12,7 +12,8 @@ import { Log } from "../interfaces"; import { assert } from "."; type CommonMessageData = { - cctpVersion: number; // 1 == v1, 2 == v2. Circle's docs say 0 and 1, but real endpoints return 1 and 2. :) + // `cctpVersion` is nuanced. cctpVersion returned from API are 1 or 2 (v1 and v2 accordingly). The bytes responsible for a version within the message itself though are 0 or 1 (v1 and v2 accordingly) :\ + cctpVersion: number; sourceDomain: number; destinationDomain: number; sender: string; @@ -595,13 +596,18 @@ export async function getAttestedCCTPMessages( if (isCctpV2) { const attestations = attestationResponses.get(message.log.transactionHash); - // TODO: here, find a matching attestation by .message field(a hex-string, careful) + const matchingAttestation = attestations.messages.find((apiAttestation) => { + return cmpAPIToEventMessageBytesV2(apiAttestation.message, message.messageBytes); + }); + + if (!matchingAttestation) { + throw new Error( + `No matching CCTP V2 attestation found in CTTP API response for message in tx ${message.log.transactionHash}, sourceDomain ${message.sourceDomain}, logIndex ${message.log.logIndex}` + ); + } - // TODO: does `message.cctpMessageIndex` work correctly here? - // Pick `messageAttestation` by `cctpMessageIndex` - const messageAttestation = attestations.messages[message.cctpMessageIndex]; const processed = await _hasCCTPMessageBeenProcessed( - messageAttestation.eventNonce, + matchingAttestation.eventNonce, destinationMessageTransmitter ); if (processed) { @@ -615,10 +621,10 @@ export async function getAttestedCCTPMessages( // For CCTPV2, the message is different than the one emitted in the Deposit because it includes the nonce, and // we need the messageBytes to submit the receiveMessage call successfully. We don't overwrite the messageHash // because its not needed for V2 - nonceHash: messageAttestation.eventNonce, - messageBytes: messageAttestation.message, - attestation: messageAttestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingV2AttestationStatus(messageAttestation), + nonceHash: matchingAttestation.eventNonce, + messageBytes: matchingAttestation.message, + attestation: matchingAttestation?.attestation, // Will be undefined if status is "pending" + status: _getPendingV2AttestationStatus(matchingAttestation), }; } } else { @@ -788,3 +794,39 @@ async function _fetchAttestationsForTxn( return httpResponse.data; } + +// This function compares message we need to finalize with the api response message. It skips the `nonce` part of comparison as it's not set at the time of emitting an on-chain `MessageSent` event +function cmpAPIToEventMessageBytesV2(apiResponseMessage: string, eventMessageBytes: string): boolean { + // Source https://developers.circle.com/stablecoins/message-format + const normalize = (hex: string) => (hex.startsWith("0x") ? hex.substring(2) : hex).toLowerCase(); + const normApiMsg = normalize(apiResponseMessage); + const normLocalMsg = normalize(eventMessageBytes); + + // Bytes [12 .. 44) are ignored. These are responsible for nonceHash, which is set remotely by Circle and not present in an event on source chain + // In hex string (2 chars/byte): + // Chars [0 .. 24) must match. + // Chars [24 .. 88) are ignored. + // Chars 88 onwards must match. + + // Hex strings must be at least 44 bytes long (88 characters). + if (normApiMsg.length < 88 || normLocalMsg.length < 88) { + return false; + } + + if (normApiMsg.length !== normLocalMsg.length) { + return false; + } + + const prefixApi = normApiMsg.substring(0, 24); + const prefixLocal = normLocalMsg.substring(0, 24); + if (prefixApi !== prefixLocal) { + return false; + } + + const suffixApi = normApiMsg.substring(88); + const suffixLocal = normLocalMsg.substring(88); + if (suffixApi !== suffixLocal) { + return false; + } + return true; +} From e7bc64634f30034db40dba5e3c80900d81641c50 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 12:32:26 -0700 Subject: [PATCH 15/21] cleanup cctpMessageIndex logic Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index d77d45ff44..4760d39c0c 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -22,11 +22,6 @@ type CommonMessageData = { messageHash: string; messageBytes: string; nonceHash: string; - - // Index of cctp message within one txn among other cctp messages. We rely on this to match - // messages to attestation responses from Cirle's API correctly. When we learn to derive v2 - // nonceHash, we can instead move to querying the attestation per message directly - cctpMessageIndex: number; }; // Common data + auxilary data from depositForBurn event type DepositForBurnMessageData = CommonMessageData & { amount: string; mintRecipient: string; burnToken: string }; @@ -329,9 +324,8 @@ async function getCCTPMessageEvents( }; const relevantEvents: CCTPMessageEvent[] = []; - const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log, cctpMessageIndex: number) => { + const _addCommonMessageEventIfRelevant = (log: ethers.providers.Log) => { const eventData = isCctpV2 ? _decodeCommonMessageDataV2(log) : _decodeCommonMessageDataV1(log); - eventData.cctpMessageIndex = cctpMessageIndex; const eventFragment = messageTransmitterInterface.getEvent(CCTP_MESSAGE_SENT_TOPIC_HASH); // underlying lib should throw error if parsing is unsuccessful const args = messageTransmitterInterface.decodeEventLog(eventFragment, log.data, log.topics); @@ -351,15 +345,13 @@ async function getCCTPMessageEvents( }; for (const receipt of receipts) { let lastMessageSentEventIdx = -1; - let cctpMessageIndex = 0; receipt.logs.forEach((log, i) => { if (_isMessageSentEvent(log)) { if (lastMessageSentEventIdx == -1) { lastMessageSentEventIdx = i; } else { - _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx], cctpMessageIndex); + _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx]); lastMessageSentEventIdx = i; - cctpMessageIndex++; } } else { const depositForBurnVersion = _getDepositForBurnVersion(log); @@ -382,7 +374,6 @@ async function getCCTPMessageEvents( const eventData = isCctpV2 ? _decodeDepositForBurnMessageDataV2(correspondingMessageSentLog) : _decodeDepositForBurnMessageDataV1(correspondingMessageSentLog); - eventData.cctpMessageIndex = cctpMessageIndex; const logDescription = tokenMessengerInterface.parseLog(log); const spreadArgs = spreadEvent(logDescription.args); const eventName = logDescription.name; @@ -412,18 +403,15 @@ async function getCCTPMessageEvents( relevantEvents.push(event); } lastMessageSentEventIdx = -1; - cctpMessageIndex++; } else { // reset `lastMessageSentEventIdx`, because we found a matching `DepositForBurn` event for it, completing the (MessageSent, DepositForBurn) sequence lastMessageSentEventIdx = -1; - // increment `cctpMessageIndex` as this message, albeit not relevant to our current search, will impact the response from Circle's api - cctpMessageIndex++; } } }); // After the loop over all logs, we might have an unmatched `MessageSent` event. Try to add it to `relevantEvents` if (lastMessageSentEventIdx != -1) { - _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx], cctpMessageIndex); + _addCommonMessageEventIfRelevant(receipt.logs[lastMessageSentEventIdx]); } } @@ -689,7 +677,6 @@ function _decodeCommonMessageDataV1(message: { data: string }): CommonMessageDat nonceHash, messageHash: ethers.utils.keccak256(messageBytes), messageBytes, - cctpMessageIndex: 0, // to be set separately }; } @@ -712,7 +699,6 @@ function _decodeCommonMessageDataV2(message: { data: string }): CommonMessageDat nonceHash: ethers.constants.HashZero, messageHash: ethers.utils.keccak256(messageBytes), messageBytes, - cctpMessageIndex: 0, // to be set separately }; } From d75dc896b4b315caf42127ae8ea76b5d684f449c Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 12:48:28 -0700 Subject: [PATCH 16/21] adjust api <> event comparison function to match reality Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 4760d39c0c..ad797b8184 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -782,37 +782,41 @@ async function _fetchAttestationsForTxn( } // This function compares message we need to finalize with the api response message. It skips the `nonce` part of comparison as it's not set at the time of emitting an on-chain `MessageSent` event +// It also skips finalityThresholdExecuted comparison for the same reason. See https://github.com/circlefin/evm-cctp-contracts/blob/6e7513cdb2bee6bb0cddf331fe972600fc5017c9/src/messages/v2/MessageV2.sol#L89 function cmpAPIToEventMessageBytesV2(apiResponseMessage: string, eventMessageBytes: string): boolean { // Source https://developers.circle.com/stablecoins/message-format const normalize = (hex: string) => (hex.startsWith("0x") ? hex.substring(2) : hex).toLowerCase(); const normApiMsg = normalize(apiResponseMessage); const normLocalMsg = normalize(eventMessageBytes); - // Bytes [12 .. 44) are ignored. These are responsible for nonceHash, which is set remotely by Circle and not present in an event on source chain - // In hex string (2 chars/byte): - // Chars [0 .. 24) must match. - // Chars [24 .. 88) are ignored. - // Chars 88 onwards must match. - - // Hex strings must be at least 44 bytes long (88 characters). - if (normApiMsg.length < 88 || normLocalMsg.length < 88) { + if (normApiMsg.length !== normLocalMsg.length) { return false; } - if (normApiMsg.length !== normLocalMsg.length) { + // Segment 1: Bytes [0 .. 12) + const seg1Api = normApiMsg.substring(0, 24); + const seg1Local = normLocalMsg.substring(0, 24); + if (seg1Api !== seg1Local) { return false; } - const prefixApi = normApiMsg.substring(0, 24); - const prefixLocal = normLocalMsg.substring(0, 24); - if (prefixApi !== prefixLocal) { + // Skip `nonce`: Bytes [12 .. 44) + + // Segment 2: Bytes [44 .. 143) + const seg2Api = normApiMsg.substring(88, 288); + const seg2Local = normLocalMsg.substring(88, 288); + if (seg2Api !== seg2Local) { return false; } - const suffixApi = normApiMsg.substring(88); - const suffixLocal = normLocalMsg.substring(88); - if (suffixApi !== suffixLocal) { + // Skip `finalityThresholdExecuted`: Bytes [144 .. 148) + + // Segment 3: Bytes [148..end) + const seg3Api = normApiMsg.substring(296); + const seg3Local = normLocalMsg.substring(296); + if (seg3Api !== seg3Local) { return false; } + return true; } From 66f1b8ee03ed749c6cd339878dee11ec94b6d581 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 13:10:05 -0700 Subject: [PATCH 17/21] handle edge case where there Circle returns 0x instead of a message for pending attestations ... Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index ad797b8184..0833f67246 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -589,9 +589,23 @@ export async function getAttestedCCTPMessages( }); if (!matchingAttestation) { - throw new Error( - `No matching CCTP V2 attestation found in CTTP API response for message in tx ${message.log.transactionHash}, sourceDomain ${message.sourceDomain}, logIndex ${message.log.logIndex}` + const anyApiMessageIsPending = attestations.messages.some( + (attestation) => _getPendingV2AttestationStatus(attestation) === "pending" ); + + if (anyApiMessageIsPending) { + // If any API message for this transaction is pending, we treat our current message as pending too. + return { + ...message, + status: "pending" as CCTPMessageStatus, + attestation: undefined, + }; + } else { + // No matching attestation found, and no other API messages for this transaction are pending. + throw new Error( + `No matching CCTP V2 attestation found in CCTP API response for message in tx ${message.log.transactionHash}, sourceDomain ${message.sourceDomain}, logIndex ${message.log.logIndex}. Additionally, no other messages from the API for this transaction are in a 'pending' state.` + ); + } } const processed = await _hasCCTPMessageBeenProcessed( From 636604b6fd47eb5dd4e862300f8f784bb75c10c3 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 13:11:22 -0700 Subject: [PATCH 18/21] remove unused fn Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 0833f67246..ae947e8f41 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -37,12 +37,6 @@ type CCTPV2APIAttestation = { cctpVersion: number; }; type CCTPV2APIGetAttestationResponse = { messages: CCTPV2APIAttestation[] }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function isCctpV2ApiResponse( - obj: CCTPAPIGetAttestationResponse | CCTPV2APIGetAttestationResponse -): obj is CCTPV2APIGetAttestationResponse { - return (obj as CCTPV2APIGetAttestationResponse).messages !== undefined; -} export type CCTPMessageStatus = "finalized" | "ready" | "pending"; export type CCTPMessageEvent = CommonMessageEvent | DepositForBurnMessageEvent; export type AttestedCCTPMessage = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string }; From 5b8bda4d3353dcd573dbe5e4711e967197586e39 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 28 May 2025 15:38:37 -0700 Subject: [PATCH 19/21] address PR comments Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 63 +++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index ae947e8f41..1984e4c575 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -172,15 +172,15 @@ function getContractInterfaces( * Gets all tx hashes that may be relevant for CCTP finalization. * This includes: * - All tx hashes where `DepositForBurn` events happened (for USDC transfers). - * - If `includeTokenlessHubPoolMessages` is true, all txs where HubPool on `sourceChainId` emitted `MessageRelayed` or `TokensRelayed` events. + * - If `isSourceHubChain` is true, all txs where HubPool on `sourceChainId` emitted `MessageRelayed` or `TokensRelayed` events. * * @param srcProvider - Provider for the source chain. * @param sourceChainId - Chain ID where the messages/deposits originated. * @param destinationChainId - Chain ID where the messages/deposits are targeted. * @param l2ChainId - Chain ID of the L2 chain involved (can be same as `sourceChainId`), used for CCTP versioning. - * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. If `includeTokenlessHubPoolMessages` is true, the HubPool address itself is implicitly a sender for its messages. + * @param senderAddresses - Addresses that initiated the `DepositForBurn` events. If `isSourceHubChain` is true, the HubPool address itself is implicitly a sender for its messages. * @param sourceEventSearchConfig - Configuration for event searching on the source chain. - * @param includeTokenlessHubPoolMessages - If true, includes messages relayed by the HubPool. **WARNING:** If true, this function assumes a HubPool contract is configured for `sourceChainId`; otherwise, it will throw an error. + * @param isSourceHubChain - If true, includes messages relayed by the HubPool. **WARNING:** If true, this function assumes a HubPool contract is configured for `sourceChainId`; otherwise, it will throw an error. * @returns A Set of unique transaction hashes. */ async function getRelevantCCTPTxHashes( @@ -190,11 +190,11 @@ async function getRelevantCCTPTxHashes( l2ChainId: number, senderAddresses: string[], sourceEventSearchConfig: EventSearchConfig, - includeTokenlessHubPoolMessages: boolean + isSourceHubChain: boolean ): Promise> { const txHashesFromHubPool: string[] = []; - if (includeTokenlessHubPoolMessages) { + if (isSourceHubChain) { const { address: hubPoolAddress, abi } = CONTRACT_ADDRESSES[sourceChainId]?.hubPool; if (!isDefined(hubPoolAddress) || !isDefined(abi)) { throw new Error(`No HubPool address or abi found for chainId: ${sourceChainId}`); @@ -208,11 +208,14 @@ async function getRelevantCCTPTxHashes( const hubPool = new Contract(hubPoolAddress, abi, srcProvider); const messageRelayedFilter = hubPool.filters.MessageRelayed(); - const messageRelayedEvents = await paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig); - messageRelayedEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); - const tokensRelayedFilter = hubPool.filters.TokensRelayed(); - const tokensRelayedEvents = await paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig); + + const [messageRelayedEvents, tokensRelayedEvents] = await Promise.all([ + paginatedEventQuery(hubPool, messageRelayedFilter, sourceEventSearchConfig), + paginatedEventQuery(hubPool, tokensRelayedFilter, sourceEventSearchConfig), + ]); + + messageRelayedEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); tokensRelayedEvents.forEach((e) => txHashesFromHubPool.push(e.transactionHash)); } } @@ -246,7 +249,7 @@ async function getRelevantCCTPTxHashes( * @param destinationChainId - Chain ID where the messages/deposits are targeted. * @param l2ChainId - Chain ID of the L2 chain involved, used for CCTP versioning. * @param sourceEventSearchConfig - Configuration for event searching on the source chain. - * @param includeTokenlessHubPoolMessages - If true, includes `MessageSent` events relayed by the HubPool on `sourceChainId`. + * @param isSourceHubChain - If true, includes `MessageSent` events relayed by the HubPool on `sourceChainId`. * **WARNING:** If true, assumes HubPool exists on `sourceChainId`; otherwise, it will error. * @returns An array of `CCTPMessageEvent` objects. */ @@ -256,7 +259,7 @@ async function getCCTPMessageEvents( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeTokenlessHubPoolMessages: boolean + isSourceHubChain: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const { tokenMessengerInterface, messageTransmitterInterface } = getContractInterfaces( @@ -273,7 +276,7 @@ async function getCCTPMessageEvents( l2ChainId, senderAddresses, sourceEventSearchConfig, - includeTokenlessHubPoolMessages + isSourceHubChain ); if (uniqueTxHashes.size === 0) { @@ -418,7 +421,7 @@ async function getCCTPMessagesWithStatus( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeTokenlessHubPoolMessages: boolean + isSourceHubChain: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const cctpMessageEvents = await getCCTPMessageEvents( @@ -427,7 +430,7 @@ async function getCCTPMessagesWithStatus( destinationChainId, l2ChainId, sourceEventSearchConfig, - includeTokenlessHubPoolMessages + isSourceHubChain ); const dstProvider = getCachedProvider(destinationChainId); const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); @@ -465,7 +468,7 @@ async function getCCTPMessagesWithStatus( * and then filters them to return only `DepositForBurn` events (i.e., token deposits). * * This function is specifically for retrieving CCTP deposits and always calls the underlying - * `getAttestedCCTPMessages` with the `includeTokenlessHubPoolMessages` flag set to `false`, + * `getAttestedCCTPMessages` with the `isSourceHubChain` flag set to `false`, * ensuring that only potential `DepositForBurn` related transactions are initially considered. * * @param senderAddresses - List of sender addresses to filter the `DepositForBurn` query. @@ -498,22 +501,22 @@ export async function getAttestedCCTPDeposits( * @notice Returns all non-finalized CCTP messages (both `DepositForBurn` and potentially raw `MessageSent` from HubPool) * with their attestations attached. Attestations will be undefined if the attestation "status" is not "ready". * - * If `includeTokenlessHubPoolMessages` is true, this function will also look for `MessageSent` events that were initiated by the `HubPool` + * If `isSourceHubChain` is true, this function will also look for `MessageSent` events that were initiated by the `HubPool` * contract on the `sourceChainId` and are directed towards a `SpokePool` on the `destinationChainId`. These are considered "raw" * CCTP messages with custom payloads, distinct from the standard `DepositForBurn` token transfers. * - * **WARNING:** If `includeTokenlessHubPoolMessages` is set to `true`, this function critically assumes that a `HubPool` contract address + * **WARNING:** If `isSourceHubChain` is set to `true`, this function critically assumes that a `HubPool` contract address * is configured and available on the `sourceChainId`. If `sourceChainId` is an L2 (or any chain without a configured HubPool) * and this flag is `true`, the function will throw an error during the attempt to fetch HubPool-related events. * * @param senderAddresses - List of sender addresses to filter `DepositForBurn` events. For `MessageSent` events from HubPool, - * the HubPool address itself acts as the sender and is used implicitly if `includeTokenlessHubPoolMessages` is true. + * the HubPool address itself acts as the sender and is used implicitly if `isSourceHubChain` is true. * @param sourceChainId - Chain ID where the CCTP messages originated (e.g., an L2 for deposits, or L1 for HubPool messages). * @param destinationChainId - Chain ID where the CCTP messages are being sent to. * @param l2ChainId - Chain ID of the L2 chain involved in the CCTP interaction. This is used to determine CCTP versioning (V1 vs V2) * for contract ABIs and event decoding logic, especially when `sourceChainId` itself might be an L1. * @param sourceEventSearchConfig - Configuration for event searching on the `sourceChainId`. - * @param includeTokenlessHubPoolMessages - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. + * @param isSourceHubChain - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. * Set to `false` if `sourceChainId` does not have a HubPool (e.g., when finalizing L2->L1 CCTP deposits). * @returns A promise that resolves to an array of `AttestedCCTPMessage` objects. These can be `AttestedCCTPDeposit` or common `AttestedCCTPMessage` types. */ @@ -523,7 +526,7 @@ export async function getAttestedCCTPMessages( destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig, - includeTokenlessHubPoolMessages: boolean + isSourceHubChain: boolean ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); @@ -533,7 +536,7 @@ export async function getAttestedCCTPMessages( destinationChainId, l2ChainId, sourceEventSearchConfig, - includeTokenlessHubPoolMessages + isSourceHubChain ); // Temporary structs we'll need until we can derive V2 nonce hashes: @@ -584,7 +587,7 @@ export async function getAttestedCCTPMessages( if (!matchingAttestation) { const anyApiMessageIsPending = attestations.messages.some( - (attestation) => _getPendingV2AttestationStatus(attestation) === "pending" + (attestation) => _getPendingAttestationStatus(attestation) === "pending" ); if (anyApiMessageIsPending) { @@ -620,7 +623,7 @@ export async function getAttestedCCTPMessages( nonceHash: matchingAttestation.eventNonce, messageBytes: matchingAttestation.message, attestation: matchingAttestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingV2AttestationStatus(matchingAttestation), + status: _getPendingAttestationStatus(matchingAttestation), }; } } else { @@ -637,17 +640,9 @@ export async function getAttestedCCTPMessages( return attestedMessages; } -function _getPendingV2AttestationStatus(attestation: CCTPV2APIAttestation): CCTPMessageStatus { - if (!isDefined(attestation.attestation)) { - return "pending"; - } else { - return attestation.status === "pending_confirmations" || attestation.attestation === "PENDING" - ? "pending" - : "ready"; - } -} - -function _getPendingAttestationStatus(attestation: CCTPAPIGetAttestationResponse): CCTPMessageStatus { +function _getPendingAttestationStatus( + attestation: CCTPV2APIAttestation | CCTPAPIGetAttestationResponse +): CCTPMessageStatus { if (!isDefined(attestation.attestation)) { return "pending"; } else { From 9cc365f550b50b7fe29c032a64a5bb62a035295e Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Thu, 29 May 2025 10:47:11 -0700 Subject: [PATCH 20/21] change RetryProvider usage to Provider Signed-off-by: Ihor Farion --- src/utils/CCTPUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 1984e4c575..41ef4e9727 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -6,10 +6,10 @@ import { CONTRACT_ADDRESSES } from "../common"; import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; -import { RetryProvider, getCachedProvider } from "./ProviderUtils"; +import { getCachedProvider } from "./ProviderUtils"; import { EventSearchConfig, paginatedEventQuery, spreadEvent } from "./EventUtils"; import { Log } from "../interfaces"; -import { assert } from "."; +import { assert, Provider } from "."; type CommonMessageData = { // `cctpVersion` is nuanced. cctpVersion returned from API are 1 or 2 (v1 and v2 accordingly). The bytes responsible for a version within the message itself though are 0 or 1 (v1 and v2 accordingly) :\ @@ -184,7 +184,7 @@ function getContractInterfaces( * @returns A Set of unique transaction hashes. */ async function getRelevantCCTPTxHashes( - srcProvider: RetryProvider, + srcProvider: Provider, sourceChainId: number, destinationChainId: number, l2ChainId: number, From ab04762f794d4adf98d8a16e3b029895870fbe11 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Thu, 29 May 2025 11:10:47 -0700 Subject: [PATCH 21/21] change isSourceHubChain from function arg to calculated param Signed-off-by: Ihor Farion --- src/finalizer/utils/cctp/l1ToL2.ts | 3 +-- src/utils/CCTPUtils.ts | 9 +++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/finalizer/utils/cctp/l1ToL2.ts b/src/finalizer/utils/cctp/l1ToL2.ts index e8f059790f..bf8ffa4cc4 100644 --- a/src/finalizer/utils/cctp/l1ToL2.ts +++ b/src/finalizer/utils/cctp/l1ToL2.ts @@ -42,8 +42,7 @@ export async function cctpL1toL2Finalizer( hubPoolClient.chainId, l2SpokePoolClient.chainId, l2SpokePoolClient.chainId, - searchConfig, - true + searchConfig ); const unprocessedMessages = outstandingDeposits.filter( (message) => message.status === "ready" && message.attestation !== "PENDING" diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index 41ef4e9727..623089ad2f 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -490,8 +490,7 @@ export async function getAttestedCCTPDeposits( sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig, - false + sourceEventSearchConfig ); // only return deposit messages return messages.filter((message) => isDepositForBurnEvent(message)) as AttestedCCTPDeposit[]; @@ -516,8 +515,6 @@ export async function getAttestedCCTPDeposits( * @param l2ChainId - Chain ID of the L2 chain involved in the CCTP interaction. This is used to determine CCTP versioning (V1 vs V2) * for contract ABIs and event decoding logic, especially when `sourceChainId` itself might be an L1. * @param sourceEventSearchConfig - Configuration for event searching on the `sourceChainId`. - * @param isSourceHubChain - If true, the function will additionally search for `MessageSent` events emitted by the HubPool on `sourceChainId`. - * Set to `false` if `sourceChainId` does not have a HubPool (e.g., when finalizing L2->L1 CCTP deposits). * @returns A promise that resolves to an array of `AttestedCCTPMessage` objects. These can be `AttestedCCTPDeposit` or common `AttestedCCTPMessage` types. */ export async function getAttestedCCTPMessages( @@ -525,11 +522,11 @@ export async function getAttestedCCTPMessages( sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig, - isSourceHubChain: boolean + sourceEventSearchConfig: EventSearchConfig ): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); + const isSourceHubChain = [CHAIN_IDs.MAINNET, CHAIN_IDs.SEPOLIA].includes(sourceChainId); const messagesWithStatus = await getCCTPMessagesWithStatus( senderAddresses, sourceChainId,