) {
);
}
+function ValidatorStatusSummary({
+ validators,
+ threshold,
+}: {
+ validators: ValidatorInfo[];
+ threshold: number;
+}) {
+ const signedCount = validators.filter((v) => v.status === 'signed').length;
+ const hasQuorum = signedCount >= threshold && threshold > 0;
+
+ return (
+
+
+ Validator Signatures
+
+ {signedCount}/{validators.length} ({threshold} required)
+
+
+ {/* Progress bar */}
+
+ {validators.length > 0 && (
+ <>
+ {/* Threshold marker */}
+
+ {/* Signed progress */}
+
+ >
+ )}
+
+ {!hasQuorum && (
+
Waiting for validators to sign...
+ )}
+ {hasQuorum && (
+
+ ✓ Quorum reached, waiting for relayer
+
+ )}
+
+ );
+}
+
function CallDataModal({ debugResult }: { debugResult?: MessageDebugResult }) {
const [isOpen, setIsOpen] = useState(false);
if (!debugResult?.calldataDetails) return null;
@@ -349,7 +389,7 @@ function CallDataModal({ debugResult }: { debugResult?: MessageDebugResult }) {
{`The last step of message delivery is the recipient contract's 'handle' function. If the handle is reverting, try debugging it with `}
+
);
}
diff --git a/src/features/messages/cards/WarpRouteVisualizationCard.tsx b/src/features/messages/cards/WarpRouteVisualizationCard.tsx
new file mode 100644
index 00000000..499bf32b
--- /dev/null
+++ b/src/features/messages/cards/WarpRouteVisualizationCard.tsx
@@ -0,0 +1,130 @@
+import { toWei } from '@hyperlane-xyz/utils';
+import { ChevronIcon, CopyButton, RefreshIcon, SpinnerIcon, Tooltip } from '@hyperlane-xyz/widgets';
+import clsx from 'clsx';
+import { useMemo, useState } from 'react';
+
+import { SectionCard } from '../../../components/layout/SectionCard';
+import { useMultiProvider } from '../../../store';
+import { Message, WarpRouteDetails } from '../../../types';
+import { WarpRouteGraph } from '../warpVisualization/WarpRouteGraph';
+import { useWarpRouteBalances } from '../warpVisualization/useWarpRouteBalances';
+import { useWarpRouteVisualization } from '../warpVisualization/useWarpRouteVisualization';
+
+interface Props {
+ message: Message;
+ warpRouteDetails: WarpRouteDetails | undefined;
+ blur: boolean;
+}
+
+export function WarpRouteVisualizationCard({ message, warpRouteDetails, blur }: Props) {
+ const multiProvider = useMultiProvider();
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ // Get warp route visualization data (from registry, no RPC calls)
+ const { visualization } = useWarpRouteVisualization(warpRouteDetails);
+
+ // Get balances with manual refetch - only fetch when expanded
+ const {
+ balances,
+ isLoading: isBalancesLoading,
+ refetch: refetchBalances,
+ } = useWarpRouteBalances(
+ visualization?.tokens,
+ visualization?.routeId,
+ warpRouteDetails
+ ? BigInt(toWei(warpRouteDetails.amount, warpRouteDetails.originToken.decimals ?? 18))
+ : undefined,
+ isExpanded, // Only fetch balances when expanded
+ );
+
+ // Get chain names
+ const originChainName = useMemo(
+ () => multiProvider.tryGetChainName(message.originDomainId) || 'Unknown',
+ [multiProvider, message.originDomainId],
+ );
+
+ const destinationChainName = useMemo(
+ () => multiProvider.tryGetChainName(message.destinationDomainId) || 'Unknown',
+ [multiProvider, message.destinationDomainId],
+ );
+
+ // Calculate transfer amount in base units for comparison
+ const transferAmount = useMemo(() => {
+ if (!warpRouteDetails) return undefined;
+ return BigInt(toWei(warpRouteDetails.amount, warpRouteDetails.originToken.decimals ?? 18));
+ }, [warpRouteDetails]);
+
+ // Don't render if no warp route details or visualization
+ if (!warpRouteDetails || !visualization) return null;
+
+ return (
+
+ {/* Collapsible Header */}
+
+
+
+ {/* Route ID pill - outside button to prevent toggle on copy */}
+
+ {visualization.routeId}
+
+
+
+
+
+
+ {/* Expandable Content */}
+ {isExpanded && (
+
+ {/* Graph Visualization */}
+
+
+
+
+ {/* Refresh Balances Button */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/messages/cards/WarpTransferDetailsCard.tsx b/src/features/messages/cards/WarpTransferDetailsCard.tsx
index d43c578a..78b54bae 100644
--- a/src/features/messages/cards/WarpTransferDetailsCard.tsx
+++ b/src/features/messages/cards/WarpTransferDetailsCard.tsx
@@ -1,9 +1,7 @@
import { Tooltip } from '@hyperlane-xyz/widgets';
-import Image from 'next/image';
import { useCallback, useEffect, useState } from 'react';
import { TokenIcon } from '../../../components/icons/TokenIcon';
-import { Card } from '../../../components/layout/Card';
-import SendMoney from '../../../images/icons/send-money.svg';
+import { SectionCard } from '../../../components/layout/SectionCard';
import { useMultiProvider } from '../../../store';
import { Message, WarpRouteDetails } from '../../../types';
import { tryGetBlockExplorerAddressUrl } from '../../../utils/url';
@@ -58,64 +56,66 @@ export function WarpTransferDetailsCard({ message, warpRouteDetails, blur }: Pro
const isCollateral = isCollateralRoute(destinationToken.standard);
return (
-
-
- {warpRouteDetails.originToken.logoURI ? (
-
- ) : (
-
+
+ }
+ >
+
+ {/* Token Logo Column */}
+ {warpRouteDetails.originToken.logoURI && (
+
+
+
)}
-
-
Warp Transfer Details
-
-
-
- {isCollateral && (
-
-
Collateral-backed route: This transfer uses locked
- collateral on the destination chain
+ {/* Details Column */}
+
+ {isCollateral && (
+
+ Collateral-backed route: This transfer uses
+ locked collateral on the destination chain
+
+ )}
+
+
+
+
+
+
- )}
-
-
-
-
-
-
+
);
}
diff --git a/src/features/messages/ica.ts b/src/features/messages/ica.ts
index 88c8dd34..42423814 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, 1000, 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/features/messages/queries/useMessageQuery.ts b/src/features/messages/queries/useMessageQuery.ts
index c1f56b9c..26f1a14e 100644
--- a/src/features/messages/queries/useMessageQuery.ts
+++ b/src/features/messages/queries/useMessageQuery.ts
@@ -167,6 +167,45 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause
};
}
+/**
+ * Hook to count messages in a given origin transaction.
+ * Used to determine if we should show the "View all messages in this transaction" link.
+ */
+export function useTransactionMessageCount(originTxHash: string | undefined) {
+ const { scrapedDomains: scrapedChains } = useScrapedDomains();
+ const multiProvider = useMultiProvider();
+
+ // Build query for origin tx hash
+ const { query, variables } = useMemo(() => {
+ if (!originTxHash) {
+ // Return a no-op query
+ return buildMessageQuery(
+ MessageIdentifierType.OriginTxHash,
+ '0x0000000000000000000000000000000000000000000000000000000000000000',
+ 1,
+ true,
+ );
+ }
+ return buildMessageQuery(MessageIdentifierType.OriginTxHash, originTxHash, 1000, true);
+ }, [originTxHash]);
+
+ // Execute query
+ const [{ data }] = useQuery({
+ query,
+ variables,
+ pause: !originTxHash,
+ });
+
+ // Parse results
+ const messageCount = useMemo(() => {
+ if (!data || !originTxHash) return 0;
+ const messages = parseMessageStubResult(multiProvider, scrapedChains, data);
+ return messages.length;
+ }, [data, multiProvider, scrapedChains, originTxHash]);
+
+ return messageCount;
+}
+
function isWindowVisible() {
return document.visibilityState === 'visible';
}
diff --git a/src/features/messages/warpVisualization/WarpRouteGraph.tsx b/src/features/messages/warpVisualization/WarpRouteGraph.tsx
new file mode 100644
index 00000000..ecc89e3a
--- /dev/null
+++ b/src/features/messages/warpVisualization/WarpRouteGraph.tsx
@@ -0,0 +1,372 @@
+import { fromWei, shortenAddress } from '@hyperlane-xyz/utils';
+import { BoxArrowIcon, CopyButton } from '@hyperlane-xyz/widgets';
+import clsx from 'clsx';
+import { useEffect, useState } from 'react';
+
+import { ChainLogo } from '../../../components/icons/ChainLogo';
+import { useMultiProvider } from '../../../store';
+import { formatAmountCompact } from '../../../utils/amount';
+import { tryGetBlockExplorerAddressUrl } from '../../../utils/url';
+
+import { ChainBalance, WarpRouteTokenVisualization } from './types';
+import { isCollateralTokenStandard } from './useWarpRouteVisualization';
+
+interface WarpRouteGraphProps {
+ tokens: WarpRouteTokenVisualization[];
+ originChain: string;
+ destinationChain: string;
+ balances: Record;
+ transferAmount?: bigint;
+ transferAmountDisplay?: string;
+ tokenSymbol?: string;
+}
+
+// Color coding for token standards
+function getTokenTypeColor(standard: string | undefined): string {
+ if (!standard) return 'bg-gray-100 text-gray-700 border-gray-300';
+
+ if (standard.includes('Synthetic')) return 'bg-purple-100 text-purple-700 border-purple-300';
+ if (standard.includes('Collateral') || standard.includes('Lockbox'))
+ return 'bg-primary-25 text-primary-700 border-primary-200';
+ if (standard.includes('Native')) return 'bg-orange-100 text-orange-700 border-orange-300';
+ if (standard.includes('XERC20')) return 'bg-blue-50 text-blue-700 border-blue-200';
+
+ return 'bg-gray-100 text-gray-700 border-gray-300';
+}
+
+function getTokenTypeLabel(standard: string | undefined): string {
+ if (!standard) return 'Unknown';
+
+ if (standard.includes('Synthetic')) return 'Synthetic';
+ if (standard.includes('Collateral')) return 'Collateral';
+ if (standard.includes('Native')) return 'Native';
+ if (standard.includes('XERC20')) return 'xERC20';
+ if (standard.includes('Lockbox')) return 'Lockbox';
+
+ // Strip protocol prefix (Evm, Sealevel, Cw, Cosmos, Starknet)
+ return standard.replace(/^(Evm|Sealevel|Cw|Cosmos|Starknet)Hyp/, '');
+}
+
+function isCollateralToken(token: WarpRouteTokenVisualization): boolean {
+ return token.standard ? isCollateralTokenStandard(token.standard) : false;
+}
+
+function isSyntheticToken(token: WarpRouteTokenVisualization): boolean {
+ return token.standard ? token.standard.includes('Synthetic') : false;
+}
+
+function isXERC20Token(token: WarpRouteTokenVisualization): boolean {
+ return token.standard ? token.standard.includes('XERC20') : false;
+}
+
+/**
+ * Format a balance in a compact form using the shared utility
+ */
+function formatBalance(balance: bigint, decimals: number): string {
+ const value = fromWei(balance.toString(), decimals);
+ return formatAmountCompact(value);
+}
+
+/**
+ * Compact node component for origin/destination
+ */
+function CompactChainNode({
+ token,
+ chainBalance,
+ transferAmount,
+ borderColor,
+ multiProvider,
+ explorerUrls,
+ isDestination = false,
+}: {
+ token: WarpRouteTokenVisualization | undefined;
+ chainBalance: ChainBalance | undefined;
+ transferAmount: bigint | undefined;
+ borderColor: string;
+ multiProvider: ReturnType;
+ explorerUrls: Record;
+ isDestination?: boolean;
+}) {
+ if (!token) return null;
+
+ const isCollateral = isCollateralToken(token);
+ const isSynthetic = isSyntheticToken(token);
+ const isXERC20 = isXERC20Token(token);
+ const balance = chainBalance?.balance;
+
+ // Only mark as insufficient if this is the destination chain
+ const hasInsufficientBalance =
+ isDestination &&
+ isCollateral &&
+ balance !== undefined &&
+ transferAmount !== undefined &&
+ balance < transferAmount;
+
+ const explorerUrl = explorerUrls[`${token.chainName}:${token.addressOrDenom}`];
+
+ // Get display name from multiProvider if available
+ const chainMetadata = multiProvider.tryGetChainMetadata(token.chainName);
+ const displayName = chainMetadata?.displayName || token.chainName;
+
+ return (
+
+
+
{displayName}
+
+ {/* Token type badge */}
+
+ {getTokenTypeLabel(token.standard)}
+
+
+ {/* Token address with link */}
+
+
+ {shortenAddress(token.addressOrDenom)}
+
+
+ {explorerUrl && (
+
+
+
+ )}
+
+
+ {/* Balance/Supply for collateral/synthetic */}
+ {balance !== undefined && (isCollateral || isSynthetic) && !isXERC20 && (
+
+ {formatBalance(balance, token.decimals)} {token.symbol}
+ {isSynthetic && (supply)}
+
+ )}
+
+ {/* xERC20 display: total supply + lockbox if applicable */}
+ {isXERC20 && !!chainBalance && (
+
+ {chainBalance.xerc20Supply !== undefined && (
+
+ {formatBalance(chainBalance.xerc20Supply, token.decimals)} {token.symbol}
+ (supply)
+
+ )}
+ {chainBalance.lockboxBalance !== undefined && (
+
+ {formatBalance(chainBalance.lockboxBalance, token.decimals)} {token.symbol}
+ (lockbox)
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * Minimal chain node for other chains - shows balance info
+ */
+function MinimalChainNode({
+ token,
+ chainBalance,
+ multiProvider,
+}: {
+ token: WarpRouteTokenVisualization;
+ chainBalance: ChainBalance | undefined;
+ multiProvider: ReturnType;
+}) {
+ const chainMetadata = multiProvider.tryGetChainMetadata(token.chainName);
+ const displayName = chainMetadata?.displayName || token.chainName;
+
+ const isCollateral = isCollateralToken(token);
+ const isSynthetic = isSyntheticToken(token);
+ const isXERC20 = isXERC20Token(token);
+ const balance = chainBalance?.balance;
+
+ return (
+
+
+
{displayName}
+
+ {getTokenTypeLabel(token.standard)}
+
+
+ {/* Balance for collateral/synthetic (non-xERC20) */}
+ {balance !== undefined && (isCollateral || isSynthetic) && !isXERC20 && (
+
+ {formatBalance(balance, token.decimals)}
+
+ )}
+
+ {/* xERC20 display */}
+ {isXERC20 && !!chainBalance && (
+
+ {chainBalance.xerc20Supply !== undefined && (
+
+ {formatBalance(chainBalance.xerc20Supply, token.decimals)}
+
+ )}
+ {chainBalance.lockboxBalance !== undefined && (
+
+ {formatBalance(chainBalance.lockboxBalance, token.decimals)}
+ lbx
+
+ )}
+
+ )}
+
+ );
+}
+
+export function WarpRouteGraph({
+ tokens,
+ originChain,
+ destinationChain,
+ balances,
+ transferAmount,
+ transferAmountDisplay,
+ tokenSymbol,
+}: WarpRouteGraphProps) {
+ const multiProvider = useMultiProvider();
+ const [explorerUrls, setExplorerUrls] = useState>({});
+
+ // Fetch explorer URLs for all tokens
+ useEffect(() => {
+ const fetchExplorerUrls = async () => {
+ const urls: Record = {};
+ const fetchTasks: { key: string; promise: Promise }[] = [];
+
+ for (const token of tokens) {
+ const tokenKey = `${token.chainName}:${token.addressOrDenom}`;
+ fetchTasks.push({
+ key: tokenKey,
+ promise: tryGetBlockExplorerAddressUrl(
+ multiProvider,
+ token.chainName,
+ token.addressOrDenom,
+ ),
+ });
+ }
+
+ const results = await Promise.allSettled(fetchTasks.map((t) => t.promise));
+ results.forEach((result, i) => {
+ const key = fetchTasks[i].key;
+ urls[key] = result.status === 'fulfilled' ? result.value : null;
+ });
+
+ setExplorerUrls(urls);
+ };
+
+ if (tokens.length > 0) {
+ fetchExplorerUrls();
+ }
+ }, [tokens, multiProvider]);
+
+ // Get origin and destination tokens
+ const originToken = tokens.find((t) => t.chainName === originChain);
+ const destToken = tokens.find((t) => t.chainName === destinationChain);
+ const otherTokens = tokens.filter(
+ (t) => t.chainName !== originChain && t.chainName !== destinationChain,
+ );
+
+ if (tokens.length === 0) {
+ return (
+
+ No tokens in warp route
+
+ );
+ }
+
+ const originBalance = originToken ? balances[originToken.chainName] : undefined;
+ const destBalance = destToken ? balances[destToken.chainName] : undefined;
+
+ return (
+
+ {/* Main transfer visualization - origin and destination */}
+
+ {/* Origin */}
+
+
+ {/* Arrow with transfer amount */}
+
+
+
+ {transferAmountDisplay && tokenSymbol && (
+
+
+ {transferAmountDisplay} {tokenSymbol}
+
+
+ )}
+
+
+
+
+
+ {/* Destination */}
+
+
+
+ {/* Other chains in a compact grid */}
+ {otherTokens.length > 0 && (
+
+
Other connected chains:
+
+ {otherTokens.map((token) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/features/messages/warpVisualization/types.ts b/src/features/messages/warpVisualization/types.ts
new file mode 100644
index 00000000..c9be9c1b
--- /dev/null
+++ b/src/features/messages/warpVisualization/types.ts
@@ -0,0 +1,35 @@
+import { TokenStandard, WarpCoreConfig } from '@hyperlane-xyz/sdk';
+
+// Token info from registry for visualization
+export interface WarpRouteTokenVisualization {
+ // From WarpCoreConfig token
+ chainName: string;
+ addressOrDenom: string;
+ symbol: string;
+ decimals: number;
+ standard?: TokenStandard;
+ logoURI?: string;
+ // Balance data (fetched via adapters when expanded)
+ collateralBalance?: bigint;
+ isCollateralInsufficient?: boolean;
+}
+
+export interface WarpRouteVisualization {
+ routeId: string;
+ config: WarpCoreConfig;
+ tokens: WarpRouteTokenVisualization[];
+}
+
+export interface ChainBalance {
+ balance: bigint;
+ xerc20Supply?: bigint; // total supply of xERC20 token
+ lockboxBalance?: bigint; // lockbox balance (EvmHypXERC20Lockbox only)
+}
+
+export interface WarpRouteBalances {
+ // Map of chainName -> balance data
+ balances: Record;
+ isLoading: boolean;
+ error?: string;
+ refetch: () => void;
+}
diff --git a/src/features/messages/warpVisualization/useWarpRouteBalances.ts b/src/features/messages/warpVisualization/useWarpRouteBalances.ts
new file mode 100644
index 00000000..a826a36b
--- /dev/null
+++ b/src/features/messages/warpVisualization/useWarpRouteBalances.ts
@@ -0,0 +1,232 @@
+import {
+ EvmHypCollateralAdapter,
+ EvmHypNativeAdapter,
+ EvmHypSyntheticAdapter,
+ EvmHypXERC20Adapter,
+ EvmHypXERC20LockboxAdapter,
+ IHypTokenAdapter,
+ MultiProtocolProvider,
+ TokenStandard,
+} from '@hyperlane-xyz/sdk';
+import { ProtocolType } from '@hyperlane-xyz/utils';
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import { useMultiProvider } from '../../../store';
+import { logger } from '../../../utils/logger';
+
+import { ChainBalance, WarpRouteBalances, WarpRouteTokenVisualization } from './types';
+import { isCollateralTokenStandard } from './useWarpRouteVisualization';
+
+// Token standards that support balance fetching
+// NOTE: Only EVM chains are supported for balance fetching.
+// Sealevel/StarkNet adapters require native dependencies (e.g., @solana/web3.js)
+// that cause build errors when imported in the browser bundle.
+const SUPPORTED_COLLATERAL_STANDARDS: TokenStandard[] = [
+ TokenStandard.EvmHypCollateral,
+ TokenStandard.EvmHypNative,
+ TokenStandard.EvmHypXERC20Lockbox,
+ TokenStandard.EvmHypXERC20,
+];
+
+const SUPPORTED_SYNTHETIC_STANDARDS: TokenStandard[] = [TokenStandard.EvmHypSynthetic];
+
+/**
+ * Check if a token standard is a supported collateral standard
+ */
+function isSupportedCollateralStandard(standard: TokenStandard | string | undefined): boolean {
+ if (!standard) return false;
+ return SUPPORTED_COLLATERAL_STANDARDS.includes(standard as TokenStandard);
+}
+
+/**
+ * Check if a token standard is a supported synthetic standard
+ */
+function isSupportedSyntheticStandard(standard: TokenStandard | string | undefined): boolean {
+ if (!standard) return false;
+ return SUPPORTED_SYNTHETIC_STANDARDS.includes(standard as TokenStandard);
+}
+
+/**
+ * Create the appropriate HypTokenAdapter for a token
+ * Only EVM adapters are supported - non-EVM adapters have native dependencies
+ * that don't work in the browser bundle
+ */
+function createHypAdapter(
+ multiProvider: MultiProtocolProvider,
+ token: WarpRouteTokenVisualization,
+): IHypTokenAdapter | undefined {
+ const { chainName, addressOrDenom, standard } = token;
+
+ if (!chainName || !addressOrDenom || !standard) {
+ return undefined;
+ }
+
+ const chainMetadata = multiProvider.tryGetChainMetadata(chainName);
+ if (!chainMetadata) {
+ return undefined;
+ }
+
+ const protocol = chainMetadata.protocol;
+
+ // Only EVM adapters are supported
+ if (protocol !== ProtocolType.Ethereum) {
+ return undefined;
+ }
+
+ switch (standard) {
+ case TokenStandard.EvmHypCollateral:
+ return new EvmHypCollateralAdapter(chainName, multiProvider, { token: addressOrDenom });
+ case TokenStandard.EvmHypNative:
+ return new EvmHypNativeAdapter(chainName, multiProvider, { token: addressOrDenom });
+ case TokenStandard.EvmHypSynthetic:
+ return new EvmHypSyntheticAdapter(chainName, multiProvider, { token: addressOrDenom });
+ case TokenStandard.EvmHypXERC20:
+ return new EvmHypXERC20Adapter(chainName, multiProvider, { token: addressOrDenom });
+ case TokenStandard.EvmHypXERC20Lockbox:
+ return new EvmHypXERC20LockboxAdapter(chainName, multiProvider, { token: addressOrDenom });
+ default:
+ return undefined;
+ }
+}
+
+/**
+ * Fetch the balance data for a single token
+ */
+async function fetchTokenBalance(
+ multiProvider: MultiProtocolProvider,
+ token: WarpRouteTokenVisualization,
+): Promise {
+ try {
+ const adapter = createHypAdapter(multiProvider, token);
+ if (!adapter) {
+ return undefined;
+ }
+
+ const bridgedSupply = await adapter.getBridgedSupply();
+ if (bridgedSupply === undefined) {
+ return undefined;
+ }
+
+ const result: ChainBalance = { balance: bridgedSupply };
+
+ // For xERC20 lockbox, get both lockbox balance and total xERC20 supply
+ if (token.standard === TokenStandard.EvmHypXERC20Lockbox) {
+ const lockboxAdapter = adapter as EvmHypXERC20LockboxAdapter;
+ try {
+ // getBridgedSupply returns lockbox balance for lockbox adapter
+ result.lockboxBalance = bridgedSupply;
+ // Get total xERC20 supply from the underlying xERC20 token
+ const xerc20 = await lockboxAdapter.getXERC20();
+ const totalSupply = await xerc20.totalSupply();
+ result.xerc20Supply = BigInt(totalSupply.toString());
+ } catch (error) {
+ logger.debug(`Failed to fetch xERC20 details for ${token.chainName}`, error);
+ }
+ }
+
+ // For xERC20 (non-lockbox), getBridgedSupply returns total supply
+ if (token.standard === TokenStandard.EvmHypXERC20) {
+ result.xerc20Supply = bridgedSupply;
+ }
+
+ return result;
+ } catch (error) {
+ logger.debug(`Failed to fetch balance for ${token.chainName}:${token.symbol}`, error);
+ return undefined;
+ }
+}
+
+/**
+ * Check if a token should have its supply fetched based on its standard
+ */
+function shouldFetchSupply(token: WarpRouteTokenVisualization): boolean {
+ if (!token.standard) return false;
+ // Check if it's a collateral or synthetic standard we can fetch
+ return (
+ isCollateralTokenStandard(token.standard) ||
+ isSupportedCollateralStandard(token.standard) ||
+ isSupportedSyntheticStandard(token.standard)
+ );
+}
+
+/**
+ * Fetch balances/supplies for all tokens in a warp route
+ */
+async function fetchAllBalances(
+ multiProvider: MultiProtocolProvider,
+ tokens: WarpRouteTokenVisualization[],
+): Promise> {
+ const balances: Record = {};
+
+ const promises = tokens.map(async (token) => {
+ const balance = await fetchTokenBalance(multiProvider, token);
+ if (balance !== undefined) {
+ balances[token.chainName] = balance;
+ }
+ });
+
+ await Promise.all(promises);
+ return balances;
+}
+
+/**
+ * Hook to get collateral balances for all tokens in a warp route visualization
+ * @param tokens - The tokens to fetch balances for
+ * @param routeId - The warp route ID for cache key
+ * @param transferAmount - Optional transfer amount for sufficiency check
+ * @param enabled - Whether to fetch balances. Set to false to defer RPC calls until needed.
+ */
+export function useWarpRouteBalances(
+ tokens: WarpRouteTokenVisualization[] | undefined,
+ routeId: string | undefined,
+ _transferAmount?: bigint,
+ enabled = true,
+): WarpRouteBalances {
+ const multiProvider = useMultiProvider();
+
+ const tokensToFetch = useMemo(() => tokens?.filter(shouldFetchSupply) || [], [tokens]);
+
+ // Create a stable string key from tokens - this prevents queryKey from changing
+ // when tokensToFetch array reference changes but content is the same
+ const tokensKey = useMemo(
+ () => tokensToFetch.map((t) => `${t.chainName}:${t.addressOrDenom}`).join(','),
+ [tokensToFetch],
+ );
+
+ const queryKey = useMemo(() => ['warpRouteBalances', routeId, tokensKey], [routeId, tokensKey]);
+
+ const {
+ data: balances,
+ isLoading,
+ error,
+ refetch,
+ } = useQuery({
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps -- multiProvider is stable, tokensToFetch is derived from tokens which is in queryKey via chain:address mapping
+ queryKey,
+ queryFn: () => fetchAllBalances(multiProvider, tokensToFetch),
+ enabled: enabled && tokensToFetch.length > 0 && !!routeId,
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
+
+ return {
+ balances: balances || {},
+ isLoading,
+ error: error ? String(error) : undefined,
+ refetch,
+ };
+}
+
+/**
+ * Check if a balance is insufficient for a given transfer amount
+ */
+export function isBalanceInsufficient(
+ balance: bigint | undefined,
+ requiredAmount: bigint | undefined,
+): boolean {
+ if (balance === undefined || requiredAmount === undefined) return false;
+ return balance < requiredAmount;
+}
diff --git a/src/features/messages/warpVisualization/useWarpRouteVisualization.ts b/src/features/messages/warpVisualization/useWarpRouteVisualization.ts
new file mode 100644
index 00000000..ed8f3139
--- /dev/null
+++ b/src/features/messages/warpVisualization/useWarpRouteVisualization.ts
@@ -0,0 +1,94 @@
+import { WarpCoreConfig } from '@hyperlane-xyz/sdk';
+import { useMemo } from 'react';
+
+import { useStore } from '../../../store';
+import { WarpRouteConfigs, WarpRouteDetails } from '../../../types';
+import { normalizeAddressToHex } from '../../../utils/yamlParsing';
+
+import { WarpRouteTokenVisualization, WarpRouteVisualization } from './types';
+
+/**
+ * Find the warp route config that contains the given token address on the given chain
+ */
+function findWarpRouteConfig(
+ warpRouteConfigs: WarpRouteConfigs,
+ tokenAddress: string,
+ chainName: string,
+): { routeId: string; config: WarpCoreConfig } | undefined {
+ for (const [routeId, config] of Object.entries(warpRouteConfigs)) {
+ const match = config.tokens.find(
+ (t) =>
+ t.chainName === chainName &&
+ t.addressOrDenom &&
+ normalizeAddressToHex(t.addressOrDenom) === normalizeAddressToHex(tokenAddress),
+ );
+ if (match) return { routeId, config };
+ }
+ return undefined;
+}
+
+/**
+ * Hook to get warp route visualization data for a message.
+ * Uses only registry data (no RPC calls) for token type information.
+ */
+export function useWarpRouteVisualization(warpRouteDetails: WarpRouteDetails | undefined): {
+ visualization: WarpRouteVisualization | undefined;
+} {
+ const warpRouteConfigs = useStore((s) => s.warpRouteConfigs);
+
+ // Find the matching warp route config
+ // Memoize based on the actual values that matter, not the object references
+ const originTokenAddress = warpRouteDetails?.originToken.addressOrDenom;
+ const originChainName = warpRouteDetails?.originToken.chainName;
+
+ const warpRoute = useMemo(() => {
+ if (!originTokenAddress) return undefined;
+ return findWarpRouteConfig(warpRouteConfigs, originTokenAddress, originChainName!);
+ }, [warpRouteConfigs, originTokenAddress, originChainName]);
+
+ // Build the visualization data directly from registry config
+ const visualization = useMemo((): WarpRouteVisualization | undefined => {
+ if (!warpRoute) return undefined;
+
+ const tokens: WarpRouteTokenVisualization[] = warpRoute.config.tokens.map((token) => ({
+ chainName: token.chainName,
+ addressOrDenom: token.addressOrDenom || '',
+ symbol: token.symbol || '',
+ decimals: token.decimals ?? 18,
+ standard: token.standard,
+ logoURI: token.logoURI,
+ }));
+
+ return {
+ routeId: warpRoute.routeId,
+ config: warpRoute.config,
+ tokens,
+ };
+ }, [warpRoute]);
+
+ return { visualization };
+}
+
+// Token standards that indicate collateral-backed tokens
+const COLLATERAL_TOKEN_STANDARDS = [
+ 'EvmHypCollateral',
+ 'EvmHypCollateralFiat',
+ 'EvmHypNative',
+ 'EvmHypNativeScaled',
+ 'EvmHypXERC20Lockbox',
+ 'SealevelHypCollateral',
+ 'SealevelHypNative',
+ 'CwHypCollateral',
+ 'CwHypNative',
+ 'CosmosIbc', // IBC tokens often represent collateral
+ 'StarknetHypCollateral',
+ 'StarknetHypNative',
+];
+
+/**
+ * Check if a token standard indicates a collateral-backed token
+ */
+export function isCollateralTokenStandard(standard: string | undefined): boolean {
+ if (!standard) return false;
+ return COLLATERAL_TOKEN_STANDARDS.includes(standard);
+}
diff --git a/src/features/transactions/MessageSummaryRow.tsx b/src/features/transactions/MessageSummaryRow.tsx
new file mode 100644
index 00000000..34c5dee6
--- /dev/null
+++ b/src/features/transactions/MessageSummaryRow.tsx
@@ -0,0 +1,234 @@
+import { toTitleCase, trimToLength } from '@hyperlane-xyz/utils';
+import { ChevronIcon, SpinnerIcon } from '@hyperlane-xyz/widgets';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useEffect, useMemo, useState } from 'react';
+
+import { ChainLogo } from '../../components/icons/ChainLogo';
+import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
+import { useMultiProvider, useStore } from '../../store';
+import { Message, MessageStatus } from '../../types';
+import { getHumanReadableDuration } from '../../utils/time';
+import { getChainDisplayName } from '../chains/utils';
+import { ContentDetailsCard } from '../messages/cards/ContentDetailsCard';
+import { IcaDetailsCard } from '../messages/cards/IcaDetailsCard';
+import { DestinationTransactionCard } from '../messages/cards/TransactionCard';
+import { WarpTransferDetailsCard } from '../messages/cards/WarpTransferDetailsCard';
+import { decodeIcaBody, IcaMessageType, isIcaMessage } from '../messages/ica';
+import { parseWarpRouteMessageDetails } from '../messages/utils';
+
+interface Props {
+ message: Message;
+ index: number;
+ forceExpanded?: boolean;
+}
+
+type MessageType = 'warp' | 'ica-commitment' | 'ica-reveal' | 'ica-calls' | 'generic';
+
+export function MessageSummaryRow({ message, index, forceExpanded }: Props) {
+ const [isManuallyToggled, setIsManuallyToggled] = useState(false);
+ const [manualExpandState, setManualExpandState] = useState(false);
+
+ // Use forceExpanded unless user has manually toggled
+ const isExpanded = isManuallyToggled ? manualExpandState : (forceExpanded ?? false);
+
+ const handleToggle = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsManuallyToggled(true);
+ setManualExpandState((prev) => !prev);
+ };
+
+ // Reset manual toggle when forceExpanded changes
+ useEffect(() => {
+ setIsManuallyToggled(false);
+ }, [forceExpanded]);
+
+ const multiProvider = useMultiProvider();
+ const warpRouteChainAddressMap = useStore((s) => s.warpRouteChainAddressMap);
+
+ // Use message data directly from GraphQL - no additional RPC calls for performance
+ const { status, originDomainId, destinationDomainId, destination } = message;
+
+ // Parse warp route details
+ const warpRouteDetails = useMemo(
+ () => parseWarpRouteMessageDetails(message, warpRouteChainAddressMap, multiProvider),
+ [message, warpRouteChainAddressMap, multiProvider],
+ );
+
+ // Detect message type
+ const { messageType } = useMemo(() => {
+ // Check warp route first
+ if (warpRouteDetails) {
+ return { messageType: 'warp' as MessageType };
+ }
+
+ // Check ICA
+ if (isIcaMessage({ sender: message.sender, recipient: message.recipient })) {
+ const decoded = decodeIcaBody(message.body);
+ if (decoded) {
+ if (decoded.messageType === IcaMessageType.COMMITMENT) {
+ return { messageType: 'ica-commitment' as MessageType };
+ }
+ if (decoded.messageType === IcaMessageType.REVEAL) {
+ return { messageType: 'ica-reveal' as MessageType };
+ }
+ if (decoded.messageType === IcaMessageType.CALLS) {
+ return { messageType: 'ica-calls' as MessageType };
+ }
+ }
+ }
+
+ return { messageType: 'generic' as MessageType };
+ }, [message, warpRouteDetails]);
+
+ const originChainName = multiProvider.tryGetChainName(originDomainId) || 'Unknown';
+ const destinationChainName = multiProvider.tryGetChainName(destinationDomainId) || 'Unknown';
+
+ const duration = destination?.timestamp
+ ? getHumanReadableDuration(destination.timestamp - message.origin.timestamp, 2)
+ : undefined;
+
+ // Generate summary line based on message type
+ const summaryLine = useMemo(() => {
+ const route = `${getChainDisplayName(multiProvider, originChainName, true)} → ${getChainDisplayName(multiProvider, destinationChainName, true)}`;
+
+ switch (messageType) {
+ case 'warp':
+ return `${warpRouteDetails?.amount} ${warpRouteDetails?.originToken?.symbol} · ${route}`;
+ case 'ica-commitment': {
+ const decoded = decodeIcaBody(message.body);
+ return `${trimToLength(decoded?.commitment || message.msgId, 12)} · ${route}`;
+ }
+ case 'ica-reveal':
+ return `Reveal · ${route}`;
+ case 'ica-calls': {
+ const decoded = decodeIcaBody(message.body);
+ return `${decoded?.calls?.length || 0} calls · ${route}`;
+ }
+ default:
+ return route;
+ }
+ }, [
+ messageType,
+ warpRouteDetails,
+ multiProvider,
+ originChainName,
+ destinationChainName,
+ message,
+ ]);
+
+ // Generate title based on message type
+ const title = useMemo(() => {
+ switch (messageType) {
+ case 'warp':
+ return 'Warp Transfer';
+ case 'ica-commitment':
+ return 'Interchain Account Commitment';
+ case 'ica-reveal':
+ return 'Interchain Account Reveal';
+ case 'ica-calls':
+ return 'Interchain Account Calls';
+ default:
+ return 'Message';
+ }
+ }, [messageType]);
+
+ const isIcaMsg = messageType.startsWith('ica-');
+
+ return (
+
+ {/* Summary Row (always visible) */}
+
+
+
#{index + 1}
+
+
+
+ {title}
+ e.stopPropagation()}
+ className="text-xs text-primary-600 transition-colors hover:text-primary-500"
+ >
+ ↗
+
+
+
{summaryLine}
+
+
+
+
+
+
+
+
+ {/* Expanded Content */}
+ {isExpanded && (
+
+ {/* Destination Transaction Card */}
+
+
+ {/* Warp Transfer Details */}
+ {messageType === 'warp' && warpRouteDetails && (
+
+ )}
+
+ {/* ICA Details */}
+ {isIcaMsg && }
+
+ {/* Content Details - only show if no decoded content (warp/ICA) */}
+ {messageType === 'generic' && }
+
+ )}
+
+ );
+}
+
+function StatusBadge({ status, duration }: { status: MessageStatus; duration?: string }) {
+ if (status === MessageStatus.Delivered) {
+ return (
+
+
+
+ Delivered{duration && ` (${duration})`}
+
+
+ );
+ }
+
+ if (status === MessageStatus.Failing) {
+ return (
+
+ Failing
+
+ );
+ }
+
+ return (
+
+
+ {toTitleCase(status)}
+
+ );
+}
diff --git a/src/features/transactions/TransactionDetails.tsx b/src/features/transactions/TransactionDetails.tsx
new file mode 100644
index 00000000..369276a0
--- /dev/null
+++ b/src/features/transactions/TransactionDetails.tsx
@@ -0,0 +1,119 @@
+import { SpinnerIcon, Tooltip } from '@hyperlane-xyz/widgets';
+import { useState } from 'react';
+
+import { Card } from '../../components/layout/Card';
+import { SectionCard } from '../../components/layout/SectionCard';
+import { OriginTransactionCard } from '../messages/cards/TransactionCard';
+
+import { MessageSummaryRow } from './MessageSummaryRow';
+import { useTransactionMessagesQuery } from './useTransactionMessagesQuery';
+
+interface Props {
+ txHash: string;
+}
+
+export function TransactionDetails({ txHash }: Props) {
+ const [allExpanded, setAllExpanded] = useState(false);
+
+ const { isFetching, isError, hasRun, isMessagesFound, messageList, originInfo } =
+ useTransactionMessagesQuery(txHash);
+
+ // Loading state
+ if (isFetching && !hasRun) {
+ return (
+
+
+
+
+
Loading transaction messages...
+
+
+
+ );
+ }
+
+ // Error state
+ if (isError) {
+ return (
+
+
+
+
Error loading transaction
+
+ Please check the transaction hash and try again.
+
+
+
+
+ );
+ }
+
+ // Not found state
+ if (hasRun && !isMessagesFound) {
+ return (
+
+
+
+
No messages found
+
+ No Hyperlane messages were found for this transaction hash. The transaction may not
+ have dispatched any messages, or it may not be indexed yet.
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Origin Transaction Card - reuse existing component */}
+ {originInfo && (
+
+ )}
+
+ {/* Messages Section */}
+
+
+
+
+
+ {messageList.length > 1 && (
+
+ )}
+
+
+
+ {messageList.map((message, index) => (
+
+ ))}
+
+
+ {isFetching && (
+
+
+ Refreshing...
+
+ )}
+
+
+ );
+}
diff --git a/src/features/transactions/useTransactionMessagesQuery.ts b/src/features/transactions/useTransactionMessagesQuery.ts
new file mode 100644
index 00000000..567af0fa
--- /dev/null
+++ b/src/features/transactions/useTransactionMessagesQuery.ts
@@ -0,0 +1,84 @@
+import { useCallback, useMemo } from 'react';
+import { useQuery } from 'urql';
+
+import { useInterval } from '@hyperlane-xyz/widgets';
+
+import { useMultiProvider } from '../../store';
+import { Message } from '../../types';
+import { useScrapedDomains } from '../chains/queries/useScrapedChains';
+import { MessageIdentifierType, buildMessageQuery } from '../messages/queries/build';
+import { MessagesQueryResult } from '../messages/queries/fragments';
+import { parseMessageQueryResult } from '../messages/queries/parse';
+
+const TX_AUTO_REFRESH_DELAY = 10_000; // 10s
+const TX_QUERY_LIMIT = 1000; // Max messages per transaction
+
+/**
+ * Hook to query all messages dispatched in a single origin transaction.
+ * Returns full Message objects (not stubs) for detailed display.
+ */
+export function useTransactionMessagesQuery(txHash: string) {
+ const { scrapedDomains: scrapedChains } = useScrapedDomains();
+ const multiProvider = useMultiProvider();
+
+ // Build the GraphQL query for origin tx hash
+ const { query, variables } = useMemo(
+ () => buildMessageQuery(MessageIdentifierType.OriginTxHash, txHash, TX_QUERY_LIMIT, false),
+ [txHash],
+ );
+
+ // Execute query
+ const [result, reexecuteQuery] = useQuery({
+ query,
+ variables,
+ pause: !txHash,
+ });
+ const { data, fetching: isFetching, error } = result;
+
+ // Parse results into Message objects
+ const messageList = useMemo(() => {
+ const messages = parseMessageQueryResult(multiProvider, scrapedChains, data);
+ // Sort by nonce (ascending) for consistent ordering
+ return messages.sort((a, b) => a.nonce - b.nonce);
+ }, [multiProvider, scrapedChains, data]);
+
+ const isMessagesFound = messageList.length > 0;
+
+ // Check if all messages are delivered
+ const allDelivered = useMemo(
+ () => messageList.length > 0 && messageList.every((m) => m.destination),
+ [messageList],
+ );
+
+ // Setup interval to re-query (only if not all delivered)
+ const reExecutor = useCallback(() => {
+ if (!txHash || allDelivered || !isWindowVisible()) return;
+ reexecuteQuery({ requestPolicy: 'network-only' });
+ }, [reexecuteQuery, txHash, allDelivered]);
+ useInterval(reExecutor, TX_AUTO_REFRESH_DELAY);
+
+ // Extract common origin transaction info from the first message
+ const originInfo = useMemo(() => {
+ if (!messageList.length) return null;
+ const first = messageList[0];
+ return {
+ chainName: multiProvider.tryGetChainName(first.originDomainId) || 'Unknown',
+ domainId: first.originDomainId,
+ transaction: first.origin,
+ };
+ }, [messageList, multiProvider]);
+
+ return {
+ isFetching,
+ isError: !!error,
+ hasRun: !!data,
+ isMessagesFound,
+ messageList: messageList as Message[],
+ originInfo,
+ refetch: reExecutor,
+ };
+}
+
+function isWindowVisible() {
+ return document.visibilityState === 'visible';
+}
diff --git a/src/images/icons/account-star.svg b/src/images/icons/account-star.svg
index 78a9db0c..e2502ba9 100644
--- a/src/images/icons/account-star.svg
+++ b/src/images/icons/account-star.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/images/icons/checkmark-circle.svg b/src/images/icons/checkmark-circle.svg
index 3f3f3a2e..99ca1d63 100644
--- a/src/images/icons/checkmark-circle.svg
+++ b/src/images/icons/checkmark-circle.svg
@@ -1,4 +1,4 @@
-