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 = buildWarpRouteWhereClause(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 {
@@ -205,6 +333,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 +362,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..0f4babba 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 | null; // e.g. "2022-08-28T17:30:15"
+ time_updated: string | null; // 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..5124f4fa 100644
--- a/src/features/messages/queries/parse.ts
+++ b/src/features/messages/queries/parse.ts
@@ -10,7 +10,10 @@ import {
MessagesQueryResult,
MessagesStubQueryResult,
MessageStubEntry,
+ RawMessageDispatchEntry,
+ RawMessagesQueryResult,
} from './fragments';
+import { parseTimestampMillis } from './timestamp';
/**
* ========================
@@ -35,6 +38,26 @@ 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);
+}
+
+export function mergeMessageStubs(messages: Array): Array {
+ return deduplicateMessageList(messages).sort((a, b) => b.origin.timestamp - a.origin.timestamp);
+}
+
function queryResult(
multiProvider: MultiProtocolProvider,
scrapedChains: DomainsEntry[],
@@ -46,12 +69,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),
);
}
@@ -82,20 +104,21 @@ 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),
}
: undefined,
isPiMsg,
+ isProvisional: false,
};
} catch (error) {
logger.error('Error parsing message stub', error);
@@ -160,9 +183,90 @@ function parseMessage(
}
}
-function parseTimestampString(t: string) {
- const asUtc = t.at(-1) === 'Z' ? t : t + 'Z';
- return new Date(asUtc).getTime();
+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: parseTimestampMillis(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 getMessageStatus(m: MessageEntry | MessageStubEntry) {
@@ -175,9 +279,37 @@ 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 {
+ 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 {
+ let score = 0;
+ if (!m.isProvisional) score += 100;
+ if (m.status === MessageStatus.Delivered) score += 50;
+ if (m.destination) score += 25;
+ if (m.body.length > 0 && 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..1040a8d5 100644
--- a/src/features/messages/queries/serverFetch.ts
+++ b/src/features/messages/queries/serverFetch.ts
@@ -2,7 +2,14 @@ import { config } from '../../../consts/config';
import { logger } from '../../../utils/logger';
import { postgresByteaToString, stringToPostgresBytea } from './encoding';
-import { MessagesStubQueryResult, MessageStubEntry, messageStubFragment } from './fragments';
+import {
+ MessagesStubQueryResult,
+ MessageStubEntry,
+ messageStubFragment,
+ RawMessageDispatchEntry,
+ rawMessageDispatchFragment,
+} from './fragments';
+import { parseTimestampMillis } from './timestamp';
/**
* Server-side utility to fetch message data from GraphQL for OG meta tags
@@ -21,6 +28,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(
+ () => (shouldQueryRaw ? parseRawMessageStubResult(multiProvider, scrapedChains, rawData) : []),
+ [multiProvider, scrapedChains, rawData, shouldQueryRaw],
+ );
+ const unfilteredMessageList = useMemo(
+ () => mergeMessageStubs([...rawMessageList, ...finalizedMessageList]),
+ [finalizedMessageList, rawMessageList],
+ );
// Apply client-side pending filter if needed
const messageList = useMemo(() => {
@@ -111,16 +152,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 +176,37 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause
// Assemble GraphQL Query
const { query, variables } = buildMessageQuery(MessageIdentifierType.Id, messageId, 1);
+ const rawQueryConfig = useMemo(
+ () => buildRawMessageQuery(MessageIdentifierType.Id, messageId, 1),
+ [messageId],
+ );
+ const hasRawQuery = !!rawQueryConfig;
// Execute query
- const [{ data, fetching: isFetching, error }, reexecuteQuery] = useQuery({
- query,
- variables,
- pause,
- });
+ 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 || !hasRawQuery,
+ });
// 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 +216,16 @@ export function useMessageQuery({ messageId, pause }: { messageId: string; pause
const reExecutor = useCallback(() => {
if (pause || isDelivered || !isWindowVisible()) return;
reexecuteQuery({ requestPolicy: 'network-only' });
- }, [pause, isDelivered, reexecuteQuery]);
+ if (hasRawQuery) {
+ reexecuteRawQuery({ requestPolicy: 'network-only' });
+ }
+ }, [pause, isDelivered, hasRawQuery, 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,
};
@@ -170,3 +234,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);
+}
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;