diff --git a/.gitignore b/.gitignore index 94096bab..591a70c2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ yalc.lock /dist/ .vercel .continueignore +.agents/ e2e.log docs/specs/** -codex-resume \ No newline at end of file +codex-resume +skills-lock.json \ No newline at end of file diff --git a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx index 441a3f39..f78a45b8 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx +++ b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx @@ -1,23 +1,67 @@ import React from "react"; -import { Box, Spinner, Text, VStack } from "native-base"; +import { Box, Button, HStack, Spinner, Text, VStack } from "native-base"; import { BridgeTransactionList } from "./MPBBridgeTransactionCard"; +import { capitalizeChain, getChainName } from "./utils"; interface TransactionHistoryProps { realTransactionHistory: any[]; historyLoading: boolean; + historyRefreshing: boolean; + historyErrorsByChain: Record; + onRefresh: () => void; onTxDetailsPress: (tx: any) => void; } export const TransactionHistory: React.FC = ({ realTransactionHistory, historyLoading, + historyRefreshing, + historyErrorsByChain, + onRefresh, onTxDetailsPress }) => { + const errorEntries = Object.entries(historyErrorsByChain || {}); + return ( - - Recent Transactions - + + + Recent Transactions + + + + {historyRefreshing && !historyLoading ? ( + + + + Refreshing transaction history... + + + ) : null} + {errorEntries.length > 0 ? ( + + + + Some networks could not refresh right now. + + {errorEntries.map(([chainId, message]) => ( + + {capitalizeChain(getChainName(Number(chainId)))}: {message} + + ))} + + + ) : null} {historyLoading ? ( diff --git a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts index ca01b079..ae62f5c1 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts @@ -113,6 +113,9 @@ export interface MPBBridgeViewModel { transactionHistoryProps: { realTransactionHistory: any[]; historyLoading: boolean; + historyRefreshing: boolean; + historyErrorsByChain: Record; + onRefresh: () => void; onTxDetailsPress: (tx: any) => void; }; } @@ -162,7 +165,8 @@ export const useMPBBridgeViewController = ({ closeAllDropdowns } = useMPBBridgeUiState(); - const { realTransactionHistory, historyLoading } = useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS); + const { realTransactionHistory, historyLoading, historyRefreshing, historyErrorsByChain, refreshHistory } = + useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS); const { getBalanceForChain } = useChainBalances(); const gdValue = getBalanceForChain(sourceChain); @@ -380,6 +384,7 @@ export const useMPBBridgeViewController = ({ successHandled.current = true; setBridgingStatus(effectiveFlow.statusLabel || "Bridge completed successfully!"); setBridging(false); + refreshHistory?.(); if (!successModalOpen && !successModalDismissedRef.current) { setSuccessModalOpen(true); @@ -435,6 +440,7 @@ export const useMPBBridgeViewController = ({ successModalOpen, onBridgeSuccess, onBridgeFailed, + refreshHistory, setBridging, setBridgingStatus, setSuccessModalOpen, @@ -598,6 +604,9 @@ export const useMPBBridgeViewController = ({ transactionHistoryProps: { realTransactionHistory: recentTransactions, historyLoading, + historyRefreshing, + historyErrorsByChain, + onRefresh: refreshHistory, onTxDetailsPress } }; diff --git a/packages/good-design/src/apps/bridge/mpbridge/hooks.ts b/packages/good-design/src/apps/bridge/mpbridge/hooks.ts index e110f186..eee5e6cb 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/hooks.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/hooks.ts @@ -176,7 +176,13 @@ export const useChainBalances = () => { }; export const useDebouncedTransactionHistory = (delay = 1000) => { - const { historySorted: realTransactionHistory } = useMPBBridgeHistory() ?? {}; + const { + historySorted: realTransactionHistory, + initialLoading, + refreshing, + errorsByChain, + refreshHistory + } = useMPBBridgeHistory() ?? {}; const [debouncedHistory, setDebouncedHistory] = useState(realTransactionHistory); const timeoutRef = useRef(); @@ -190,7 +196,10 @@ export const useDebouncedTransactionHistory = (delay = 1000) => { return { realTransactionHistory: debouncedHistory, - historyLoading: !realTransactionHistory + historyLoading: Boolean(initialLoading), + historyRefreshing: Boolean(refreshing), + historyErrorsByChain: errorsByChain || {}, + refreshHistory: refreshHistory || (() => undefined) }; }; diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts new file mode 100644 index 00000000..5f3e620c --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts @@ -0,0 +1,128 @@ +/* eslint-env jest */ + +import { + createBlockChunks, + getErrorsByChain, + mergeBridgeHistoryCache, + MPBBridgeHistoryCache +} from "./useMPBBridgeHistory.helpers"; + +describe("useMPBBridgeHistory helpers", () => { + it("splits log ranges into 500-block chunks", () => { + expect(createBlockChunks(100, 1201)).toEqual([ + { fromBlock: 100, toBlock: 599 }, + { fromBlock: 600, toBlock: 1099 }, + { fromBlock: 1100, toBlock: 1201 } + ]); + }); + + it("merges history rows, prunes old cache entries, and keeps chain sync state", () => { + const nowMs = new Date("2026-06-29T00:00:00.000Z").getTime(); + const recentTimestamp = Math.floor(nowMs / 1000) - 60; + const oldTimestamp = Math.floor(nowMs / 1000) - 31 * 24 * 60 * 60; + const currentCache: MPBBridgeHistoryCache = { + BridgeRequest: [ + { + transactionHash: "0xold", + blockHash: "0xblock-old", + blockNumber: 1, + transactionIndex: 0, + removed: false, + sourceChainId: 122, + from: "0xfrom", + to: "0xto", + targetChainId: "42220", + amount: "10", + timestamp: oldTimestamp.toString(), + id: "1" + }, + { + transactionHash: "0xkeep", + blockHash: "0xblock-keep", + blockNumber: 10, + transactionIndex: 0, + removed: false, + sourceChainId: 122, + from: "0xfrom", + to: "0xto", + targetChainId: "42220", + amount: "20", + timestamp: recentTimestamp.toString(), + id: "2" + } + ], + ExecutedTransfer: [], + chains: { + 122: { + lastSyncedBlock: 20, + error: { + message: "old error", + updatedAt: 1 + } + } + } + }; + + const nextCache = mergeBridgeHistoryCache( + currentCache, + { + BridgeRequest: [ + { + transactionHash: "0xkeep-updated", + blockHash: "0xblock-keep-updated", + blockNumber: 12, + transactionIndex: 1, + removed: false, + sourceChainId: 122, + from: "0xfrom", + to: "0xto", + targetChainId: "42220", + amount: "30", + timestamp: recentTimestamp.toString(), + id: "2" + } + ], + ExecutedTransfer: [ + { + transactionHash: "0xcompleted", + blockHash: "0xblock-completed", + blockNumber: 30, + transactionIndex: 0, + removed: false, + sourceChainId: 42220, + from: "0xfrom", + to: "0xto", + targetChainId: "122", + amount: "30", + timestamp: recentTimestamp.toString(), + id: "2" + } + ] + }, + { + 122: { + lastSyncedBlock: 40, + lastSuccessfulSyncAt: nowMs + }, + 42220: { + error: { + message: "rpc failed", + updatedAt: nowMs + } + } + }, + nowMs + ); + + expect(nextCache.BridgeRequest).toHaveLength(1); + expect(nextCache.BridgeRequest?.[0].transactionHash).toBe("0xkeep-updated"); + expect(nextCache.ExecutedTransfer).toHaveLength(1); + expect(nextCache.chains?.[122]).toEqual({ + lastSyncedBlock: 40, + lastSuccessfulSyncAt: nowMs + }); + expect(getErrorsByChain(nextCache)).toEqual({ + 42220: "rpc failed" + }); + }); +}); diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts new file mode 100644 index 00000000..e038d8f7 --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts @@ -0,0 +1,110 @@ +export type BridgeEventName = "BridgeRequest" | "ExecutedTransfer"; + +export type CachedBridgeEvent = { + transactionHash: string; + blockHash: string; + blockNumber: number; + transactionIndex: number; + removed: boolean; + sourceChainId: number; + from?: string; + to?: string; + targetChainId: string; + amount: string; + timestamp: string; + bridge?: string; + id?: string; +}; + +export type ChainSyncErrorState = { + message: string; + updatedAt: number; +}; + +export type ChainSyncState = { + lastSyncedBlock?: number; + lastSuccessfulSyncAt?: number; + error?: ChainSyncErrorState; +}; + +export type MPBBridgeHistoryCache = { + BridgeRequest?: CachedBridgeEvent[]; + ExecutedTransfer?: CachedBridgeEvent[]; + chains?: Partial>; +}; + +// The cache only keeps a rolling 30-day history so first paint stays useful without growing forever. +export const HISTORY_WINDOW_DAYS = 30; +export const HISTORY_WINDOW_SECONDS = HISTORY_WINDOW_DAYS * 24 * 60 * 60; +// Public RPCs were failing on large getLogs windows, so every sync is split into small ranges. +export const HISTORY_BLOCK_CHUNK_SIZE = 500; + +const getEventCacheKey = (event: CachedBridgeEvent) => + event.id ? `${event.sourceChainId}:${event.id}` : `${event.sourceChainId}:${event.transactionHash}`; + +const sortBridgeEvents = (events: CachedBridgeEvent[]) => + events.sort((a, b) => + a.blockNumber === b.blockNumber ? a.transactionIndex - b.transactionIndex : a.blockNumber - b.blockNumber + ); + +export const createBlockChunks = (fromBlock: number, toBlock: number, chunkSize = HISTORY_BLOCK_CHUNK_SIZE) => { + if (fromBlock > toBlock) { + return []; + } + + const chunks: Array<{ fromBlock: number; toBlock: number }> = []; + + // Build inclusive ranges so callers can safely fetch [fromBlock, toBlock] without gaps or overlaps. + for (let cursor = fromBlock; cursor <= toBlock; cursor += chunkSize) { + chunks.push({ + fromBlock: cursor, + toBlock: Math.min(cursor + chunkSize - 1, toBlock) + }); + } + + return chunks; +}; + +export const pruneExpiredEvents = (events: CachedBridgeEvent[], minTimestamp: number) => + events.filter(event => Number(event.timestamp || 0) >= minTimestamp); + +export const getHistoryWindowStartTimestamp = (nowMs = Date.now()) => Math.floor(nowMs / 1000) - HISTORY_WINDOW_SECONDS; + +export const mergeBridgeHistoryCache = ( + current: MPBBridgeHistoryCache, + nextEvents: Partial>, + nextChains: Partial>, + nowMs = Date.now() +): MPBBridgeHistoryCache => { + const minTimestamp = getHistoryWindowStartTimestamp(nowMs); + const mergedRequests = new Map(); + const mergedTransfers = new Map(); + + // Merge new rows into the cached window and let the event key dedupe repeated fetches across refreshes. + pruneExpiredEvents((current.BridgeRequest || []).concat(nextEvents.BridgeRequest || []), minTimestamp).forEach( + event => mergedRequests.set(getEventCacheKey(event), event) + ); + + pruneExpiredEvents((current.ExecutedTransfer || []).concat(nextEvents.ExecutedTransfer || []), minTimestamp).forEach( + event => mergedTransfers.set(getEventCacheKey(event), event) + ); + + return { + BridgeRequest: sortBridgeEvents(Array.from(mergedRequests.values())), + ExecutedTransfer: sortBridgeEvents(Array.from(mergedTransfers.values())), + chains: { + // Per-chain sync state is merged independently so one failing RPC does not wipe successful cursors. + ...(current.chains || {}), + ...nextChains + } + }; +}; + +export const getErrorsByChain = (cache: MPBBridgeHistoryCache) => + Object.entries(cache.chains || {}).reduce((result, [chainId, state]) => { + if (state?.error?.message) { + result[Number(chainId)] = state.error.message; + } + + return result; + }, {} as Record); diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts index 47d826c3..dc32bb1b 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts @@ -1,40 +1,28 @@ -import { useEffect, useMemo, useState } from "react"; -import { useEthers, useLogs, ChainId } from "@usedapp/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEthers } from "@usedapp/core"; import { ethers } from "ethers"; import { first, groupBy, sortBy } from "lodash"; -import { useRefreshOrNever } from "../../../hooks"; import { AsyncStorage } from "../../storage"; import { SupportedChains, formatAmount } from "../../constants"; import { useGetContract } from "../../base/react"; - -type BridgeEventName = "BridgeRequest" | "ExecutedTransfer"; - -type CachedBridgeEvent = { - transactionHash: string; - blockHash: string; - blockNumber: number; - transactionIndex: number; - removed: boolean; - sourceChainId: number; - from?: string; - to?: string; - targetChainId: string; - amount: string; - timestamp: string; - bridge?: string; - id?: string; -}; - -type MPBBridgeHistoryCache = Partial>; - -const HISTORY_CACHE_VERSION = 1; - -// Keep the live RPC scan intentionally small. XDC public RPCs reject wider -// eth_getLogs windows, and the cache below is what gives us persistence. -const HISTORY_BLOCK_WINDOW = 500; +import { + BridgeEventName, + CachedBridgeEvent, + ChainSyncState, + MPBBridgeHistoryCache, + HISTORY_BLOCK_CHUNK_SIZE, + HISTORY_WINDOW_SECONDS, + createBlockChunks, + getErrorsByChain, + mergeBridgeHistoryCache +} from "./useMPBBridgeHistory.helpers"; + +const HISTORY_CACHE_VERSION = 2; const CHAIN_IDS = [SupportedChains.FUSE, SupportedChains.CELO, SupportedChains.MAINNET, SupportedChains.XDC]; +const MAX_PARALLEL_CHUNKS = 3; const hydrateCachedEvent = (event: CachedBridgeEvent) => { + // Persist plain JSON in storage, then rebuild the BigNumber-shaped fields the rest of the hook expects. const targetChainId = ethers.BigNumber.from(event.targetChainId); const amount = ethers.BigNumber.from(event.amount); const timestamp = ethers.BigNumber.from(event.timestamp); @@ -66,41 +54,194 @@ const hydrateCachedEvent = (event: CachedBridgeEvent) => { }; }; -const normalizeLiveEvents = (items: Array<{ sourceChainId: SupportedChains; events: any[] }>) => - items.flatMap(({ sourceChainId, events }) => - events.map((event: any) => { - const targetChainId = event.data?.targetChainId || event.data?.[2]; - const amount = event.data?.amount || event.data?.[3]; - const timestamp = event.data?.timestamp || event.data?.[4]; - const bridge = event.data?.bridge || event.data?.[5]; - const id = event.data?.id || event.data?.[6]; +const getErrorMessage = (error: unknown) => { + if (error instanceof Error && error.message) { + return error.message; + } - // useLogs returns decoded ethers values such as BigNumber. Store only - // plain JSON strings/numbers so AsyncStorage round-trips cleanly. - return { - transactionHash: event.transactionHash, - blockHash: event.blockHash, - blockNumber: event.blockNumber, - transactionIndex: event.transactionIndex, - removed: event.removed, - sourceChainId, - from: event.data?.from || event.data?.[0], - to: event.data?.to || event.data?.[1], - targetChainId: targetChainId?.toString?.() || SupportedChains.CELO.toString(), - amount: amount?.toString?.() || "0", - timestamp: timestamp?.toString?.() || "0", - bridge, - id: id ? id.toString() : undefined - }; - }) + if (typeof error === "string") { + return error; + } + + return "Failed to load bridge history from RPC"; +}; + +const runWithConcurrency = async (tasks: Array<() => Promise>, concurrency: number): Promise => { + const results: T[] = []; + + // Batch chunk fetches instead of firing every getLogs request at once against the same public RPC. + for (let index = 0; index < tasks.length; index += concurrency) { + const nextResults = await Promise.all(tasks.slice(index, index + concurrency).map(task => task())); + results.push(...nextResults); + } + + return results; +}; + +const findHistoryStartBlock = async ( + provider: ethers.providers.Provider, + latestBlockNumber: number, + targetTimestamp: number +) => { + const latestBlock = await provider.getBlock(latestBlockNumber); + + if (!latestBlock || latestBlock.timestamp <= targetTimestamp) { + return latestBlockNumber; + } + + let low = 0; + let high = latestBlockNumber; + + // Binary search keeps the 30-day backfill exact without relying on chain-specific block-time guesses. + while (low < high) { + const mid = Math.floor((low + high) / 2); + const block = await provider.getBlock(mid); + + if (!block) { + throw new Error(`Failed to fetch block ${mid} while backfilling bridge history`); + } + + if (block.timestamp < targetTimestamp) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; +}; + +const normalizeProviderLogs = ( + contract: ethers.Contract, + sourceChainId: SupportedChains, + logs: ethers.providers.Log[] +): CachedBridgeEvent[] => + logs.flatMap(log => { + try { + const parsedLog = contract.interface.parseLog(log); + const targetChainId = parsedLog.args?.targetChainId || parsedLog.args?.[2]; + const amount = parsedLog.args?.amount || parsedLog.args?.[3]; + const timestamp = parsedLog.args?.timestamp || parsedLog.args?.[4]; + const bridge = parsedLog.args?.bridge || parsedLog.args?.[5]; + const id = parsedLog.args?.id || parsedLog.args?.[6]; + + return [ + { + transactionHash: log.transactionHash, + blockHash: log.blockHash, + blockNumber: log.blockNumber, + transactionIndex: log.transactionIndex, + removed: log.removed, + sourceChainId, + from: parsedLog.args?.from || parsedLog.args?.[0], + to: parsedLog.args?.to || parsedLog.args?.[1], + targetChainId: targetChainId?.toString?.() || SupportedChains.CELO.toString(), + amount: amount?.toString?.() || "0", + timestamp: timestamp?.toString?.() || "0", + bridge, + id: id ? id.toString() : undefined + } + ]; + } catch (error) { + console.warn("Failed to parse bridge history log", error); + return []; + } + }); + +const filterEventsForAccount = (events: CachedBridgeEvent[], account?: string) => { + if (!account) { + return events; + } + + const normalizedAccount = account.toLowerCase(); + + return events.filter( + event => event.from?.toLowerCase() === normalizedAccount || event.to?.toLowerCase() === normalizedAccount + ); +}; + +const fetchEventLogs = async ( + contract: ethers.Contract, + eventName: BridgeEventName, + fromBlock: number, + toBlock: number +) => { + if (fromBlock > toBlock) { + return [] as ethers.providers.Log[]; + } + + const provider = contract.provider as ethers.providers.Provider; + const topic = contract.interface.getEventTopic(eventName); + const chunks = createBlockChunks(fromBlock, toBlock, HISTORY_BLOCK_CHUNK_SIZE); + + // Public RPCs are sensitive to large eth_getLogs windows, so every request stays within 500 blocks. + const logsByChunk = await runWithConcurrency( + chunks.map( + chunk => () => + provider.getLogs({ + address: contract.address, + topics: [topic], + fromBlock: chunk.fromBlock, + toBlock: chunk.toBlock + }) + ), + MAX_PARALLEL_CHUNKS ); + return logsByChunk.flat(); +}; + +const syncChainHistory = async ( + chainId: SupportedChains, + contract: ethers.Contract, + currentCache: MPBBridgeHistoryCache, + account?: string +) => { + const provider = contract.provider as ethers.providers.Provider; + const latestBlock = await provider.getBlockNumber(); + const chainState = currentCache.chains?.[chainId]; + const targetTimestamp = Math.floor(Date.now() / 1000) - HISTORY_WINDOW_SECONDS; + const fromBlock = + // Warm cache: resume from the last synced block. Cold cache: backfill only the rolling history window. + chainState?.lastSyncedBlock !== undefined + ? chainState.lastSyncedBlock + 1 + : await findHistoryStartBlock(provider, latestBlock, targetTimestamp); + + if (fromBlock > latestBlock) { + return { + chainId, + bridgeRequests: [] as CachedBridgeEvent[], + executedTransfers: [] as CachedBridgeEvent[], + chainState: { + lastSyncedBlock: latestBlock, + lastSuccessfulSyncAt: Date.now() + } satisfies ChainSyncState + }; + } + + const [bridgeRequests, executedTransfers] = await Promise.all([ + fetchEventLogs(contract, "BridgeRequest", fromBlock, latestBlock), + fetchEventLogs(contract, "ExecutedTransfer", fromBlock, latestBlock) + ]); + + return { + chainId, + bridgeRequests: filterEventsForAccount(normalizeProviderLogs(contract, chainId, bridgeRequests), account), + executedTransfers: filterEventsForAccount(normalizeProviderLogs(contract, chainId, executedTransfers), account), + chainState: { + lastSyncedBlock: latestBlock, + lastSuccessfulSyncAt: Date.now() + } satisfies ChainSyncState + }; +}; + export const useMPBBridgeHistory = () => { const { account } = useEthers(); - const refresh = useRefreshOrNever(5); - const refreshFaster = useRefreshOrNever(2); const [cacheLoaded, setCacheLoaded] = useState(false); const [historyCache, setHistoryCache] = useState({}); + const [refreshTick, setRefreshTick] = useState(0); + const [syncing, setSyncing] = useState(false); + const historyCacheRef = useRef({}); const fuseBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.FUSE); const celoBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.CELO); @@ -117,97 +258,27 @@ export const useMPBBridgeHistory = () => { [celoBridgeContract, fuseBridgeContract, mainnetBridgeContract, xdcBridgeContract] ); - // These are the only live RPC queries. Each useLogs call scans only the - // recent 500-block window; older discovered events come from AsyncStorage. - const fuseBridgeRequests = useLogs( - fuseBridgeContract ? { contract: fuseBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.FUSE as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh - } - ); - - const celoBridgeRequests = useLogs( - celoBridgeContract ? { contract: celoBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.CELO as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh - } - ); - - const mainnetBridgeRequests = useLogs( - mainnetBridgeContract ? { contract: mainnetBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.MAINNET as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh - } - ); - - const xdcBridgeRequests = useLogs( - xdcBridgeContract ? { contract: xdcBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.XDC as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh - } - ); - - const fuseBridgeCompleted = useLogs( - fuseBridgeContract ? { contract: fuseBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.FUSE as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster - } - ); - - const celoBridgeCompleted = useLogs( - celoBridgeContract ? { contract: celoBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.CELO as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster - } - ); - - const mainnetBridgeCompleted = useLogs( - mainnetBridgeContract ? { contract: mainnetBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.MAINNET as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster - } - ); - - const xdcBridgeCompleted = useLogs( - xdcBridgeContract ? { contract: xdcBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.XDC as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster - } - ); - const cacheKey = useMemo(() => { if (!account) return undefined; - // Include contract addresses in the key so deployments/env changes do not - // reuse stale logs from an older bridge contract. const contractAddresses = CHAIN_IDS.map(chainId => contracts[chainId]?.address?.toLowerCase() || "missing").join( ":" ); + // Scope cache entries to the wallet and the deployed bridge addresses so network/config changes do not mix data. return `GD_MPBBridgeHistory_v${HISTORY_CACHE_VERSION}_${account.toLowerCase()}_${contractAddresses}`; }, [account, contracts]); + useEffect(() => { + historyCacheRef.current = historyCache; + }, [historyCache]); + useEffect(() => { let cancelled = false; setCacheLoaded(false); setHistoryCache({}); + historyCacheRef.current = {}; if (!cacheKey) { setCacheLoaded(true); @@ -216,17 +287,18 @@ export const useMPBBridgeHistory = () => { }; } - // Load local history first so the UI can show previously discovered bridge - // events immediately, then merge fresh useLogs results in the effect below. + // Cache hydrate keeps the first paint fast while a background sync fetches new chain deltas. AsyncStorage.getItem(cacheKey) .then(cached => { if (!cancelled) { - setHistoryCache(cached || {}); + const hydratedCache = cached || {}; + setHistoryCache(hydratedCache); + historyCacheRef.current = hydratedCache; setCacheLoaded(true); } }) - .catch(e => { - console.warn("Failed to read MPB bridge history cache", e); + .catch(error => { + console.warn("Failed to read MPB bridge history cache", error); if (!cancelled) setCacheLoaded(true); }); @@ -235,113 +307,135 @@ export const useMPBBridgeHistory = () => { }; }, [cacheKey]); - const freshCache = useMemo(() => { - // Normalize all live logs into a single plain-object structure. The cache - // does not care which chain produced the event; sourceChainId keeps that. - const bridgeRequests = normalizeLiveEvents([ - { sourceChainId: SupportedChains.FUSE, events: fuseBridgeRequests?.value || [] }, - { sourceChainId: SupportedChains.CELO, events: celoBridgeRequests?.value || [] }, - { sourceChainId: SupportedChains.MAINNET, events: mainnetBridgeRequests?.value || [] }, - { sourceChainId: SupportedChains.XDC, events: xdcBridgeRequests?.value || [] } - ]); - - const completedTransfers = normalizeLiveEvents([ - { sourceChainId: SupportedChains.FUSE, events: fuseBridgeCompleted?.value || [] }, - { sourceChainId: SupportedChains.CELO, events: celoBridgeCompleted?.value || [] }, - { sourceChainId: SupportedChains.MAINNET, events: mainnetBridgeCompleted?.value || [] }, - { sourceChainId: SupportedChains.XDC, events: xdcBridgeCompleted?.value || [] } - ]); + useEffect(() => { + void refreshTick; - return { - BridgeRequest: bridgeRequests, - ExecutedTransfer: completedTransfers - }; - }, [ - fuseBridgeRequests, - celoBridgeRequests, - mainnetBridgeRequests, - xdcBridgeRequests, - fuseBridgeCompleted, - celoBridgeCompleted, - mainnetBridgeCompleted, - xdcBridgeCompleted - ]); + if (!cacheLoaded || !cacheKey) { + return; + } - useEffect(() => { - if (!cacheLoaded || !cacheKey) return; - if (!freshCache.BridgeRequest.length && !freshCache.ExecutedTransfer.length) return; - - setHistoryCache(current => { - // Fresh useLogs results are merged into the persisted cache. This turns a - // rolling 500-block scan into local history that survives page/app reloads. - const bridgeRequests = new Map(); - const completedTransfers = new Map(); - - (current.BridgeRequest || []).concat(freshCache.BridgeRequest).forEach(event => { - // Bridge ids are stable across source/target chains. Fall back to tx - // hash for malformed or older cached entries that do not contain an id. - const key = event.id ? `${event.sourceChainId}:${event.id}` : `${event.sourceChainId}:${event.transactionHash}`; - bridgeRequests.set(key, event); - }); + const chainContracts = CHAIN_IDS.flatMap(chainId => + contracts[chainId] ? [{ chainId, contract: contracts[chainId] as ethers.Contract }] : [] + ); - (current.ExecutedTransfer || []).concat(freshCache.ExecutedTransfer).forEach(event => { - const key = event.id ? `${event.sourceChainId}:${event.id}` : `${event.sourceChainId}:${event.transactionHash}`; - completedTransfers.set(key, event); - }); + if (!chainContracts.length) { + return; + } - const next = { - BridgeRequest: Array.from(bridgeRequests.values()).sort((a, b) => - a.blockNumber === b.blockNumber ? a.transactionIndex - b.transactionIndex : a.blockNumber - b.blockNumber - ), - ExecutedTransfer: Array.from(completedTransfers.values()).sort((a, b) => - a.blockNumber === b.blockNumber ? a.transactionIndex - b.transactionIndex : a.blockNumber - b.blockNumber - ) - }; + let cancelled = false; - // Persist only the normalized event shape; ethers BigNumber instances do - // not survive JSON cleanly. - void AsyncStorage.setItem(cacheKey, next).catch(e => console.warn("Failed to store MPB bridge history cache", e)); + // Keep cached rows on screen and expose a separate refreshing state while each chain sync runs. + setSyncing(true); + + const syncHistory = async () => { + const currentCache = historyCacheRef.current; + // Sync every chain independently so a single failing RPC cannot block the others from updating cache. + const settledChains = await Promise.allSettled( + chainContracts.map(({ chainId, contract }) => syncChainHistory(chainId, contract, currentCache, account)) + ); + + if (cancelled) { + return; + } + + const nextChains: Partial> = {}; + const nextBridgeRequests: CachedBridgeEvent[] = []; + const nextExecutedTransfers: CachedBridgeEvent[] = []; + + settledChains.forEach((result, index) => { + const { chainId } = chainContracts[index]; + + if (result.status === "fulfilled") { + // Successful chains contribute rows and advance only their own cursor/error state. + nextChains[chainId] = result.value.chainState; + nextBridgeRequests.push(...result.value.bridgeRequests); + nextExecutedTransfers.push(...result.value.executedTransfers); + return; + } + + // Failed chains keep their last good cursor and surface a chain-specific error for the UI. + nextChains[chainId] = { + ...(currentCache.chains?.[chainId] || {}), + error: { + message: getErrorMessage(result.reason), + updatedAt: Date.now() + } + }; + }); - return next; + const nextCache = mergeBridgeHistoryCache( + currentCache, + { + BridgeRequest: nextBridgeRequests, + ExecutedTransfer: nextExecutedTransfers + }, + nextChains + ); + + setHistoryCache(nextCache); + historyCacheRef.current = nextCache; + // Persist the merged cache after every refresh so the next mount can render immediately from storage. + void AsyncStorage.setItem(cacheKey, nextCache).catch(error => + console.warn("Failed to store MPB bridge history cache", error) + ); + }; + + void syncHistory().finally(() => { + if (!cancelled) { + setSyncing(false); + } }); - }, [cacheKey, cacheLoaded, freshCache]); + + return () => { + cancelled = true; + }; + }, [account, cacheKey, cacheLoaded, contracts, refreshTick]); + + const refreshHistory = useCallback(() => { + setRefreshTick(current => current + 1); + }, []); return useMemo(() => { + const errorsByChain = getErrorsByChain(historyCache); + const hasCachedRows = Boolean( + (historyCache.BridgeRequest || []).length || (historyCache.ExecutedTransfer || []).length + ); + if (!cacheLoaded) { - return { historySorted: undefined }; + return { + history: undefined, + historySorted: undefined, + initialLoading: true, + refreshing: false, + errorsByChain, + refreshHistory + }; } - // Rebuild the minimal decoded-event shape the existing MP bridge UI - // expects. This keeps the render path compatible with the original useLogs - // result while storing only JSON-safe values in AsyncStorage. const bridgeRequests = (historyCache.BridgeRequest || []).map(hydrateCachedEvent); const completedTransfers = (historyCache.ExecutedTransfer || []).map(hydrateCachedEvent); - const getEventId = (e: any) => { - const id = e.data?.id || e.data?.[6]; + const getEventId = (event: any) => { + const id = event.data?.id || event.data?.[6]; - return id ? id.toString() : e.transactionHash; + return id ? id.toString() : event.transactionHash; }; const completedByChain = groupBy(completedTransfers, event => event.data.sourceChainId.toNumber()); const completedByTargetChain = CHAIN_IDS.reduce((result, sourceChainId) => { - // Completion events are looked up by chain and bridge id so source - // requests can be marked complete when the target-chain event is cached. result[sourceChainId] = groupBy(completedByChain[sourceChainId] || [], getEventId); - return result; }, {} as Record>); - const processBridgeRequestEvent = (e: any) => { - type BridgeEvent = typeof e & { completedEvent: any; amount: string }; - const extended = e as BridgeEvent; - const amountBN = e.data?.amount || ethers.BigNumber.from(0); - const requestId = e.data?.id?.toString(); - const sourceChainId = e.data.sourceChainId.toNumber(); + const processBridgeRequestEvent = (event: any) => { + type BridgeEvent = typeof event & { completedEvent: any; amount: string }; + const extended = event as BridgeEvent; + const amountBN = event.data?.amount || ethers.BigNumber.from(0); + const requestId = event.data?.id?.toString(); + const sourceChainId = event.data.sourceChainId.toNumber(); - // Completion happens on the opposite chain, so match request IDs against - // all other chains. + // Match a request against completion events from the other chains to preserve the old merged UX. const completedEventsMap = CHAIN_IDS.filter(chainId => chainId !== sourceChainId).reduce((result, chainId) => { return { ...result, ...completedByTargetChain[chainId] }; }, {} as Record); @@ -357,8 +451,6 @@ export const useMPBBridgeHistory = () => { const historyFiltered = account ? historyCombined.filter( - // Keep only the connected wallet's bridge requests. Cached events are - // wallet-scoped by key, but this guards against old/stale cache data. (tx: any) => tx.data?.from?.toLowerCase() === account?.toLowerCase() || tx.data?.to?.toLowerCase() === account?.toLowerCase() @@ -367,6 +459,13 @@ export const useMPBBridgeHistory = () => { const historySorted = sortBy(historyFiltered, (tx: any) => tx.data?.timestamp?.toNumber?.() || 0).reverse(); - return { historySorted }; - }, [account, cacheLoaded, historyCache]); + return { + history: historySorted, + historySorted, + initialLoading: syncing && !hasCachedRows, + refreshing: syncing, + errorsByChain, + refreshHistory + }; + }, [account, cacheLoaded, historyCache, refreshHistory, syncing]); };