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;