From 1de7d8f568b17fcf7f61d490c9084a925793e1fc Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:37:59 +0000 Subject: [PATCH 1/6] feat: add tip-stage raw dispatch fallback for near-head messages --- .../deliveryStatus/fetchDeliveryStatus.ts | 13 ++ src/features/messages/MessageTable.tsx | 12 +- src/features/messages/queries/build.ts | 162 +++++++++++++++++- src/features/messages/queries/fragments.ts | 33 ++++ src/features/messages/queries/parse.ts | 133 +++++++++++++- src/features/messages/queries/serverFetch.ts | 48 +++++- .../messages/queries/useMessageQuery.ts | 95 ++++++++-- src/types.ts | 1 + 8 files changed, 472 insertions(+), 25 deletions(-) diff --git a/src/features/deliveryStatus/fetchDeliveryStatus.ts b/src/features/deliveryStatus/fetchDeliveryStatus.ts index 19c82e28..e044a249 100644 --- a/src/features/deliveryStatus/fetchDeliveryStatus.ts +++ b/src/features/deliveryStatus/fetchDeliveryStatus.ts @@ -68,6 +68,19 @@ export async function fetchDeliveryStatus( }; return result; } else { + // Tip/raw rows may lack full finalized fields (e.g. message body), so avoid + // deep debug that can generate false negatives. + if (message.isProvisional) { + const result: MessageDeliveryPendingResult = { + status: MessageStatus.Pending, + debugResult: { + status: MessageDebugStatus.NoErrorsFound, + description: 'Provisional tip-stage message; debug deferred until finalized.', + }, + }; + return result; + } + const debugResult = await debugMessage(multiProvider, registry, overrideChainMetadata, message); const messageStatus = debugResult.status === MessageDebugStatus.NoErrorsFound diff --git a/src/features/messages/MessageTable.tsx b/src/features/messages/MessageTable.tsx index a106cef2..36971e0d 100644 --- a/src/features/messages/MessageTable.tsx +++ b/src/features/messages/MessageTable.tsx @@ -117,7 +117,17 @@ export function MessageSummaryRow({ {shortenAddress(formattedTxHash)} - {getHumanReadableTimeString(origin.timestamp)} + + {getHumanReadableTimeString(origin.timestamp)} + {message.isProvisional && ( + + tip + + )} + searchValueToPostgresBytea(addr)) + .filter((addr): addr is string => !!addr); + + const variables: Record = { + search: searchValueToPostgresBytea(searchInput), + originChains, + destinationChains, + startTime, + endTime, + }; + + if (warpAddressesBytea.length > 0) { + variables.warpAddresses = warpAddressesBytea; + } + + const hasFilters = !!( + originDomainIdFilter || + destDomainIdFilter || + startTimeFilter || + endTimeFilter || + searchInput || + warpAddressesBytea.length > 0 + ); + const whereClauses = buildRawSearchWhereClauses(searchInput); + const originDomainWhereClause = buildRawDomainIdWhereClause( + originDomainIdFilter, + hasFilters, + 'origin', + mainnetDomainIds, + ); + const destinationDomainWhereClause = buildRawDomainIdWhereClause( + destDomainIdFilter, + hasFilters, + 'destination', + mainnetDomainIds, + ); + const warpRouteWhereClause = buildRawWarpRouteWhereClause(warpAddressesBytea); + + const queries = whereClauses.map( + (whereClause, i) => + `q${i}: raw_message_dispatch( + where: { + _and: [ + ${originDomainWhereClause} + ${destinationDomainWhereClause} + ${startTimeFilter ? '{time_updated: {_gte: $startTime}},' : ''} + ${endTimeFilter ? '{time_updated: {_lte: $endTime}},' : ''} + ${warpRouteWhereClause} + ${whereClause} + ] + }, + order_by: [{time_updated: desc}, {id: desc}], + limit: ${limit} + ) { + ${rawMessageDispatchFragment} + }`, + ); + + const variableDeclarations = [ + '$search: bytea', + '$originChains: [Int!]', + '$destinationChains: [Int!]', + '$startTime: timestamp', + '$endTime: timestamp', + ]; + if (warpAddressesBytea.length > 0) { + variableDeclarations.push('$warpAddresses: [bytea!]'); + } + + const query = `query (${variableDeclarations.join(', ')}) @cached(ttl: 5) { + ${queries.join('\n')} + }`; + return { query, variables }; +} + // Note: Only 'delivered' filter is applied at DB level. 'pending' uses client-side // filtering (see useMessageQuery.ts) because DB query for is_delivered=false is slow. function buildStatusWhereClause(statusFilter: MessageStatusFilter): string { @@ -185,6 +313,11 @@ function buildWarpRouteWhereClause(warpAddressesBytea: string[]): string { return '{_or: [{sender: {_in: $warpAddresses}}, {recipient: {_in: $warpAddresses}}]},'; } +function buildRawWarpRouteWhereClause(warpAddressesBytea: string[]): string { + if (warpAddressesBytea.length === 0) return ''; + return '{_or: [{sender: {_in: $warpAddresses}}, {recipient: {_in: $warpAddresses}}]},'; +} + function buildSearchWhereClauses(searchInput: string) { if (!searchInput) return ['']; @@ -205,6 +338,20 @@ function buildSearchWhereClauses(searchInput: string) { return clauses; } +function buildRawSearchWhereClauses(searchInput: string) { + if (!searchInput) return ['']; + + const clauses: string[] = []; + if (isAddress(searchInput)) { + clauses.push(`{sender: {_eq: $search}}`, `{recipient: {_eq: $search}}`); + } + if (isPotentiallyTransactionHash(searchInput)) { + clauses.push(`{origin_tx_hash: {_eq: $search}}`); + } + clauses.push(`{msg_id: {_eq: $search}}`); + return clauses; +} + function buildDomainIdWhereClause( domainId: number | null, hasFilters: boolean, @@ -220,3 +367,16 @@ function buildDomainIdWhereClause( // if domainId is not set but there are other filters, remove condition of filtering by mainnet chains return ''; } + +function buildRawDomainIdWhereClause( + domainId: number | null, + hasFilters: boolean, + fieldName: 'origin' | 'destination', + mainnetDomainIds: number[] = [], +) { + const dbField = `${fieldName}_domain`; + + if (!hasFilters) return `{${dbField}: {_in: [${mainnetDomainIds}]}},`; + if (domainId) return `{${dbField}: {_in: $${fieldName}Chains}},`; + return ''; +} diff --git a/src/features/messages/queries/fragments.ts b/src/features/messages/queries/fragments.ts index d9c51f79..09a66733 100644 --- a/src/features/messages/queries/fragments.ts +++ b/src/features/messages/queries/fragments.ts @@ -60,6 +60,22 @@ ${messageStubFragment} num_payments `; +export const rawMessageDispatchFragment = ` + id + msg_id + nonce + origin_domain + destination_domain + sender + recipient + origin_tx_hash + origin_block_hash + origin_block_height + origin_mailbox + time_created + time_updated +`; + /** * =================================== * FRAGMENT TYPES @@ -121,5 +137,22 @@ export interface MessageEntry extends MessageStubEntry { num_payments: number; } +export interface RawMessageDispatchEntry { + id: number; + msg_id: string; // binary e.g. \\x123 + nonce: number; + origin_domain: number; + destination_domain: number; + sender: string; // binary e.g. \\x123 + recipient: string; // binary e.g. \\x123 + origin_tx_hash: string; // binary e.g. \\x123 + origin_block_hash: string; // binary e.g. \\x123 + origin_block_height: number; + origin_mailbox: string; // binary e.g. \\x123 + time_created: string; // e.g. "2022-08-28T17:30:15" + time_updated: string; // e.g. "2022-08-28T17:30:15" +} + export type MessagesStubQueryResult = Record; export type MessagesQueryResult = Record; +export type RawMessagesQueryResult = Record; diff --git a/src/features/messages/queries/parse.ts b/src/features/messages/queries/parse.ts index aad30bf7..9d750a47 100644 --- a/src/features/messages/queries/parse.ts +++ b/src/features/messages/queries/parse.ts @@ -8,6 +8,8 @@ import { postgresByteaToAddress, postgresByteaToString, postgresByteaToTxHash } import { MessageEntry, MessagesQueryResult, + RawMessagesQueryResult, + RawMessageDispatchEntry, MessagesStubQueryResult, MessageStubEntry, } from './fragments'; @@ -35,6 +37,22 @@ export function parseMessageQueryResult( return queryResult(multiProvider, scrapedChains, data, parseMessage); } +export function parseRawMessageStubResult( + multiProvider: MultiProtocolProvider, + scrapedChains: DomainsEntry[], + data: RawMessagesQueryResult | undefined, +): MessageStub[] { + return queryResult(multiProvider, scrapedChains, data, parseRawMessageStub); +} + +export function parseRawMessageQueryResult( + multiProvider: MultiProtocolProvider, + scrapedChains: DomainsEntry[], + data: RawMessagesQueryResult | undefined, +): Message[] { + return queryResult(multiProvider, scrapedChains, data, parseRawMessage); +} + function queryResult( multiProvider: MultiProtocolProvider, scrapedChains: DomainsEntry[], @@ -96,6 +114,7 @@ function parseMessageStub( } : undefined, isPiMsg, + isProvisional: false, }; } catch (error) { logger.error('Error parsing message stub', error); @@ -160,6 +179,92 @@ function parseMessage( } } +function parseRawMessageStub( + multiProvider: MultiProtocolProvider, + scrapedChains: DomainsEntry[], + m: RawMessageDispatchEntry, +): MessageStub | null { + try { + const originDomainId = m.origin_domain; + const destinationDomainId = m.destination_domain; + + const originMetadata = multiProvider.tryGetChainMetadata(originDomainId); + const destinationMetadata = multiProvider.tryGetChainMetadata(destinationDomainId); + + const isPiMsg = + isPiChain(multiProvider, scrapedChains, originDomainId) || + isPiChain(multiProvider, scrapedChains, destinationDomainId); + + const sender = postgresByteaToAddress(m.sender, originMetadata); + const originMailbox = postgresByteaToAddress(m.origin_mailbox, originMetadata); + + return { + status: MessageStatus.Pending, + id: `raw-${m.id}`, + msgId: postgresByteaToString(m.msg_id), + nonce: m.nonce, + sender, + recipient: postgresByteaToAddress(m.recipient, destinationMetadata), + originChainId: chainIdForDomain(multiProvider, originDomainId), + originDomainId, + destinationChainId: chainIdForDomain(multiProvider, destinationDomainId), + destinationDomainId, + body: '', + origin: { + timestamp: parseTimestampString(m.time_updated || m.time_created), + hash: postgresByteaToTxHash(m.origin_tx_hash, originMetadata), + from: sender, + to: originMailbox, + }, + destination: undefined, + isPiMsg, + isProvisional: true, + }; + } catch (error) { + logger.error('Error parsing raw message stub', error); + return null; + } +} + +function parseRawMessage( + multiProvider: MultiProtocolProvider, + scrapedChains: DomainsEntry[], + m: RawMessageDispatchEntry, +): Message | null { + try { + const stub = parseRawMessageStub(multiProvider, scrapedChains, m); + if (!stub) throw new Error('Raw message stub required'); + + const originMetadata = multiProvider.tryGetChainMetadata(m.origin_domain); + + return { + ...stub, + decodedBody: undefined, + origin: { + ...stub.origin, + blockHash: postgresByteaToString(m.origin_block_hash), + blockNumber: m.origin_block_height, + mailbox: postgresByteaToAddress(m.origin_mailbox, originMetadata), + nonce: 0, + gasLimit: 0, + gasPrice: 0, + effectiveGasPrice: 0, + gasUsed: 0, + cumulativeGasUsed: 0, + maxFeePerGas: 0, + maxPriorityPerGas: 0, + }, + destination: undefined, + totalGasAmount: undefined, + totalPayment: undefined, + numPayments: undefined, + }; + } catch (error) { + logger.error('Error parsing raw message', error); + return null; + } +} + function parseTimestampString(t: string) { const asUtc = t.at(-1) === 'Z' ? t : t + 'Z'; return new Date(asUtc).getTime(); @@ -175,9 +280,33 @@ function getMessageStatus(m: MessageEntry | MessageStubEntry) { } function deduplicateMessageList(messages: Array): Array { - const map = new Map(); + const map = new Map(); for (const item of messages) { - map.set(item.id, item); + const existing = map.get(item.msgId); + if (!existing) { + map.set(item.msgId, item); + continue; + } + if (shouldReplaceWithCandidate(existing, item)) { + map.set(item.msgId, item); + } } return Array.from(map.values()); } + +function shouldReplaceWithCandidate(current: MessageStub, candidate: MessageStub): boolean { + return scoreMessage(candidate) > scoreMessage(current); +} + +function scoreMessage(m: MessageStub): number { + let score = 0; + if (!m.isProvisional) score += 100; + if (m.status === MessageStatus.Delivered) score += 50; + if (m.destination) score += 25; + if (m.body && m.body !== '0x') score += 5; + return score; +} + +function chainIdForDomain(multiProvider: MultiProtocolProvider, domainId: number) { + return multiProvider.tryGetChainMetadata(domainId)?.chainId || domainId; +} diff --git a/src/features/messages/queries/serverFetch.ts b/src/features/messages/queries/serverFetch.ts index a174bb8a..bc022f76 100644 --- a/src/features/messages/queries/serverFetch.ts +++ b/src/features/messages/queries/serverFetch.ts @@ -2,7 +2,13 @@ import { config } from '../../../consts/config'; import { logger } from '../../../utils/logger'; import { postgresByteaToString, stringToPostgresBytea } from './encoding'; -import { MessagesStubQueryResult, MessageStubEntry, messageStubFragment } from './fragments'; +import { + MessagesStubQueryResult, + MessageStubEntry, + RawMessageDispatchEntry, + rawMessageDispatchFragment, + messageStubFragment, +} from './fragments'; /** * Server-side utility to fetch message data from GraphQL for OG meta tags @@ -21,6 +27,13 @@ export async function fetchMessageForOG(messageId: string): Promise({ query, variables, pause: !isValidInput, }); - const { data, fetching: isFetching, error } = result; + const { data, fetching: isFinalizedFetching, error: finalizedError } = result; + const [rawResult, reexecuteRawQuery] = useQuery({ + query: rawQueryConfig.query, + variables: rawQueryConfig.variables, + pause: !shouldQueryRaw || !isValidInput, + }); + const { data: rawData, fetching: isRawFetching, error: rawError } = rawResult; // Parse results - const unfilteredMessageList = useMemo( + const finalizedMessageList = useMemo( () => parseMessageStubResult(multiProvider, scrapedChains, data), [multiProvider, scrapedChains, data], ); + const rawMessageList = useMemo( + () => parseRawMessageStubResult(multiProvider, scrapedChains, rawData), + [multiProvider, scrapedChains, rawData], + ); + const unfilteredMessageList = useMemo(() => { + if (!rawMessageList.length) return finalizedMessageList; + const map = new Map(rawMessageList.map((m) => [m.msgId, m])); + for (const msg of finalizedMessageList) { + map.set(msg.msgId, msg); + } + return Array.from(map.values()).sort((a, b) => b.origin.timestamp - a.origin.timestamp); + }, [finalizedMessageList, rawMessageList]); // Apply client-side pending filter if needed const messageList = useMemo(() => { @@ -111,16 +153,19 @@ export function useMessageSearchQuery( const refresh = useCallback(() => { if (!query || !isValidInput || !isWindowVisible()) return; reexecuteQuery({ requestPolicy: 'network-only' }); - }, [reexecuteQuery, query, isValidInput]); + if (shouldQueryRaw) { + reexecuteRawQuery({ requestPolicy: 'network-only' }); + } + }, [reexecuteQuery, reexecuteRawQuery, query, isValidInput, shouldQueryRaw]); useInterval(refresh, SEARCH_AUTO_REFRESH_DELAY); return { isValidInput, isValidOrigin, isValidDestination, - isFetching, - isError: !!error, - hasRun: !!data, + isFetching: isFinalizedFetching || isRawFetching, + isError: !!finalizedError || !!rawError, + hasRun: !!data || !!rawData, isMessagesFound, messageList, refetch: refresh, @@ -132,20 +177,37 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause // Assemble GraphQL Query const { query, variables } = buildMessageQuery(MessageIdentifierType.Id, messageId, 1); + const rawQueryConfig = buildRawMessageQuery(MessageIdentifierType.Id, messageId, 1); // Execute query - const [{ data, fetching: isFetching, error }, reexecuteQuery] = useQuery({ + const [ + { data, fetching: isFinalizedFetching, error: finalizedError }, + reexecuteQuery, + ] = useQuery({ query, variables, pause, }); + const [ + { data: rawData, fetching: isRawFetching, error: rawError }, + reexecuteRawQuery, + ] = useQuery({ + query: rawQueryConfig?.query || '', + variables: rawQueryConfig?.variables || {}, + pause: pause || !rawQueryConfig, + }); // Parse results const multiProvider = useMultiProvider(); - const messageList = useMemo( + const finalizedMessageList = useMemo( () => parseMessageQueryResult(multiProvider, scrapedChains, data), [multiProvider, scrapedChains, data], ); + const rawMessageList = useMemo( + () => parseRawMessageQueryResult(multiProvider, scrapedChains, rawData), + [multiProvider, scrapedChains, rawData], + ); + const messageList = finalizedMessageList.length ? finalizedMessageList : rawMessageList; const isMessageFound = messageList.length > 0; const message = isMessageFound ? messageList[0] : null; const msgStatus = message?.status; @@ -155,13 +217,14 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause const reExecutor = useCallback(() => { if (pause || isDelivered || !isWindowVisible()) return; reexecuteQuery({ requestPolicy: 'network-only' }); - }, [pause, isDelivered, reexecuteQuery]); + reexecuteRawQuery({ requestPolicy: 'network-only' }); + }, [pause, isDelivered, reexecuteQuery, reexecuteRawQuery]); useInterval(reExecutor, MSG_AUTO_REFRESH_DELAY); return { - isFetching, - isError: !!error, - hasRun: !!data, + isFetching: isFinalizedFetching || isRawFetching, + isError: !!finalizedError || !!rawError, + hasRun: !!data || !!rawData, isMessageFound, message, }; diff --git a/src/types.ts b/src/types.ts index 0bb262fe..b62c45a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export interface MessageStub { status: MessageStatus; id: string; // Database id msgId: string; // Message hash + isProvisional?: boolean; // True when sourced from tip/raw table (not finalized) nonce: number; // formerly leafIndex sender: Address; recipient: Address; From 98771503f3d3febe29855c75c5ba48410cf44194 Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:03:10 +0000 Subject: [PATCH 2/6] Address PR feedback for raw fallback message queries --- src/features/messages/queries/build.ts | 7 +-- src/features/messages/queries/parse.ts | 17 +++--- src/features/messages/queries/serverFetch.ts | 2 +- .../messages/queries/useMessageQuery.ts | 57 ++++++++++--------- 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/src/features/messages/queries/build.ts b/src/features/messages/queries/build.ts index ba04bed2..4c211cde 100644 --- a/src/features/messages/queries/build.ts +++ b/src/features/messages/queries/build.ts @@ -259,7 +259,7 @@ export function buildRawMessageSearchQuery( 'destination', mainnetDomainIds, ); - const warpRouteWhereClause = buildRawWarpRouteWhereClause(warpAddressesBytea); + const warpRouteWhereClause = buildWarpRouteWhereClause(warpAddressesBytea); const queries = whereClauses.map( (whereClause, i) => @@ -313,11 +313,6 @@ function buildWarpRouteWhereClause(warpAddressesBytea: string[]): string { return '{_or: [{sender: {_in: $warpAddresses}}, {recipient: {_in: $warpAddresses}}]},'; } -function buildRawWarpRouteWhereClause(warpAddressesBytea: string[]): string { - if (warpAddressesBytea.length === 0) return ''; - return '{_or: [{sender: {_in: $warpAddresses}}, {recipient: {_in: $warpAddresses}}]},'; -} - function buildSearchWhereClauses(searchInput: string) { if (!searchInput) return ['']; diff --git a/src/features/messages/queries/parse.ts b/src/features/messages/queries/parse.ts index 9d750a47..7d389501 100644 --- a/src/features/messages/queries/parse.ts +++ b/src/features/messages/queries/parse.ts @@ -8,10 +8,10 @@ import { postgresByteaToAddress, postgresByteaToString, postgresByteaToTxHash } import { MessageEntry, MessagesQueryResult, - RawMessagesQueryResult, - RawMessageDispatchEntry, MessagesStubQueryResult, MessageStubEntry, + RawMessageDispatchEntry, + RawMessagesQueryResult, } from './fragments'; /** @@ -53,6 +53,10 @@ export function parseRawMessageQueryResult( return queryResult(multiProvider, scrapedChains, data, parseRawMessage); } +export function mergeMessageStubs(messages: Array): Array { + return deduplicateMessageList(messages).sort((a, b) => b.origin.timestamp - a.origin.timestamp); +} + function queryResult( multiProvider: MultiProtocolProvider, scrapedChains: DomainsEntry[], @@ -64,12 +68,11 @@ function queryResult( ) => M | null, ) { if (!data || !Object.keys(data).length) return []; - return deduplicateMessageList( + return mergeMessageStubs( Object.values(data) .flat() .map((d) => parseFn(multiProvider, scrapedChains, d)) - .filter((m): m is M => !!m) - .sort((a, b) => b.origin.timestamp - a.origin.timestamp), + .filter((m): m is M => !!m), ); } @@ -211,7 +214,7 @@ function parseRawMessageStub( destinationDomainId, body: '', origin: { - timestamp: parseTimestampString(m.time_updated || m.time_created), + timestamp: parseTimestampString(m.time_updated ?? m.time_created), hash: postgresByteaToTxHash(m.origin_tx_hash, originMetadata), from: sender, to: originMailbox, @@ -303,7 +306,7 @@ function scoreMessage(m: MessageStub): number { if (!m.isProvisional) score += 100; if (m.status === MessageStatus.Delivered) score += 50; if (m.destination) score += 25; - if (m.body && m.body !== '0x') score += 5; + if (m.body.length > 0 && m.body !== '0x') score += 5; return score; } diff --git a/src/features/messages/queries/serverFetch.ts b/src/features/messages/queries/serverFetch.ts index bc022f76..0a8b449b 100644 --- a/src/features/messages/queries/serverFetch.ts +++ b/src/features/messages/queries/serverFetch.ts @@ -5,9 +5,9 @@ import { postgresByteaToString, stringToPostgresBytea } from './encoding'; import { MessagesStubQueryResult, MessageStubEntry, + messageStubFragment, RawMessageDispatchEntry, rawMessageDispatchFragment, - messageStubFragment, } from './fragments'; /** diff --git a/src/features/messages/queries/useMessageQuery.ts b/src/features/messages/queries/useMessageQuery.ts index 2d4ed7f6..77f772e2 100644 --- a/src/features/messages/queries/useMessageQuery.ts +++ b/src/features/messages/queries/useMessageQuery.ts @@ -14,9 +14,10 @@ import { buildRawMessageQuery, buildRawMessageSearchQuery, } from './build'; -import { isPotentiallyTransactionHash, searchValueToPostgresBytea } from './encoding'; +import { searchValueToPostgresBytea } from './encoding'; import { MessagesQueryResult, MessagesStubQueryResult, RawMessagesQueryResult } from './fragments'; import { + mergeMessageStubs, parseMessageQueryResult, parseMessageStubResult, parseRawMessageQueryResult, @@ -95,7 +96,9 @@ export function useMessageSearchQuery( ); const shouldQueryRaw = - dbStatusFilter !== 'delivered' && hasInput && isPotentiallyTransactionHash(sanitizedInput); + dbStatusFilter !== 'delivered' && + hasInput && + isPotentiallyMessageOrTransactionHash(sanitizedInput); const rawQueryConfig = buildRawMessageSearchQuery( sanitizedInput, isValidOrigin ? originDomainId : null, @@ -130,14 +133,10 @@ export function useMessageSearchQuery( () => parseRawMessageStubResult(multiProvider, scrapedChains, rawData), [multiProvider, scrapedChains, rawData], ); - const unfilteredMessageList = useMemo(() => { - if (!rawMessageList.length) return finalizedMessageList; - const map = new Map(rawMessageList.map((m) => [m.msgId, m])); - for (const msg of finalizedMessageList) { - map.set(msg.msgId, msg); - } - return Array.from(map.values()).sort((a, b) => b.origin.timestamp - a.origin.timestamp); - }, [finalizedMessageList, rawMessageList]); + const unfilteredMessageList = useMemo( + () => mergeMessageStubs([...rawMessageList, ...finalizedMessageList]), + [finalizedMessageList, rawMessageList], + ); // Apply client-side pending filter if needed const messageList = useMemo(() => { @@ -180,22 +179,18 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause const rawQueryConfig = buildRawMessageQuery(MessageIdentifierType.Id, messageId, 1); // Execute query - const [ - { data, fetching: isFinalizedFetching, error: finalizedError }, - reexecuteQuery, - ] = useQuery({ - query, - variables, - pause, - }); - const [ - { data: rawData, fetching: isRawFetching, error: rawError }, - reexecuteRawQuery, - ] = useQuery({ - query: rawQueryConfig?.query || '', - variables: rawQueryConfig?.variables || {}, - pause: pause || !rawQueryConfig, - }); + const [{ data, fetching: isFinalizedFetching, error: finalizedError }, reexecuteQuery] = + useQuery({ + query, + variables, + pause, + }); + const [{ data: rawData, fetching: isRawFetching, error: rawError }, reexecuteRawQuery] = + useQuery({ + query: rawQueryConfig?.query || '', + variables: rawQueryConfig?.variables || {}, + pause: pause || !rawQueryConfig, + }); // Parse results const multiProvider = useMultiProvider(); @@ -217,8 +212,10 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause const reExecutor = useCallback(() => { if (pause || isDelivered || !isWindowVisible()) return; reexecuteQuery({ requestPolicy: 'network-only' }); - reexecuteRawQuery({ requestPolicy: 'network-only' }); - }, [pause, isDelivered, reexecuteQuery, reexecuteRawQuery]); + if (rawQueryConfig) { + reexecuteRawQuery({ requestPolicy: 'network-only' }); + } + }, [pause, isDelivered, rawQueryConfig, reexecuteQuery, reexecuteRawQuery]); useInterval(reExecutor, MSG_AUTO_REFRESH_DELAY); return { @@ -233,3 +230,7 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause function isWindowVisible() { return document.visibilityState === 'visible'; } + +function isPotentiallyMessageOrTransactionHash(input: string) { + return /^0x[a-fA-F0-9]{64}$/.test(input); +} From b3e85ad46cecf8db26b17df26ab770b64d6b9d52 Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:30:57 +0000 Subject: [PATCH 3/6] Harden raw message dedupe and timestamp parsing --- src/features/messages/queries/fragments.ts | 4 ++-- src/features/messages/queries/parse.ts | 16 +++++++++++----- src/features/messages/queries/serverFetch.ts | 11 ++++++++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/features/messages/queries/fragments.ts b/src/features/messages/queries/fragments.ts index 09a66733..0f4babba 100644 --- a/src/features/messages/queries/fragments.ts +++ b/src/features/messages/queries/fragments.ts @@ -149,8 +149,8 @@ export interface RawMessageDispatchEntry { origin_block_hash: string; // binary e.g. \\x123 origin_block_height: number; origin_mailbox: string; // binary e.g. \\x123 - time_created: string; // e.g. "2022-08-28T17:30:15" - time_updated: string; // e.g. "2022-08-28T17:30:15" + time_created: string | null; // e.g. "2022-08-28T17:30:15" + time_updated: string | null; // e.g. "2022-08-28T17:30:15" } export type MessagesStubQueryResult = Record; diff --git a/src/features/messages/queries/parse.ts b/src/features/messages/queries/parse.ts index 7d389501..9bdf438b 100644 --- a/src/features/messages/queries/parse.ts +++ b/src/features/messages/queries/parse.ts @@ -53,7 +53,7 @@ export function parseRawMessageQueryResult( return queryResult(multiProvider, scrapedChains, data, parseRawMessage); } -export function mergeMessageStubs(messages: Array): Array { +export function mergeMessageStubs(messages: Array): Array { return deduplicateMessageList(messages).sort((a, b) => b.origin.timestamp - a.origin.timestamp); } @@ -268,9 +268,11 @@ function parseRawMessage( } } -function parseTimestampString(t: string) { - const asUtc = t.at(-1) === 'Z' ? t : t + 'Z'; - return new Date(asUtc).getTime(); +function parseTimestampString(t: string | null | undefined) { + if (!t) return 0; + const asUtc = t.at(-1) === 'Z' ? t : `${t}Z`; + const millis = new Date(asUtc).getTime(); + return Number.isFinite(millis) ? millis : 0; } function getMessageStatus(m: MessageEntry | MessageStubEntry) { @@ -298,7 +300,11 @@ function deduplicateMessageList(messages: Array): Arra } function shouldReplaceWithCandidate(current: MessageStub, candidate: MessageStub): boolean { - return scoreMessage(candidate) > scoreMessage(current); + const currentScore = scoreMessage(current); + const candidateScore = scoreMessage(candidate); + if (candidateScore !== currentScore) return candidateScore > currentScore; + // If scores tie, keep the freshest row (helps when raw rows tie on score). + return candidate.origin.timestamp > current.origin.timestamp; } function scoreMessage(m: MessageStub): number { diff --git a/src/features/messages/queries/serverFetch.ts b/src/features/messages/queries/serverFetch.ts index 0a8b449b..288824d1 100644 --- a/src/features/messages/queries/serverFetch.ts +++ b/src/features/messages/queries/serverFetch.ts @@ -101,15 +101,13 @@ function parseMessageForOG(message: MessageStubEntry): MessageOGData { } function parseRawMessageForOG(message: RawMessageDispatchEntry): MessageOGData { - const rawTimestamp = message.time_updated || message.time_created; - const timestampWithZone = rawTimestamp.at(-1) === 'Z' ? rawTimestamp : `${rawTimestamp}Z`; return { msgId: postgresByteaToString(message.msg_id), status: 'Pending', originDomainId: message.origin_domain, destinationDomainId: message.destination_domain, originTxHash: postgresByteaToString(message.origin_tx_hash), - timestamp: new Date(timestampWithZone).getTime(), + timestamp: parseTimestampMillis(message.time_updated ?? message.time_created), sender: postgresByteaToString(message.sender), recipient: postgresByteaToString(message.recipient), body: null, @@ -117,6 +115,13 @@ function parseRawMessageForOG(message: RawMessageDispatchEntry): MessageOGData { }; } +function parseTimestampMillis(timestamp: string | null | undefined): number { + if (!timestamp) return 0; + const timestampWithZone = timestamp.at(-1) === 'Z' ? timestamp : `${timestamp}Z`; + const millis = new Date(timestampWithZone).getTime(); + return Number.isFinite(millis) ? millis : 0; +} + /** * Fetch chain names from the domains table */ From 4e6e1f41dd1a50a03b51b36ca7450ce938d8735b Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:46:43 +0000 Subject: [PATCH 4/6] dedupe timestamp parsing in message queries --- src/features/messages/queries/parse.ts | 14 ++++---------- src/features/messages/queries/serverFetch.ts | 8 +------- src/features/messages/queries/timestamp.ts | 6 ++++++ 3 files changed, 11 insertions(+), 17 deletions(-) create mode 100644 src/features/messages/queries/timestamp.ts diff --git a/src/features/messages/queries/parse.ts b/src/features/messages/queries/parse.ts index 9bdf438b..5124f4fa 100644 --- a/src/features/messages/queries/parse.ts +++ b/src/features/messages/queries/parse.ts @@ -13,6 +13,7 @@ import { RawMessageDispatchEntry, RawMessagesQueryResult, } from './fragments'; +import { parseTimestampMillis } from './timestamp'; /** * ======================== @@ -103,14 +104,14 @@ function parseMessageStub( destinationDomainId: m.destination_domain_id, body, origin: { - timestamp: parseTimestampString(m.send_occurred_at), + timestamp: parseTimestampMillis(m.send_occurred_at), hash: postgresByteaToTxHash(m.origin_tx_hash, originMetadata), from: postgresByteaToAddress(m.origin_tx_sender, originMetadata), to: postgresByteaToAddress(m.origin_tx_recipient, originMetadata), }, destination: m.is_delivered ? { - timestamp: parseTimestampString(m.delivery_occurred_at!), + timestamp: parseTimestampMillis(m.delivery_occurred_at!), hash: postgresByteaToTxHash(m.destination_tx_hash!, destinationMetadata), from: postgresByteaToAddress(m.destination_tx_sender!, destinationMetadata), to: postgresByteaToAddress(m.destination_tx_recipient!, destinationMetadata), @@ -214,7 +215,7 @@ function parseRawMessageStub( destinationDomainId, body: '', origin: { - timestamp: parseTimestampString(m.time_updated ?? m.time_created), + timestamp: parseTimestampMillis(m.time_updated ?? m.time_created), hash: postgresByteaToTxHash(m.origin_tx_hash, originMetadata), from: sender, to: originMailbox, @@ -268,13 +269,6 @@ function parseRawMessage( } } -function parseTimestampString(t: string | null | undefined) { - if (!t) return 0; - const asUtc = t.at(-1) === 'Z' ? t : `${t}Z`; - const millis = new Date(asUtc).getTime(); - return Number.isFinite(millis) ? millis : 0; -} - function getMessageStatus(m: MessageEntry | MessageStubEntry) { if (m.is_delivered) { return MessageStatus.Delivered; diff --git a/src/features/messages/queries/serverFetch.ts b/src/features/messages/queries/serverFetch.ts index 288824d1..1040a8d5 100644 --- a/src/features/messages/queries/serverFetch.ts +++ b/src/features/messages/queries/serverFetch.ts @@ -9,6 +9,7 @@ import { RawMessageDispatchEntry, rawMessageDispatchFragment, } from './fragments'; +import { parseTimestampMillis } from './timestamp'; /** * Server-side utility to fetch message data from GraphQL for OG meta tags @@ -115,13 +116,6 @@ function parseRawMessageForOG(message: RawMessageDispatchEntry): MessageOGData { }; } -function parseTimestampMillis(timestamp: string | null | undefined): number { - if (!timestamp) return 0; - const timestampWithZone = timestamp.at(-1) === 'Z' ? timestamp : `${timestamp}Z`; - const millis = new Date(timestampWithZone).getTime(); - return Number.isFinite(millis) ? millis : 0; -} - /** * Fetch chain names from the domains table */ diff --git a/src/features/messages/queries/timestamp.ts b/src/features/messages/queries/timestamp.ts new file mode 100644 index 00000000..d1c38c17 --- /dev/null +++ b/src/features/messages/queries/timestamp.ts @@ -0,0 +1,6 @@ +export function parseTimestampMillis(timestamp: string | null | undefined): number { + if (!timestamp) return 0; + const timestampWithZone = timestamp.at(-1) === 'Z' ? timestamp : `${timestamp}Z`; + const millis = new Date(timestampWithZone).getTime(); + return Number.isFinite(millis) ? millis : 0; +} From f14c016c7fd6ab60b7f08ba7b9274059b45056d5 Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:25:05 +0000 Subject: [PATCH 5/6] gate raw search results on shouldQueryRaw --- src/features/messages/queries/useMessageQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/messages/queries/useMessageQuery.ts b/src/features/messages/queries/useMessageQuery.ts index 77f772e2..74912f19 100644 --- a/src/features/messages/queries/useMessageQuery.ts +++ b/src/features/messages/queries/useMessageQuery.ts @@ -130,8 +130,8 @@ export function useMessageSearchQuery( [multiProvider, scrapedChains, data], ); const rawMessageList = useMemo( - () => parseRawMessageStubResult(multiProvider, scrapedChains, rawData), - [multiProvider, scrapedChains, rawData], + () => (shouldQueryRaw ? parseRawMessageStubResult(multiProvider, scrapedChains, rawData) : []), + [multiProvider, scrapedChains, rawData, shouldQueryRaw], ); const unfilteredMessageList = useMemo( () => mergeMessageStubs([...rawMessageList, ...finalizedMessageList]), From 37136567d00fd6e102ae38fe41fe87249c86c4f2 Mon Sep 17 00:00:00 2001 From: pbio <10051819+paulbalaji@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:42:20 +0000 Subject: [PATCH 6/6] stabilize raw query deps in message hook --- src/features/messages/queries/useMessageQuery.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/features/messages/queries/useMessageQuery.ts b/src/features/messages/queries/useMessageQuery.ts index 74912f19..d66bc7a9 100644 --- a/src/features/messages/queries/useMessageQuery.ts +++ b/src/features/messages/queries/useMessageQuery.ts @@ -176,7 +176,11 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause // Assemble GraphQL Query const { query, variables } = buildMessageQuery(MessageIdentifierType.Id, messageId, 1); - const rawQueryConfig = buildRawMessageQuery(MessageIdentifierType.Id, messageId, 1); + const rawQueryConfig = useMemo( + () => buildRawMessageQuery(MessageIdentifierType.Id, messageId, 1), + [messageId], + ); + const hasRawQuery = !!rawQueryConfig; // Execute query const [{ data, fetching: isFinalizedFetching, error: finalizedError }, reexecuteQuery] = @@ -189,7 +193,7 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause useQuery({ query: rawQueryConfig?.query || '', variables: rawQueryConfig?.variables || {}, - pause: pause || !rawQueryConfig, + pause: pause || !hasRawQuery, }); // Parse results @@ -212,10 +216,10 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause const reExecutor = useCallback(() => { if (pause || isDelivered || !isWindowVisible()) return; reexecuteQuery({ requestPolicy: 'network-only' }); - if (rawQueryConfig) { + if (hasRawQuery) { reexecuteRawQuery({ requestPolicy: 'network-only' }); } - }, [pause, isDelivered, rawQueryConfig, reexecuteQuery, reexecuteRawQuery]); + }, [pause, isDelivered, hasRawQuery, reexecuteQuery, reexecuteRawQuery]); useInterval(reExecutor, MSG_AUTO_REFRESH_DELAY); return {