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 {