diff --git a/src/common/abi/HubPool.json b/src/common/abi/HubPool.json index 6b6081a68..f29c9bbc0 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/finalizer/utils/cctp/l1ToL2.ts b/src/finalizer/utils/cctp/l1ToL2.ts index ea756c0b7..bf8ffa4cc 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 8273a4f82..db20bf409 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 691ee073b..78e08ca35 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -7,21 +7,27 @@ import { BigNumber } from "./BNUtils"; import { bnZero, compareAddressesSimple } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; import { getCachedProvider } from "./ProviderUtils"; -import { EventSearchConfig, paginatedEventQuery } from "./EventUtils"; -import { findLast } from "lodash"; +import { EventSearchConfig, paginatedEventQuery, spreadEvent } from "./EventUtils"; import { Log } from "../interfaces"; +import { assert, Provider } from "."; -type CCTPDeposit = { - nonceHash: string; - amount: string; +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) :\ + cctpVersion: number; sourceDomain: number; destinationDomain: number; sender: string; recipient: string; + messageHash: string; messageBytes: string; + nonceHash: string; }; -type CCTPDepositEvent = CCTPDeposit & { log: Log }; +// 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 CCTPAPIGetAttestationResponse = { status: string; attestation: string }; type CCTPV2APIAttestation = { status: string; @@ -31,15 +37,21 @@ type CCTPV2APIAttestation = { cctpVersion: number; }; type CCTPV2APIGetAttestationResponse = { messages: CCTPV2APIAttestation[] }; -function isCctpV2ApiResponse( - obj: CCTPAPIGetAttestationResponse | CCTPV2APIGetAttestationResponse -): obj is CCTPV2APIGetAttestationResponse { - 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 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; +} const CCTP_MESSAGE_SENT_TOPIC_HASH = ethers.utils.id("MessageSent(bytes)"); +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,19 +126,105 @@ export function getCctpDomainForChainId(chainId: number): number { return cctpDomain; } -async function getCCTPDepositEvents( - senderAddresses: string[], +/** + * 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, + 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 === expectedDepositForBurnTopic, + `DepositForBurn topic mismatch: expected ${expectedDepositForBurnTopic}, got ${depositForBurnTopic}` + ); + + const messageSentTopic = messageTransmitterInterface.getEventTopic("MessageSent"); + assert( + messageSentTopic === CCTP_MESSAGE_SENT_TOPIC_HASH, + `MessageSent topic mismatch: expected ${CCTP_MESSAGE_SENT_TOPIC_HASH}, got ${messageSentTopic}` + ); + + return { + tokenMessengerInterface, + messageTransmitterInterface, + }; +} + +/** + * Gets all tx hashes that may be relevant for CCTP finalization. + * This includes: + * - All tx hashes where `DepositForBurn` events happened (for USDC transfers). + * - 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 `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 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( + srcProvider: Provider, sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig -): Promise { - const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + senderAddresses: string[], + sourceEventSearchConfig: EventSearchConfig, + isSourceHubChain: boolean +): Promise> { + const txHashesFromHubPool: string[] = []; - // Step 1: Get all DepositForBurn events matching the senderAddress and source chain. - const srcProvider = getCachedProvider(sourceChainId); + 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}`); + } + + const isHubPoolAmongSenders = senderAddresses.some((senderAddr) => + compareAddressesSimple(senderAddr, hubPoolAddress) + ); + + if (isHubPoolAmongSenders) { + const hubPool = new Contract(hubPoolAddress, abi, srcProvider); + + const messageRelayedFilter = hubPool.filters.MessageRelayed(); + const tokensRelayedFilter = hubPool.filters.TokensRelayed(); + + 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)); + } + } + + // 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); + const eventFilterParams = isCctpV2 ? [TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses] : [undefined, TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId], undefined, senderAddresses]; @@ -135,91 +233,248 @@ async function getCCTPDepositEvents( 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 uniqueTxHashes = new Set([...txHashesFromHubPool, ...depositForBurnEvents.map((e) => e.transactionHash)]); + + return uniqueTxHashes; +} + +/** + * 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 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. + */ +async function getCCTPMessageEvents( + senderAddresses: string[], + sourceChainId: number, + destinationChainId: number, + l2ChainId: number, + sourceEventSearchConfig: EventSearchConfig, + isSourceHubChain: boolean +): Promise { + const isCctpV2 = isCctpV2L2ChainId(l2ChainId); + const { tokenMessengerInterface, messageTransmitterInterface } = getContractInterfaces( + l2ChainId, + sourceChainId, + isCctpV2 + ); + + const srcProvider = getCachedProvider(sourceChainId); + const uniqueTxHashes = await getRelevantCCTPTxHashes( + srcProvider, + sourceChainId, + destinationChainId, + l2ChainId, + senderAddresses, + sourceEventSearchConfig, + isSourceHubChain ); - 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 (uniqueTxHashes.size === 0) { + return []; + } + + const receipts = await Promise.all(Array.from(uniqueTxHashes).map((hash) => srcProvider.getTransactionReceipt(hash))); + + const sourceDomainId = getCctpDomainForChainId(sourceChainId); + const destinationDomainId = getCctpDomainForChainId(destinationChainId); + const usdcAddress = TOKEN_SYMBOLS_MAP.USDC.addresses[sourceChainId]; + assert(isDefined(usdcAddress), `USDC address not defined for chain ${sourceChainId}`); + + const relevantEvents: CCTPMessageEvent[] = []; + for (const receipt of receipts) { + const relevantEventsFromReceipt = getRelevantCCTPEventsFromReceipt( + receipt, + isCctpV2, + tokenMessengerInterface, + messageTransmitterInterface, + sourceDomainId, + destinationDomainId, + usdcAddress, + senderAddresses ); - if (!isDefined(_messageSentEvent)) { - throw new Error( - `Could not find MessageSent event for DepositForBurn event in ${txnHash} at log index ${logIndex}` - ); + relevantEvents.push(...relevantEventsFromReceipt); + } + + return relevantEvents; +} + +function getRelevantCCTPEventsFromReceipt( + receipt: ethers.providers.TransactionReceipt, + isCctpV2: boolean, + tokenMessengerInterface: ethers.utils.Interface, + messageTransmitterInterface: ethers.utils.Interface, + sourceDomainId: number, + destinationDomainId: number, + usdcAddress: string, + senderAddresses: string[] +): CCTPMessageEvent[] { + const relevantEvents: CCTPMessageEvent[] = []; + + // --- Define helper functions --- // + // expects 0 for v1, 1 for v2, in accordance with `_getMessageSentVersion` and `_getDepositForBurnVersion` + const _isMatchingCCTPVersion = (logVersion: number): boolean => { + return (logVersion == 0 && !isCctpV2) || (logVersion == 1 && isCctpV2); + }; + + // Checks if event is for desired source / destination domains and addresses + const _isRelevantEvent = (event: CCTPMessageEvent): boolean => { + const relevant = + event.sourceDomain === sourceDomainId && + event.destinationDomain === destinationDomainId && + senderAddresses.some((sender) => compareAddressesSimple(sender, event.sender)); + + if (isDepositForBurnEvent(event)) { + return relevant && compareAddressesSimple(event.burnToken, usdcAddress); + } else { + return relevant; } - return _messageSentEvent; - }); + }; + + // Decodes `MessageSent` log params and tries to add `CommonMessageEvent` to `relevantEvents` + const _addMessageSentEventIfRelevant = (log: ethers.providers.Log) => { + const eventData = isCctpV2 ? _decodeCommonMessageDataV2(log) : _decodeCommonMessageDataV1(log); + const eventFragment = messageTransmitterInterface.getEvent(CCTP_MESSAGE_SENT_TOPIC_HASH); + const args = messageTransmitterInterface.decodeEventLog(eventFragment, log.data, log.topics); + const event: CommonMessageEvent = { + ...eventData, + log: { + ...log, + event: eventFragment.name, + args: spreadEvent(args), + }, + }; + if (_isRelevantEvent(event)) { + relevantEvents.push(event); + } + }; + + // Decodes `MessageSent` + `DepositForBurn` log params and tries to add `DepositForBurnMessageEvent` to `relevantEvents` + const _addDepositForBurnMessageEvent = ( + messageSentLog: ethers.providers.Log, + depositForBurnLog: ethers.providers.Log + ) => { + const eventData = isCctpV2 + ? _decodeDepositForBurnMessageDataV2(messageSentLog) + : _decodeDepositForBurnMessageDataV1(messageSentLog); + const logDescription = tokenMessengerInterface.parseLog(depositForBurnLog); + const spreadArgs = spreadEvent(logDescription.args); - // 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. + // Verify that args decoded from raw `MessageSent` event match the values reported with `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) + !compareAddressesSimple(eventData.sender, spreadArgs.depositor) || + !compareAddressesSimple(eventData.recipient, cctpBytes32ToAddress(spreadArgs.mintRecipient)) || + !BigNumber.from(eventData.amount).eq(spreadArgs.amount) ) { - const { transactionHash: txnHash, logIndex } = _depositEvent; + const { transactionHash: txnHash, logIndex } = depositForBurnLog; throw new Error( - `Decoded message at log index ${_messageSentEvent.logIndex}` + + `Decoded message at log index ${messageSentLog.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, + + const event: DepositForBurnMessageEvent = { + ...eventData, + log: { + ...depositForBurnLog, + event: logDescription.name, + args: spreadArgs, + }, }; + if (_isRelevantEvent(event)) { + relevantEvents.push(event); + } + }; + // --- END OF Define helper functions --- // + + /* + For this receipt, go through all logs one-by-one. + Identify: + 1. Pairs: [MessageSent + DepositForBurn] == *cctp token transfers* we need to finalze + 2. Single MessageSent events(either not followed by any other cctp event we're tracking, or followed by another MessageSent event in the logs array, not necessarily consecutively) + == *cctp crosschain message* we need to finalize + */ + // Index in `receipt.logs` of a `MessageSent` log with matching CCTP version + let lastMessageSentIdx = -1; + receipt.logs.forEach((log, i) => { + // --- Try to parse log as `MessageSent` --- // + const messageSentVersion = _getMessageSentVersion(log); + const isMessageSentEvent = messageSentVersion != -1; + if (isMessageSentEvent) { + if (_isMatchingCCTPVersion(messageSentVersion)) { + _addMessageSentEventIfRelevant(receipt.logs[lastMessageSentIdx]); + lastMessageSentIdx = i; + } + return; + } + // --- END OF Try to parse log as `MessageSent` --- // + + // --- Try to parse log as `DepositForBurn` --- // + const depositForBurnVersion = _getDepositForBurnVersion(log); + const isDepositForBurnEvent = depositForBurnVersion != -1; + if (!isDepositForBurnEvent) { + // return early if event is neither `MessageSent` event nor `DepositForBurn` + return; + } + + if (_isMatchingCCTPVersion(depositForBurnVersion)) { + if (lastMessageSentIdx == -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}` + ); + } + + // @dev We found a [MessageSent + DepositForBurn] pair! *cctp token transfers* we need to finalze + const correspondingMessageSentLog = receipt.logs[lastMessageSentIdx]; + _addDepositForBurnMessageEvent(correspondingMessageSentLog, log); + + lastMessageSentIdx = -1; + } + // --- END OF Try to parse log as `DepositForBurn` --- // }); - return decodedMessages; + // After the loop over all logs, we might have an unpaired `MessageSent` event. Try to add it to `relevantEvents` + if (lastMessageSentIdx != -1) { + _addMessageSentEventIfRelevant(receipt.logs[lastMessageSentIdx]); + } + + return relevantEvents; } -async function getCCTPDepositEventsWithStatus( +async function getCCTPMessagesWithStatus( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, - sourceEventSearchConfig: EventSearchConfig -): Promise { + sourceEventSearchConfig: EventSearchConfig, + isSourceHubChain: boolean +): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); - const deposits = await getCCTPDepositEvents( + const cctpMessageEvents = await getCCTPMessageEvents( senderAddresses, sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig + sourceEventSearchConfig, + isSourceHubChain ); const dstProvider = getCachedProvider(destinationChainId); const { address, abi } = getCctpMessageTransmitter(l2ChainId, destinationChainId); const destinationMessageTransmitter = new ethers.Contract(address, abi, dstProvider); return await Promise.all( - deposits.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. @@ -245,98 +500,191 @@ async function getCCTPDepositEventsWithStatus( ); } +// same as `getAttestedCCTPMessages`, but filters out all non-deposit CCTP messages /** - * @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 + * 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 `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. + * @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 getAttestationsForCCTPDepositEvents( +export async function getAttestedCCTPDeposits( senderAddresses: string[], sourceChainId: number, destinationChainId: number, l2ChainId: number, sourceEventSearchConfig: EventSearchConfig -): Promise { +): Promise { + const messages = await getAttestedCCTPMessages( + senderAddresses, + sourceChainId, + destinationChainId, + l2ChainId, + sourceEventSearchConfig + ); + // only return deposit messages + return messages.filter((message) => isDepositForBurnEvent(message)) as AttestedCCTPDeposit[]; +} + +/** + * @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 `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 `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 `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`. + * @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 +): Promise { const isCctpV2 = isCctpV2L2ChainId(l2ChainId); const isMainnet = utils.chainIsProd(destinationChainId); - const depositsWithStatus = await getCCTPDepositEventsWithStatus( + const isSourceHubChain = [CHAIN_IDs.MAINNET, CHAIN_IDs.SEPOLIA].includes(sourceChainId); + const messagesWithStatus = await getCCTPMessagesWithStatus( senderAddresses, sourceChainId, destinationChainId, l2ChainId, - sourceEventSearchConfig + sourceEventSearchConfig, + isSourceHubChain ); // 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(); - const attestedDeposits = await Promise.all( - depositsWithStatus.map(async (deposit) => { + 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 = 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( + 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 count = (txnReceiptHashCount[transactionHash] ??= 0); - ++txnReceiptHashCount[transactionHash]; - - const attestation = isCctpV2 - ? await _generateCCTPV2AttestationProof(deposit.sourceDomain, transactionHash, isMainnet) - : await _generateCCTPAttestationProof(deposit.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); + + const matchingAttestation = attestations.messages.find((apiAttestation) => { + return cmpAPIToEventMessageBytesV2(apiAttestation.message, message.messageBytes); + }); + + if (!matchingAttestation) { + const anyApiMessageIsPending = attestations.messages.some( + (attestation) => _getPendingAttestationStatus(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( - attestationForDeposit.eventNonce, + matchingAttestation.eventNonce, destinationMessageTransmitter ); 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 - nonceHash: attestationForDeposit.eventNonce, - messageBytes: attestationForDeposit.message, - attestation: attestationForDeposit?.attestation, // Will be undefined if status is "pending" - status: _getPendingV2AttestationStatus(attestationForDeposit.status), + nonceHash: matchingAttestation.eventNonce, + messageBytes: matchingAttestation.message, + attestation: matchingAttestation?.attestation, // Will be undefined if status is "pending" + status: _getPendingAttestationStatus(matchingAttestation), }; } } else { + // For v1 messages, fetch attestation by messageHash -> receive a single attestation in response + const attestation = await _fetchV1Attestation(message.messageHash, isMainnet); return { - ...deposit, + ...message, attestation: attestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingAttestationStatus(attestation.status), + status: _getPendingAttestationStatus(attestation), }; } }) ); - return attestedDeposits; -} - -function _getPendingV2AttestationStatus(attestation: string): CCTPMessageStatus { - return attestation === "pending_confirmation" ? "pending" : "ready"; + return attestedMessages; } -function _getPendingAttestationStatus(attestation: string): CCTPMessageStatus { - return attestation === "pending_confirmations" ? "pending" : "ready"; +function _getPendingAttestationStatus( + attestation: CCTPV2APIAttestation | 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 { @@ -345,55 +693,100 @@ async function _hasCCTPMessageBeenProcessed(nonceHash: string, contract: ethers. return (resultingCall ?? bnZero).toNumber() === 1; } -function _decodeCCTPV1Message(message: { data: string }): CCTPDeposit { +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])); // - 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) + const nonceHash = ethers.utils.keccak256(ethers.utils.solidityPack(["uint32", "uint64"], [sourceDomain, nonce])); return { - nonceHash, - amount: BigNumber.from(amount).toString(), + cctpVersion: 1, sourceDomain, destinationDomain, sender, recipient, + nonceHash, messageHash: ethers.utils.keccak256(messageBytes), messageBytes, }; } -function _decodeCCTPV2Message(message: { data: string; transactionHash: string; logIndex: number }): CCTPDeposit { +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 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) + 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 { - // 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(), + cctpVersion: 2, 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. 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, + }; +} + +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. 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, + }; +} + /** * 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. @@ -402,10 +795,7 @@ function _decodeCCTPV2Message(message: { data: string; transactionHash: string; * 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}` ); @@ -414,7 +804,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 @@ -424,9 +815,71 @@ 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; +} + +// 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); + + if (normApiMsg.length !== normLocalMsg.length) { + return false; + } + + // Segment 1: Bytes [0 .. 12) + const seg1Api = normApiMsg.substring(0, 24); + const seg1Local = normLocalMsg.substring(0, 24); + if (seg1Api !== seg1Local) { + return false; + } + + // 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; + } + + // 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; } + +// returns 0 for v1 `MessageSent` event, 1 for v2, -1 for other events +const _getMessageSentVersion = (log: ethers.providers.Log): number => { + if (log.topics[0] !== CCTP_MESSAGE_SENT_TOPIC_HASH) { + return -1; + } + // v1 and v2 have the same topic hash, so we have to do a bit of decoding here to understand the version + const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], log.data)[0]; + // Source: https://developers.circle.com/stablecoins/message-format + const version = parseInt(messageBytes.slice(2, 10), 16); // read version: first 4 bytes (skipping '0x') + return version; +}; + +// 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; + } +};