diff --git a/src/features/debugger/debugMessage.ts b/src/features/debugger/debugMessage.ts index 59a39ae6..422bf049 100644 --- a/src/features/debugger/debugMessage.ts +++ b/src/features/debugger/debugMessage.ts @@ -13,9 +13,9 @@ import { IRegistry } from '@hyperlane-xyz/registry'; import { ChainMap, ChainMetadata, + isProxy, MAILBOX_VERSION, MultiProtocolProvider, - isProxy, proxyImplementation, } from '@hyperlane-xyz/sdk'; import { @@ -30,7 +30,7 @@ import { import { Message } from '../../types'; import { logger } from '../../utils/logger'; import { getMailboxAddress } from '../chains/utils'; -import { isIcaMessage, tryDecodeIcaBody, tryFetchIcaAddress } from '../messages/ica'; +import { computeIcaAddress, decodeIcaBody, IcaMessageType, isIcaMessage } from '../messages/ica'; import { debugIgnoredChains } from '../../consts/config'; import { GasPayment, IsmModuleTypes, MessageDebugResult, MessageDebugStatus } from './types'; @@ -207,12 +207,19 @@ async function debugMessageDelivery( }; } - const icaCallErr = await tryDebugIcaMsg(sender, recipient, body, originDomain, destProvider); - if (icaCallErr) { + const icaDebugResult = await tryDebugIcaMsg( + sender, + recipient, + body, + originDomain, + destProvider, + ); + if (icaDebugResult) { return { status: MessageDebugStatus.IcaCallFailure, - description: icaCallErr, + description: `ICA call ${icaDebugResult.failedCallIndex + 1} of ${icaDebugResult.totalCalls} cannot be executed. ${icaDebugResult.errorReason}`, calldataDetails, + icaDetails: icaDebugResult, }; } @@ -389,35 +396,68 @@ async function tryCheckBytecodeHandle(provider: Provider, recipientAddress: stri } } +interface IcaDebugResult { + failedCallIndex: number; + totalCalls: number; + errorReason: string; +} + async function tryDebugIcaMsg( sender: Address, recipient: Address, body: string, originDomainId: DomainId, destinationProvider: Provider, -) { +): Promise { if (!isIcaMessage({ sender, recipient })) return null; logger.debug('Message is for an ICA'); - const decodedBody = tryDecodeIcaBody(body); + const decodedBody = decodeIcaBody(body); if (!decodedBody) return null; - const { sender: originalSender, calls } = decodedBody; + // Only debug CALLS type messages - COMMITMENT and REVEAL have different flows + if (decodedBody.messageType !== IcaMessageType.CALLS) { + logger.debug('Skipping ICA debug for non-CALLS message type'); + return null; + } - const icaAddress = await tryFetchIcaAddress(originDomainId, originalSender, destinationProvider); - if (!icaAddress) return null; + const { calls, owner, ism, salt } = decodedBody; + + // Compute the actual ICA address for accurate gas estimation + // sender is the origin ICA router, recipient is the destination ICA router + const icaAddress = await computeIcaAddress( + originDomainId, + owner!, // owner is defined for CALLS type + sender, // origin router (sender of ICA message) + recipient, // destination router + ism, + salt, + destinationProvider, + ); + + if (!icaAddress) { + logger.debug('Could not compute ICA address, skipping call checks'); + return null; + } + + logger.debug(`Computed ICA address: ${icaAddress}`); for (let i = 0; i < calls.length; i++) { const call = calls[i]; - logger.debug(`Checking ica call ${i + 1} of ${calls.length}`); + logger.debug(`Checking ICA call ${i + 1} of ${calls.length}`); const errorReason = await tryCheckIcaCall( icaAddress, - call.destinationAddress, - call.callBytes, + call.to, + call.data, + call.value, destinationProvider, ); if (errorReason) { - return `ICA call ${i + 1} of ${calls.length} cannot be executed. ${errorReason}`; + return { + failedCallIndex: i, + totalCalls: calls.length, + errorReason, + }; } } @@ -428,6 +468,7 @@ async function tryCheckIcaCall( icaAddress: string, destinationAddress: string, callBytes: string, + callValue: string, destinationProvider: Provider, ) { try { @@ -435,6 +476,7 @@ async function tryCheckIcaCall( to: destinationAddress, data: callBytes, from: icaAddress, + value: BigNumber.from(callValue), }); logger.debug(`No call error found for call from ${icaAddress} to ${destinationAddress}`); return null; diff --git a/src/features/debugger/types.ts b/src/features/debugger/types.ts index 45b9ef12..1ba15e4e 100644 --- a/src/features/debugger/types.ts +++ b/src/features/debugger/types.ts @@ -25,6 +25,11 @@ export interface MessageDebugResult { contract: Address; mailbox: Address; }; + icaDetails?: { + failedCallIndex: number; // 0-based index of the failed call + totalCalls: number; + errorReason: string; + }; } export interface GasPayment { diff --git a/src/features/messages/MessageDetails.tsx b/src/features/messages/MessageDetails.tsx index e4f60758..b75e657a 100644 --- a/src/features/messages/MessageDetails.tsx +++ b/src/features/messages/MessageDetails.tsx @@ -59,7 +59,7 @@ export function MessageDetails({ messageId, message: messageFromUrlParams }: Pro const isFetching = isGraphQlFetching || isPiFetching; const isError = isGraphQlError || isPiError; const blur = !isMessageFound; - const isIcaMsg = useIsIcaMessage(_message); + const isIcaMsg = useIsIcaMessage({ sender: _message.sender, recipient: _message.recipient }); // If message isn't delivered, attempt to check for // more recent updates and possibly debug info @@ -138,6 +138,7 @@ export function MessageDetails({ messageId, message: messageFromUrlParams }: Pro warpRouteDetails={warpRouteDetails} blur={blur} /> + {isIcaMsg && } )} - {isIcaMsg && } ); diff --git a/src/features/messages/cards/IcaDetailsCard.tsx b/src/features/messages/cards/IcaDetailsCard.tsx index 5839b975..620c75fb 100644 --- a/src/features/messages/cards/IcaDetailsCard.tsx +++ b/src/features/messages/cards/IcaDetailsCard.tsx @@ -1,26 +1,246 @@ -import { Tooltip } from '@hyperlane-xyz/widgets'; +import { MAILBOX_VERSION } from '@hyperlane-xyz/sdk'; +import { addressToBytes32, formatMessage, fromWei, strip0x } from '@hyperlane-xyz/utils'; +import { CopyButton, Tooltip } from '@hyperlane-xyz/widgets'; +import { BigNumber } from 'ethers'; import Image from 'next/image'; -import { useMemo } from 'react'; +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + import { Card } from '../../../components/layout/Card'; import AccountStar from '../../../images/icons/account-star.svg'; -import { Message } from '../../../types'; -import { tryDecodeIcaBody, useIcaAddress } from '../ica'; - +import { useMultiProvider } from '../../../store'; +import { IcaCall, Message, MessageStatus } from '../../../types'; +import { tryGetBlockExplorerAddressUrl } from '../../../utils/url'; +import { MessageDebugResult } from '../../debugger/types'; +import { + decodeIcaBody, + IcaMessageType, + useCcipReadIsmUrls, + useIcaAddress, + useRelatedIcaMessage, + useRevealCalls, +} from '../ica'; import { KeyValueRow } from './KeyValueRow'; +/** + * Check if a bytes32 salt contains an address (first 12 bytes are zeros, last 20 bytes are non-zero). + * Returns the address if valid, or null otherwise. + */ +function extractAddressFromSalt(salt: string | undefined): string | null { + if (!salt) return null; + const saltHex = strip0x(salt); + if (saltHex.length !== 64) return null; + + // Check if first 12 bytes (24 hex chars) are zeros + const prefix = saltHex.slice(0, 24); + if (prefix !== '0'.repeat(24)) return null; + + // Check if last 20 bytes (40 hex chars) are non-zero + const addressHex = saltHex.slice(24); + if (addressHex === '0'.repeat(40)) return null; + + return '0x' + addressHex; +} + interface Props { message: Message; blur: boolean; + debugResult?: MessageDebugResult; } -export function IcaDetailsCard({ message: { originDomainId, body }, blur }: Props) { - const decodeResult = useMemo(() => tryDecodeIcaBody(body), [body]); +export function IcaDetailsCard({ message, blur, debugResult }: Props) { + const { + body, + msgId, + nonce, + sender, + recipient, + originDomainId, + destinationDomainId, + destination, + status, + } = message; + const isDelivered = status === MessageStatus.Delivered; + const multiProvider = useMultiProvider(); + + const originChainName = multiProvider.tryGetChainName(originDomainId) || undefined; + const destinationChainName = multiProvider.tryGetChainName(destinationDomainId) || undefined; + + const decodeResult = useMemo(() => decodeIcaBody(body), [body]); + + // Construct the full message bytes for calling route() on the ICA router + const messageBytes = useMemo(() => { + return formatMessage( + MAILBOX_VERSION, + nonce, + originDomainId, + addressToBytes32(sender), + destinationDomainId, + addressToBytes32(recipient), + body, + ); + }, [nonce, originDomainId, sender, destinationDomainId, recipient, body]); + // For REVEAL messages, fetch the calls from the destination transaction + const { + data: revealCalls, + isFetching: isRevealFetching, + isError: isRevealError, + } = useRevealCalls(destinationChainName, destination?.hash, msgId, decodeResult?.messageType); + + // Find related COMMITMENT <-> REVEAL message + const { + relatedMessage, + relatedMessageType, + relatedDecoded, + isFetching: isRelatedFetching, + } = useRelatedIcaMessage( + message.origin.hash, + msgId, + decodeResult?.commitment, + decodeResult?.messageType, + ); + + // For REVEAL messages, derive owner/ism/salt from the related COMMITMENT message + // For CALLS/COMMITMENT messages, use the decoded data directly + const displayOwner = decodeResult?.owner || relatedDecoded?.owner || ''; + const displayIsm = decodeResult?.ism || relatedDecoded?.ism || ''; + const displaySalt = decodeResult?.salt || relatedDecoded?.salt || ''; + + // Fetch the derived ICA address + // For REVEAL messages, use owner/ism/salt from the related COMMITMENT message const { data: icaAddress, - isFetching, - isError, - } = useIcaAddress(originDomainId, decodeResult?.sender); + isFetching: isIcaFetching, + isError: isIcaError, + } = useIcaAddress( + originChainName, + destinationChainName, + displayOwner || undefined, + displayIsm || undefined, + displaySalt || undefined, + ); + + // For REVEAL messages, fetch the CCIP Read ISM address and URLs from the destination chain + const { + data: ccipReadData, + isFetching: isCcipFetching, + isError: isCcipError, + } = useCcipReadIsmUrls(destinationChainName, messageBytes, decodeResult?.messageType); + + // Combine calls from message body (CALLS type) or from reveal metadata (REVEAL type) + const displayCalls = useMemo(() => { + if (decodeResult?.messageType === IcaMessageType.CALLS) { + return decodeResult.calls; + } + if (decodeResult?.messageType === IcaMessageType.REVEAL && revealCalls) { + return revealCalls; + } + return []; + }, [decodeResult, revealCalls]); + + // Get the failed call index from debug result (-1 if no failure or not available) + const failedCallIndex = debugResult?.icaDetails?.failedCallIndex ?? -1; + + // Get block explorer URLs for call targets and ICA address + const [explorerUrls, setExplorerUrls] = useState>({}); + + const getExplorerUrls = useCallback(async () => { + if (!decodeResult) return; + + const urls: Record = {}; + + // Get URLs for call targets + for (let i = 0; i < displayCalls.length; i++) { + const call = displayCalls[i]; + urls[`call-${i}`] = await tryGetBlockExplorerAddressUrl( + multiProvider, + destinationDomainId, + call.to, + ); + } + + // Get URL for owner on origin chain (use derived value for REVEAL messages) + if (displayOwner) { + urls['owner'] = await tryGetBlockExplorerAddressUrl( + multiProvider, + originDomainId, + displayOwner, + ); + } + + // Get URL for ICA address on destination chain + if (icaAddress) { + urls['ica'] = await tryGetBlockExplorerAddressUrl( + multiProvider, + destinationDomainId, + icaAddress, + ); + } + + // Get URL for salt address on origin chain (use derived value for REVEAL messages) + const saltAddress = extractAddressFromSalt(displaySalt); + if (saltAddress) { + urls['saltAddress'] = await tryGetBlockExplorerAddressUrl( + multiProvider, + originDomainId, + saltAddress, + ); + } + + // Get URL for ISM address on destination chain (use derived value for REVEAL messages) + if (displayIsm && displayIsm !== '0x0000000000000000000000000000000000000000') { + urls['ism'] = await tryGetBlockExplorerAddressUrl( + multiProvider, + destinationDomainId, + displayIsm, + ); + } + + setExplorerUrls(urls); + }, [ + decodeResult, + displayCalls, + destinationDomainId, + originDomainId, + multiProvider, + icaAddress, + displayOwner, + displayIsm, + displaySalt, + ]); + + useEffect(() => { + getExplorerUrls().catch(() => setExplorerUrls({})); + }, [getExplorerUrls]); + + const messageTypeLabel = useMemo(() => { + if (!decodeResult) return 'Unknown'; + switch (decodeResult.messageType) { + case IcaMessageType.CALLS: + return 'Calls'; + case IcaMessageType.COMMITMENT: + return 'Commitment'; + case IcaMessageType.REVEAL: + return 'Reveal'; + default: + return 'Unknown'; + } + }, [decodeResult]); + + const messageTypeDescription = useMemo(() => { + if (!decodeResult) return ''; + switch (decodeResult.messageType) { + case IcaMessageType.CALLS: + return 'Direct execution of calls on the destination chain'; + case IcaMessageType.COMMITMENT: + return 'First phase of commit-reveal: stores a commitment hash on the account'; + case IcaMessageType.REVEAL: + return 'Second phase of commit-reveal: reveals and executes the committed calls'; + default: + return ''; + } + }, [decodeResult]); return ( @@ -29,77 +249,441 @@ export function IcaDetailsCard({ message: { originDomainId, body }, blur }: Prop
-

ICA Details

+

Interchain Account Details

{decodeResult ? ( <> - - - {decodeResult.calls.length ? ( - decodeResult.calls.map((c, i) => ( -
- -
- + {/* Message type info */} +
+ {messageTypeLabel} + + {messageTypeDescription} +
+ + {/* Status section for COMMITMENT type - at the top */} + {decodeResult.messageType === IcaMessageType.COMMITMENT && decodeResult.commitment && ( +
+ {relatedMessage && + relatedMessageType === IcaMessageType.REVEAL && + relatedMessage.status === MessageStatus.Delivered ? ( +
+
+ +
+
Commitment Revealed
+
+
+ {decodeResult.commitment} +
+ +
+
+ + View REVEAL message → + +
+
+
+
+ ) : ( +
+
+ +
+
+ {relatedMessage && relatedMessageType === IcaMessageType.REVEAL + ? 'Reveal Pending Delivery' + : 'Commitment Pending Reveal'} +
+
+
+ {decodeResult.commitment} +
+ +
+
+ {relatedMessage && relatedMessageType === IcaMessageType.REVEAL + ? 'The REVEAL message is waiting to be delivered on the destination chain.' + : 'A subsequent REVEAL message with matching calls must be sent to execute.'} +
+ {relatedMessage && relatedMessageType === IcaMessageType.REVEAL && ( +
+ + View REVEAL message → + +
+ )} + {isRelatedFetching && ( +
+ Looking for related REVEAL message... +
+ )} +
+
+
+ )} +
+ )} + + {/* Status section for REVEAL type - at the top */} + {decodeResult.messageType === IcaMessageType.REVEAL && decodeResult.commitment && ( +
+ {isDelivered ? ( +
+
+ +
+
Commitment Revealed
+
+
+ {decodeResult.commitment} +
+ +
+ {relatedMessage && relatedMessageType === IcaMessageType.COMMITMENT && ( +
+ + ← View corresponding COMMITMENT message + +
+ )} + {isRelatedFetching && ( +
+ Looking for related COMMITMENT message... +
+ )} +
+
+
+ ) : ( +
+
+ +
+
Revealing Commitment
+
+
+ {decodeResult.commitment} +
+ +
+
+ Waiting for message to be delivered on the destination chain. +
+ {relatedMessage && relatedMessageType === IcaMessageType.COMMITMENT && ( +
+ + ← View corresponding COMMITMENT message + +
+ )} + {isRelatedFetching && ( +
+ Looking for related COMMITMENT message... +
+ )} +
+
+
+ )} + {/* CCIP Read ISM section - only show when pending to help debug delivery issues */} + {!isDelivered && ( +
+ + {isCcipFetching ? ( +
Fetching gateway URLs...
+ ) : ccipReadData?.urls && ccipReadData.urls.length > 0 ? ( +
+ {ccipReadData.urls.map((url, i) => ( +
+ {url} +
+ ))} +
+ ) : isCcipError ? ( +
+ Failed to fetch gateway URLs from ISM contract. +
+ ) : ( +
+ No gateway URLs configured. The ISM may not support CCIP Read. +
+ )} +
+ The secret calls must be posted to this gateway by the commitment sender, who + specifies which relayers are authorized to fetch and deliver the calls. +
+
+ If delivery is failing, the calls may not have been posted to the gateway, or no + authorized relayer is available. +
+
+ )} +
+ )} + + {displayOwner && ( + + )} + + {/* Show ICA address when we have owner data */} + {displayOwner && ( + + )} + + {/* Show ISM when available and non-zero */} + {displayIsm && displayIsm !== '0x0000000000000000000000000000000000000000' && ( + + )} + + {displaySalt && displaySalt !== '0x' + '0'.repeat(64) && ( + <> + + {(() => { + const saltAddress = extractAddressFromSalt(displaySalt); + if (!saltAddress) return null; + return ( + ); + })()} + + )} + + {/* Show calls for CALLS type or REVEAL type (when fetched) */} + {(decodeResult.messageType === IcaMessageType.CALLS || + decodeResult.messageType === IcaMessageType.REVEAL) && ( +
+ + + {/* Loading state for reveal calls */} + {decodeResult.messageType === IcaMessageType.REVEAL && isRevealFetching && ( +
+ Fetching calls from destination transaction...
-
- )) - ) : ( -
- -
No calls found for this message.
+ )} + + {/* Error state for reveal calls */} + {decodeResult.messageType === IcaMessageType.REVEAL && + isRevealError && + !revealCalls && ( +
+ Could not fetch calls from destination transaction. +
+ )} + + {/* No destination tx yet for reveal */} + {decodeResult.messageType === IcaMessageType.REVEAL && + !destination?.hash && + !isRevealFetching && ( +
+ Calls will be shown once the message is processed on the destination chain. +
+ )} + + {/* Display calls */} + {displayCalls.length > 0 && + displayCalls.map((call, i) => ( + + ))} + + {/* Empty calls for CALLS type */} + {decodeResult.messageType === IcaMessageType.CALLS && + decodeResult.calls.length === 0 && ( +
No calls in this message.
+ )}
)} ) : (
- Unable to decode ICA message body, no details currently available. + Unable to decode Interchain Account message body. The message format may be unrecognized.
)} ); } + +function IcaCallDetails({ + call, + index, + total, + explorerUrl, + blur, + isDelivered, + failedCallIndex, +}: { + call: IcaCall; + index: number; + total: number; + explorerUrl: string | null | undefined; + blur: boolean; + isDelivered: boolean; + failedCallIndex: number; // -1 if no failure, otherwise 0-based index of failed call +}) { + // Defensive handling for BigNumber conversion - malformed values shouldn't crash the card + let hasValue = false; + let formattedValue = '0'; + try { + hasValue = BigNumber.from(call.value).gt(0); + formattedValue = hasValue ? fromWei(call.value, 18) : '0'; + } catch { + // Malformed value, use defaults + } + + // Determine call state for styling + const isFailed = failedCallIndex === index; + + // Determine styling based on state + let borderClass: string; + let labelClass: string; + let statusSuffix = ''; + + if (isDelivered) { + // All calls succeeded + borderClass = 'border-green-200 bg-green-50'; + labelClass = 'text-green-600'; + } else if (isFailed) { + // This specific call failed + borderClass = 'border-red-200 bg-red-50'; + labelClass = 'text-red-600'; + statusSuffix = ' — Failed'; + } else { + // Pending (either not checked yet, or after a failed call) + borderClass = 'border-amber-200 bg-amber-50'; + labelClass = 'text-amber-600'; + } + + return ( +
+ +
+ + {hasValue && ( + + )} + +
+
+ ); +} diff --git a/src/features/messages/cards/KeyValueRow.tsx b/src/features/messages/cards/KeyValueRow.tsx index b1419b79..dd223fb4 100644 --- a/src/features/messages/cards/KeyValueRow.tsx +++ b/src/features/messages/cards/KeyValueRow.tsx @@ -32,26 +32,30 @@ export function KeyValueRow({ return (
-
- {!useFallbackVal ? display : 'Unknown'} - {subDisplay && !useFallbackVal && {subDisplay}} +
+ + {!useFallbackVal ? display : 'Unknown'} + + {subDisplay && !useFallbackVal && ( + {subDisplay} + )} + {showCopy && !useFallbackVal && ( + + )} + {link && }
- {showCopy && !useFallbackVal && ( - - )} - {link && }
); } function LinkIcon({ href }: { href: string }) { return ( - + ); diff --git a/src/features/messages/ica.ts b/src/features/messages/ica.ts index 88c8dd34..405a5149 100644 --- a/src/features/messages/ica.ts +++ b/src/features/messages/ica.ts @@ -1,117 +1,915 @@ +// eslint-disable-next-line camelcase +import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core'; +import { chainAddresses } from '@hyperlane-xyz/registry'; +import { addressToBytes32, bytes32ToAddress, strip0x } from '@hyperlane-xyz/utils'; import { useQuery } from '@tanstack/react-query'; -import { BigNumber, providers, utils } from 'ethers'; +import { BigNumber, Contract, providers, utils } from 'ethers'; import { useMemo } from 'react'; +import { useQuery as useUrqlQuery } from 'urql'; -import { InterchainAccountRouter__factory as InterchainAccountRouterFactory } from '@hyperlane-xyz/core'; -import { eqAddress, isValidAddress } from '@hyperlane-xyz/utils'; - -import { useReadyMultiProvider } from '../../store'; +import { useMultiProvider, useReadyMultiProvider } from '../../store'; +import { IcaCall, IcaRouterAddressMap, Message, MessageStub } from '../../types'; import { logger } from '../../utils/logger'; +import { useScrapedDomains } from '../chains/queries/useScrapedChains'; +import { MessageIdentifierType, buildMessageQuery } from './queries/build'; +import { MessagesStubQueryResult } from './queries/fragments'; +import { parseMessageStubResult } from './queries/parse'; -// This assumes all chains have the same ICA address -// const ICA_ADDRESS = hyperlaneEnvironments.mainnet.ethereum.interchainAccountRouter; -// TODO V3 determine what ICA address should be -const ICA_ADDRESS = ''; +/** + * ICA Message Types (from InterchainAccountMessage.sol) + */ +export enum IcaMessageType { + CALLS = 0, + COMMITMENT = 1, + REVEAL = 2, +} -export function useIsIcaMessage({ sender, recipient }: { sender: Address; recipient: Address }) { - return useMemo(() => isIcaMessage({ sender, recipient }), [sender, recipient]); +/** + * Decoded ICA message with all fields + */ +export interface DecodedIcaMessage { + messageType: IcaMessageType; + owner: string; // bytes32 -> address + ism: string; // bytes32 -> address + salt: string; // bytes32 hex + calls: IcaCall[]; // Only present for CALLS type + commitment?: string; // Only present for COMMITMENT type +} + +// Build a map of chainName -> ICA router address from the registry +export function buildIcaRouterAddressMap(): IcaRouterAddressMap { + const map: IcaRouterAddressMap = {}; + + for (const [chainName, addresses] of Object.entries(chainAddresses)) { + const icaRouter = (addresses as Record).interchainAccountRouter; + if (icaRouter) { + map[chainName] = icaRouter; + } + } + + return map; +} + +// Cached ICA router address map built at module load time +const ICA_ROUTER_MAP = buildIcaRouterAddressMap(); + +// Get all known ICA router addresses as a Set for fast lookup +function getIcaRouterAddresses(): Set { + return new Set(Object.values(ICA_ROUTER_MAP).map((addr) => addr.toLowerCase())); +} + +/** + * Check if an address is a known ICA router + */ +export function isAddressIcaRouter(addr: Address): boolean { + if (!addr) return false; + try { + const icaRouters = getIcaRouterAddresses(); + return icaRouters.has(addr.toLowerCase()); + } catch (error) { + logger.warn('Error checking if address is ICA router', error, addr); + return false; + } } -export function isIcaMessage({ sender, recipient }: { sender: Address; recipient: Address }) { +/** + * Check if a message is an ICA message by verifying both sender and recipient + * are known ICA router addresses + */ +export function isIcaMessage({ + sender, + recipient, +}: { + sender: Address; + recipient: Address; +}): boolean { const isSenderIca = isAddressIcaRouter(sender); const isRecipIca = isAddressIcaRouter(recipient); + if (isSenderIca && isRecipIca) return true; + if (isSenderIca && !isRecipIca) { - logger.warn('Msg sender is ICA router but not recip', sender, recipient); + logger.warn('Msg sender is ICA router but not recipient', sender, recipient); } if (!isSenderIca && isRecipIca) { - logger.warn('Msg recip is ICA router but not sender', recipient, sender); + logger.warn('Msg recipient is ICA router but not sender', recipient, sender); } + return false; } -function isAddressIcaRouter(addr: Address) { - try { - // TODO PI support - return ICA_ADDRESS && eqAddress(addr, ICA_ADDRESS); - } catch (error) { - logger.warn('Error checking if address is ICA router', error, addr); - return false; - } +/** + * React hook to check if a message is an ICA message + */ +export function useIsIcaMessage({ + sender, + recipient, +}: { + sender: Address; + recipient: Address; +}): boolean { + return useMemo(() => isIcaMessage({ sender, recipient }), [sender, recipient]); } -export function tryDecodeIcaBody(body: string) { - if (!body || BigNumber.from(body).isZero()) return null; +/** + * Decode an ICA message body. + * + * Message formats (from InterchainAccountMessage.sol): + * + * CALLS message: + * - Byte 0: MessageType.CALLS (0x00) + * - Bytes 1-33: ICA owner (bytes32) + * - Bytes 33-65: ICA ISM (bytes32) + * - Bytes 65-97: User Salt (bytes32) + * - Bytes 97+: ABI-encoded Call[] where Call = (bytes32 to, uint256 value, bytes data) + * + * COMMITMENT message: + * - Byte 0: MessageType.COMMITMENT (0x01) + * - Bytes 1-33: ICA owner (bytes32) + * - Bytes 33-65: ICA ISM (bytes32) + * - Bytes 65-97: User Salt (bytes32) + * - Bytes 97-129: Commitment (bytes32) + * + * REVEAL message: + * - Byte 0: MessageType.REVEAL (0x02) + * - Bytes 1-33: ICA ISM (bytes32) + * - Bytes 33-65: Commitment (bytes32) + */ +export function decodeIcaBody(body: string): DecodedIcaMessage | null { + if (!body) return null; + try { - const decoder = utils.defaultAbiCoder; - const decodedBody = decoder.decode(['address sender', 'tuple(address, bytes)[] calls'], body); - const { sender, calls } = decodedBody as unknown as { - sender: string; - calls: Array<[string, string]>; - }; - if (typeof sender !== 'string' || !isValidAddress(sender)) - throw new Error(`Invalid sender address: ${sender}`); - if (!Array.isArray(calls)) throw new Error(`Invalid call list: ${JSON.stringify(calls)}`); - - const formattedCalls = calls.map((c) => { - const [destinationAddress, callBytes] = c; - if (typeof destinationAddress !== 'string' || !isValidAddress(destinationAddress)) - throw new Error(`Invalid call dest address: ${destinationAddress}`); - if (typeof callBytes !== 'string') throw new Error(`Invalid call bytes: ${callBytes}`); + const bodyHex = strip0x(body); + + // Safe zero check - handles any length payload without throwing + if (!bodyHex || /^0*$/.test(bodyHex)) return null; + + // Minimum length to read message type: 1 byte = 2 hex chars + if (bodyHex.length < 2) { + logger.warn('ICA body too short to read message type'); + return null; + } + + // Parse message type (first byte) + const messageType = parseInt(bodyHex.slice(0, 2), 16) as IcaMessageType; + + if (messageType === IcaMessageType.REVEAL) { + // REVEAL format: type (1) + ism (32) + commitment (32) = 65 bytes = 130 hex chars + if (bodyHex.length < 130) { + logger.warn('REVEAL message body too short'); + return null; + } + + const revealIsm = bytes32ToAddress('0x' + bodyHex.slice(2, 66)); + const revealCommitment = '0x' + bodyHex.slice(66, 130); + return { - destinationAddress, - callBytes, + messageType, + owner: '', // Not present in REVEAL + ism: revealIsm, + salt: '', // Not present in REVEAL + calls: [], + commitment: revealCommitment, }; - }); + } - return { - sender, - calls: formattedCalls, - }; + // CALLS and COMMITMENT messages have the same prefix format + // Minimum length: 1 byte type + 32 bytes owner + 32 bytes ism + 32 bytes salt = 97 bytes = 194 hex chars + if (bodyHex.length < 194) { + logger.warn('ICA CALLS/COMMITMENT body too short'); + return null; + } + + // Parse owner (bytes 1-33) + const ownerBytes32 = '0x' + bodyHex.slice(2, 66); + const owner = bytes32ToAddress(ownerBytes32); + + // Parse ISM (bytes 33-65) + const ismBytes32 = '0x' + bodyHex.slice(66, 130); + const ism = bytes32ToAddress(ismBytes32); + + // Parse salt (bytes 65-97) + const salt = '0x' + bodyHex.slice(130, 194); + + if (messageType === IcaMessageType.CALLS) { + // Decode the ABI-encoded calls array (bytes 97+) + const encodedCalls = '0x' + bodyHex.slice(194); + + // Format: (bytes32 to, uint256 value, bytes data)[] + const decoded = utils.defaultAbiCoder.decode( + ['tuple(bytes32 to, uint256 value, bytes data)[]'], + encodedCalls, + ); + + const rawCalls = decoded[0] as Array<{ + to: string; + value: BigNumber; + data: string; + }>; + + const calls: IcaCall[] = rawCalls.map((call) => ({ + to: bytes32ToAddress(call.to), + value: call.value.toString(), + data: call.data, + })); + + return { messageType, owner, ism, salt, calls }; + } else if (messageType === IcaMessageType.COMMITMENT) { + // Commitment is bytes 97-129 + if (bodyHex.length < 258) { + logger.warn('COMMITMENT message body too short for commitment hash'); + return null; + } + const commitment = '0x' + bodyHex.slice(194, 258); + return { messageType, owner, ism, salt, calls: [], commitment }; + } + + // Unknown message type + logger.warn('Unknown ICA message type:', messageType); + return null; } catch (error) { logger.error('Error decoding ICA body', error); return null; } } -export async function tryFetchIcaAddress( - originDomainId: DomainId, - sender: Address, - provider: providers.Provider, -) { +/** + * Parse ICA message details from a message + */ +export function parseIcaMessageDetails(message: Message | MessageStub): DecodedIcaMessage | null { + const { body, sender, recipient } = message; + + // First verify this is an ICA message + if (!isIcaMessage({ sender, recipient })) { + return null; + } + + if (!body) return null; + + return decodeIcaBody(body); +} + +/** + * Get the ICA router address for a given chain + */ +export function getIcaRouterAddress(chainName: string): Address | undefined { + return ICA_ROUTER_MAP[chainName]; +} + +/** + * Compute the ICA address for a given owner on the destination chain. + * This is a non-hook version for use in non-React contexts like the debugger. + * + * @param originDomainId - The origin chain's domain ID + * @param owner - The owner address on the origin chain + * @param originRouter - The ICA router address on the origin chain + * @param destRouter - The ICA router address on the destination chain + * @param ism - Optional ISM address (uses default if not specified) + * @param salt - Optional salt (uses zero salt if not specified) + * @param destProvider - Provider for the destination chain + * @returns The derived ICA address, or null if computation fails + */ +export async function computeIcaAddress( + originDomainId: number, + owner: string, + originRouter: string, + destRouter: string, + ism: string | undefined, + salt: string | undefined, + destProvider: providers.Provider, +): Promise { try { - if (!ICA_ADDRESS) return null; - logger.debug('Fetching Ica address', originDomainId, sender); + // eslint-disable-next-line camelcase + const router = InterchainAccountRouter__factory.connect(destRouter, destProvider); + + // Use zero address for ISM if not specified (will use default ISM) + const ismAddress = ism || '0x0000000000000000000000000000000000000000'; + const userSalt = salt || '0x' + '0'.repeat(64); - const icaContract = InterchainAccountRouterFactory.connect(ICA_ADDRESS, provider); - const icaAddress = await icaContract['getInterchainAccount(uint32,address)']( + // Get the ICA address using the contract with salt + // Signature: getLocalInterchainAccount(uint32,bytes32,bytes32,address,bytes32) + const icaAddress = await router[ + 'getLocalInterchainAccount(uint32,bytes32,bytes32,address,bytes32)' + ]( originDomainId, - sender, + addressToBytes32(owner), + addressToBytes32(originRouter), + ismAddress, + userSalt, ); - if (!isValidAddress(icaAddress)) throw new Error(`Invalid Ica addr ${icaAddress}`); - logger.debug('Ica address found', icaAddress); + return icaAddress; } catch (error) { - logger.error('Error fetching ICA address', error); + logger.error('Error computing ICA address', error); return null; } } -export function useIcaAddress(originDomainId: DomainId, sender?: Address | null) { +/** + * Decode the ISM metadata for a REVEAL message to extract the calls. + * + * Metadata format (from CommitmentReadIsm.verify): + * - Bytes 0-20: ICA address + * - Bytes 20-52: Salt (bytes32) + * - Bytes 52+: ABI-encoded CallLib.Call[] + */ +export function decodeRevealMetadata(metadata: string): { + icaAddress: string; + salt: string; + calls: IcaCall[]; +} | null { + try { + const metaHex = strip0x(metadata); + + // Minimum: 20 bytes address + 32 bytes salt = 52 bytes = 104 hex chars + if (metaHex.length < 104) { + return null; + } + + // ICA address (bytes 0-20) + const icaAddress = '0x' + metaHex.slice(0, 40); + + // Salt (bytes 20-52) + const salt = '0x' + metaHex.slice(40, 104); + + // Calls (bytes 52+) + const encodedCalls = '0x' + metaHex.slice(104); + + const decoded = utils.defaultAbiCoder.decode( + ['tuple(bytes32 to, uint256 value, bytes data)[]'], + encodedCalls, + ); + + const rawCalls = decoded[0] as Array<{ + to: string; + value: BigNumber; + data: string; + }>; + + const calls: IcaCall[] = rawCalls.map((call) => ({ + to: bytes32ToAddress(call.to), + value: call.value.toString(), + data: call.data, + })); + + return { icaAddress, salt, calls }; + } catch (error) { + logger.error('Error decoding reveal metadata', error); + return null; + } +} + +// Multicall3 canonical address (deployed on 70+ chains at the same address) +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'; + +// Common Multicall3 function signatures +const MULTICALL_SIGNATURES = { + // aggregate3: (Call3[] calldata calls) -> (Result[] memory returnData) + // where Call3 = { target, allowFailure, callData } + aggregate3: 'aggregate3((address,bool,bytes)[])', + // aggregate3Value: (Call3Value[] calldata calls) -> (Result[] memory returnData) + // where Call3Value = { target, allowFailure, value, callData } + aggregate3Value: 'aggregate3Value((address,bool,uint256,bytes)[])', + // tryAggregate: (bool requireSuccess, Call[] calldata calls) -> (Result[] memory returnData) + tryAggregate: 'tryAggregate(bool,(address,bytes)[])', + // aggregate: (Call[] calldata calls) -> (uint256 blockNumber, bytes[] memory returnData) + aggregate: 'aggregate((address,bytes)[])', +}; + +/** + * Get the mailbox address for a chain from the registry + */ +function getMailboxAddress(chainName: string): Address | undefined { + const addresses = chainAddresses[chainName as keyof typeof chainAddresses]; + return (addresses as Record | undefined)?.mailbox; +} + +/** + * Get the batch contract address for a chain from the registry (if available) + */ +function getBatchContractAddress(chainName: string): Address | undefined { + const addresses = chainAddresses[chainName as keyof typeof chainAddresses]; + return (addresses as Record | undefined)?.batchContractAddress; +} + +/** + * Check if an address is a known multicall/batch contract + */ +function isMulticallAddress(address: Address, chainName: string): boolean { + const normalizedAddress = address.toLowerCase(); + + // Check canonical Multicall3 address + if (normalizedAddress === MULTICALL3_ADDRESS.toLowerCase()) { + return true; + } + + // Check chain-specific batch contract address from registry + const batchContract = getBatchContractAddress(chainName); + if (batchContract && normalizedAddress === batchContract.toLowerCase()) { + return true; + } + + return false; +} + +/** + * Try to extract process calls from a multicall transaction. + * Supports various Multicall contract formats (Multicall3, etc.) + */ +function tryDecodeMulticall( + txData: string, + mailboxInterface: utils.Interface, +): Array<{ metadata: string; message: string }> { + const results: Array<{ metadata: string; message: string }> = []; + + try { + const selector = txData.slice(0, 10); + + // Try aggregate3: (Call3[] calldata calls) + // Call3 = (address target, bool allowFailure, bytes callData) + if (selector === utils.id(MULTICALL_SIGNATURES.aggregate3).slice(0, 10)) { + const decoded = utils.defaultAbiCoder.decode( + ['tuple(address target, bool allowFailure, bytes callData)[]'], + '0x' + txData.slice(10), + ); + const calls = decoded[0] as Array<{ + target: string; + allowFailure: boolean; + callData: string; + }>; + + for (const call of calls) { + try { + const parsed = mailboxInterface.parseTransaction({ data: call.callData }); + if (parsed.name === 'process') { + results.push({ + metadata: parsed.args[0] as string, + message: parsed.args[1] as string, + }); + } + } catch { + // Not a process call, continue + } + } + return results; + } + + // Try aggregate3Value: (Call3Value[] calldata calls) + // Call3Value = (address target, bool allowFailure, uint256 value, bytes callData) + if (selector === utils.id(MULTICALL_SIGNATURES.aggregate3Value).slice(0, 10)) { + const decoded = utils.defaultAbiCoder.decode( + ['tuple(address target, bool allowFailure, uint256 value, bytes callData)[]'], + '0x' + txData.slice(10), + ); + const calls = decoded[0] as Array<{ + target: string; + allowFailure: boolean; + value: BigNumber; + callData: string; + }>; + + for (const call of calls) { + try { + const parsed = mailboxInterface.parseTransaction({ data: call.callData }); + if (parsed.name === 'process') { + results.push({ + metadata: parsed.args[0] as string, + message: parsed.args[1] as string, + }); + } + } catch { + // Not a process call, continue + } + } + return results; + } + + // Try tryAggregate: (bool requireSuccess, Call[] calldata calls) + // Call = (address target, bytes callData) + if (selector === utils.id(MULTICALL_SIGNATURES.tryAggregate).slice(0, 10)) { + const decoded = utils.defaultAbiCoder.decode( + ['bool', 'tuple(address target, bytes callData)[]'], + '0x' + txData.slice(10), + ); + const calls = decoded[1] as Array<{ target: string; callData: string }>; + + for (const call of calls) { + try { + const parsed = mailboxInterface.parseTransaction({ data: call.callData }); + if (parsed.name === 'process') { + results.push({ + metadata: parsed.args[0] as string, + message: parsed.args[1] as string, + }); + } + } catch { + // Not a process call, continue + } + } + return results; + } + + // Try aggregate: (Call[] calldata calls) + // Call = (address target, bytes callData) + if (selector === utils.id(MULTICALL_SIGNATURES.aggregate).slice(0, 10)) { + const decoded = utils.defaultAbiCoder.decode( + ['tuple(address target, bytes callData)[]'], + '0x' + txData.slice(10), + ); + const calls = decoded[0] as Array<{ target: string; callData: string }>; + + for (const call of calls) { + try { + const parsed = mailboxInterface.parseTransaction({ data: call.callData }); + if (parsed.name === 'process') { + results.push({ + metadata: parsed.args[0] as string, + message: parsed.args[1] as string, + }); + } + } catch { + // Not a process call, continue + } + } + return results; + } + } catch (error) { + logger.debug('Failed to decode multicall', error); + } + + return results; +} + +/** + * Fetch the calls for a REVEAL message by parsing the process transaction. + * The calls are passed as ISM metadata to the mailbox.process() function. + * Handles both direct process calls to the mailbox and multicall batches. + */ +export async function fetchRevealCalls( + destinationChainName: string, + processTxHash: string, + messageId: string, + multiProvider: any, +): Promise { + try { + const provider = multiProvider.getEthersV5Provider(destinationChainName); + const tx = await provider.getTransaction(processTxHash); + + if (!tx || !tx.data || !tx.to) { + logger.debug('Transaction not found or has no data/to address'); + return null; + } + + // eslint-disable-next-line camelcase + const { Mailbox__factory } = await import('@hyperlane-xyz/core'); + // eslint-disable-next-line camelcase + const mailboxInterface = Mailbox__factory.createInterface(); + + const mailboxAddress = getMailboxAddress(destinationChainName); + const txTo = tx.to.toLowerCase(); + + // Check if this is a direct call to the mailbox + if (mailboxAddress && txTo === mailboxAddress.toLowerCase()) { + logger.debug('Direct process call to mailbox detected'); + try { + const decoded = mailboxInterface.parseTransaction({ data: tx.data }); + + if (decoded.name === 'process') { + const metadata = decoded.args[0] as string; + const revealData = decodeRevealMetadata(metadata); + + if (revealData) { + return revealData.calls; + } + } + } catch { + logger.debug('Failed to decode direct process call'); + } + return null; + } + + // Check if this is a multicall transaction + if (isMulticallAddress(tx.to, destinationChainName)) { + logger.debug('Multicall transaction detected'); + const processCalls = tryDecodeMulticall(tx.data, mailboxInterface); + + if (processCalls.length > 0) { + // Find the process call that matches our message ID + const { messageId: computeMessageId } = await import('@hyperlane-xyz/utils'); + + for (const processCall of processCalls) { + try { + const msgId = computeMessageId(processCall.message); + if (msgId.toLowerCase() === messageId.toLowerCase()) { + const revealData = decodeRevealMetadata(processCall.metadata); + if (revealData) { + return revealData.calls; + } + } + } catch { + // Failed to compute message ID, continue + } + } + + // If we couldn't match by message ID, return null to avoid showing potentially incorrect data + // (the first process call might belong to a different message in a batched transaction) + logger.debug('Could not match message ID, calls unavailable'); + } + return null; + } + + // Unknown destination contract - try both approaches as fallback + logger.debug('Unknown destination contract, trying fallback decoding'); + + // Try direct process call first + try { + const decoded = mailboxInterface.parseTransaction({ data: tx.data }); + if (decoded.name === 'process') { + const metadata = decoded.args[0] as string; + const revealData = decodeRevealMetadata(metadata); + if (revealData) { + return revealData.calls; + } + } + } catch { + // Not a direct process call + } + + // Try multicall decode + const processCalls = tryDecodeMulticall(tx.data, mailboxInterface); + if (processCalls.length > 0) { + const { messageId: computeMessageId } = await import('@hyperlane-xyz/utils'); + + for (const processCall of processCalls) { + try { + const msgId = computeMessageId(processCall.message); + if (msgId.toLowerCase() === messageId.toLowerCase()) { + const revealData = decodeRevealMetadata(processCall.metadata); + if (revealData) { + return revealData.calls; + } + } + } catch { + // Failed to compute message ID, continue + } + } + } + + return null; + } catch (error) { + logger.error('Error fetching reveal calls', error); + return null; + } +} + +/** + * Hook to fetch the derived ICA address for a given owner on the destination chain. + * Uses the InterchainAccountRouter's getLocalInterchainAccount function. + * + * The ICA address is derived from: origin domain, owner, router, ISM, and salt. + */ +export function useIcaAddress( + originChainName: string | undefined, + destinationChainName: string | undefined, + owner: Address | undefined, + ism: Address | undefined, + salt: string | undefined, +) { + const multiProvider = useReadyMultiProvider(); + + return useQuery({ + queryKey: [ + 'icaAddress', + originChainName, + destinationChainName, + owner, + ism, + salt, + !!multiProvider, + ], + queryFn: async () => { + if (!originChainName || !destinationChainName || !owner || !multiProvider) { + return null; + } + + // Get the ICA router addresses for both chains + const originRouter = getIcaRouterAddress(originChainName); + const destRouter = getIcaRouterAddress(destinationChainName); + + if (!originRouter || !destRouter) { + logger.debug('ICA router not found for chains', originChainName, destinationChainName); + return null; + } + + const provider = multiProvider.getEthersV5Provider(destinationChainName); + const originDomainId = multiProvider.getDomainId(originChainName); + + return computeIcaAddress( + originDomainId, + owner, + originRouter, + destRouter, + ism, + salt, + provider, + ); + }, + retry: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} + +/** + * Hook to fetch the calls from a REVEAL message by parsing the destination transaction. + */ +export function useRevealCalls( + destinationChainName: string | undefined, + processTxHash: string | undefined, + messageId: string | undefined, + messageType: IcaMessageType | undefined, +) { + const multiProvider = useReadyMultiProvider(); + + return useQuery({ + queryKey: [ + 'revealCalls', + destinationChainName, + processTxHash, + messageId, + messageType, + !!multiProvider, + ], + queryFn: async () => { + if ( + !destinationChainName || + !processTxHash || + !messageId || + messageType !== IcaMessageType.REVEAL || + !multiProvider + ) { + return null; + } + + return fetchRevealCalls(destinationChainName, processTxHash, messageId, multiProvider); + }, + retry: false, + enabled: messageType === IcaMessageType.REVEAL && !!processTxHash && !!messageId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} + +/** + * Hook to fetch the CCIP Read ISM address and URLs for REVEAL messages. + * + * For REVEAL messages, the CCIP Read ISM is used to verify the commitment. + * If the ISM in the message is zero address, we fetch the default ISM from the ICA Router. + * The ISM's urls() function returns the off-chain gateway URLs used to fetch reveal metadata. + */ +export function useCcipReadIsmUrls( + destinationChainName: string | undefined, + messageBytes: string | undefined, + messageType: IcaMessageType | undefined, +) { const multiProvider = useReadyMultiProvider(); + return useQuery({ - queryKey: ['useIcaAddress', originDomainId, sender, !!multiProvider], - queryFn: () => { - if (!originDomainId || !multiProvider || !sender || BigNumber.from(sender).isZero()) + queryKey: ['ccipReadIsmUrls', destinationChainName, messageBytes, messageType, !!multiProvider], + queryFn: async () => { + if ( + !destinationChainName || + !messageBytes || + messageType !== IcaMessageType.REVEAL || + !multiProvider + ) { return null; + } + try { - const provider = multiProvider.getEthersV5Provider(originDomainId); - return tryFetchIcaAddress(originDomainId, sender, provider); + const provider = multiProvider.getEthersV5Provider(destinationChainName); + + // Get the ICA Router address for the destination chain + const icaRouterAddress = getIcaRouterAddress(destinationChainName); + if (!icaRouterAddress) { + logger.debug('ICA router not found for chain', destinationChainName); + return null; + } + + // Call route(message) on the ICA Router to get the ISM address for this message + const routerInterface = new utils.Interface([ + 'function route(bytes calldata _message) view returns (address)', + ]); + const routerContract = new Contract(icaRouterAddress, routerInterface, provider); + const ismAddress = await routerContract.route(messageBytes); + + if (!ismAddress || ismAddress === '0x0000000000000000000000000000000000000000') { + return null; + } + + // Fetch URLs from the CCIP Read ISM + const ismInterface = new utils.Interface(['function urls() view returns (string[])']); + const ismContract = new Contract(ismAddress, ismInterface, provider); + + const urls = await ismContract.urls(); + return { ismAddress, urls: urls as string[] }; } catch (error) { - logger.error('Error fetching ICA address', error); + logger.debug('Error fetching CCIP Read ISM URLs', error); return null; } }, retry: false, + enabled: messageType === IcaMessageType.REVEAL && !!destinationChainName && !!messageBytes, + staleTime: 1000 * 60 * 5, // 5 minutes }); } + +/** + * Hook to find the related ICA message (COMMITMENT <-> REVEAL) by searching + * for messages in the same origin transaction with matching commitment hash. + * + * For commit-reveal flow, both COMMITMENT and REVEAL messages are dispatched + * in the same transaction via callRemoteCommitReveal(). + */ +export function useRelatedIcaMessage( + originTxHash: string | undefined, + currentMsgId: string | undefined, + currentCommitment: string | undefined, + currentMessageType: IcaMessageType | undefined, +) { + const { scrapedDomains } = useScrapedDomains(); + const multiProvider = useMultiProvider(); + + // Only search for related messages if this is a COMMITMENT or REVEAL message + const shouldSearch = + !!originTxHash && + !!currentMsgId && + !!currentCommitment && + (currentMessageType === IcaMessageType.COMMITMENT || + currentMessageType === IcaMessageType.REVEAL); + + // Build query to fetch all messages from the same origin tx + // Note: We must always return a valid GraphQL query string (even when paused) + // because urql may validate the query before checking the pause flag + const { query, variables } = useMemo(() => { + if (!shouldSearch || !originTxHash) { + // Return a valid no-op query that will be paused anyway + return buildMessageQuery( + MessageIdentifierType.OriginTxHash, + '0x0000000000000000000000000000000000000000000000000000000000000000', + 1, + true, + ); + } + return buildMessageQuery(MessageIdentifierType.OriginTxHash, originTxHash, 10, true); + }, [shouldSearch, originTxHash]); + + // Execute the query + const [{ data, fetching, error }] = useUrqlQuery({ + query, + variables, + pause: !shouldSearch, + }); + + // Parse and find the related message + const relatedMessage = useMemo(() => { + if (!data || !currentCommitment || !currentMsgId) return null; + + const messages = parseMessageStubResult(multiProvider, scrapedDomains, data); + + // Find the related message by matching commitment hash + for (const msg of messages) { + // Skip the current message + if (msg.msgId === currentMsgId) continue; + + // Decode the message to check if it's the related COMMITMENT/REVEAL + const decoded = decodeIcaBody(msg.body); + if (!decoded || !decoded.commitment) continue; + + // Check if the commitment matches + if (decoded.commitment.toLowerCase() === currentCommitment.toLowerCase()) { + // Verify it's the opposite type (COMMITMENT <-> REVEAL) + if ( + (currentMessageType === IcaMessageType.COMMITMENT && + decoded.messageType === IcaMessageType.REVEAL) || + (currentMessageType === IcaMessageType.REVEAL && + decoded.messageType === IcaMessageType.COMMITMENT) + ) { + return { + message: msg, + messageType: decoded.messageType, + decoded, + }; + } + } + } + + return null; + }, [data, multiProvider, scrapedDomains, currentMsgId, currentCommitment, currentMessageType]); + + return { + relatedMessage: relatedMessage?.message, + relatedMessageType: relatedMessage?.messageType, + relatedDecoded: relatedMessage?.decoded, + isFetching: fetching, + isError: !!error, + }; +} diff --git a/src/types.ts b/src/types.ts index 45e9d2e8..dae7c2ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,3 +72,14 @@ export interface WarpRouteDetails { } export type WarpRouteChainAddressMap = ChainMap>; + +// ICA (Interchain Account) types +// Map of chainName -> ICA router address +export type IcaRouterAddressMap = ChainMap
; + +// Decoded ICA call (from SDK's CallData type) +export interface IcaCall { + to: Address; // Decoded address (from bytes32) + value: string; // uint256 as string (wei) + data: string; // Hex encoded call data +}