From c95b0f37813d6dfc124979027d0d819ed0b858ac Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Fri, 3 Jul 2026 22:15:28 +0100 Subject: [PATCH 1/5] fix: stabilize MPB bridge history reads --- .gitignore | 4 +- .../bridge/mpbridge/MPBBridgeController.tsx | 8 +- .../feature/useMPBBridgeFeatureController.ts | 7 +- .../feature/useMPBBridgeViewController.ts | 13 +- .../src/apps/bridge/mpbridge/hooks.ts | 13 +- .../src/apps/bridge/mpbridge/index.ts | 10 +- .../src/apps/bridge/mpbridge/types.ts | 5 + .../sdk-v2/src/hooks/useMulticallAtChain.tsx | 4 +- .../sdk-v2/src/sdk/mpbridge/hooks/index.ts | 1 + .../hooks/useMPBBridgeHistory.helpers.test.ts | 32 +++ .../hooks/useMPBBridgeHistory.helpers.ts | 35 +++ .../sdk/mpbridge/hooks/useMPBBridgeHistory.ts | 265 ++++++++++++++---- 12 files changed, 327 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 591a70c2b..94096bab5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,6 @@ yalc.lock /dist/ .vercel .continueignore -.agents/ e2e.log docs/specs/** -codex-resume -skills-lock.json \ No newline at end of file +codex-resume \ No newline at end of file diff --git a/packages/good-design/src/apps/bridge/mpbridge/MPBBridgeController.tsx b/packages/good-design/src/apps/bridge/mpbridge/MPBBridgeController.tsx index 819a7ab6b..d10edcc91 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/MPBBridgeController.tsx +++ b/packages/good-design/src/apps/bridge/mpbridge/MPBBridgeController.tsx @@ -3,23 +3,27 @@ import { VStack } from "native-base"; import { MPBBridge } from "./MPBBridge"; import { useMPBBridgeFeatureController } from "./feature/useMPBBridgeFeatureController"; +import type { MPBBridgeReadOnlyUrls } from "./types"; interface IMPBBridgeControllerProps { withHistory?: boolean; onBridgeStart?: () => void; onBridgeSuccess?: () => void; onBridgeFailed?: (e: Error) => void; + bridgeReadOnlyUrls?: MPBBridgeReadOnlyUrls; } export const MPBBridgeController: React.FC = ({ onBridgeStart, onBridgeSuccess, - onBridgeFailed + onBridgeFailed, + bridgeReadOnlyUrls }) => { const bridgeProps = useMPBBridgeFeatureController({ onBridgeStart, onBridgeSuccess, - onBridgeFailed + onBridgeFailed, + bridgeReadOnlyUrls }); return ( diff --git a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeFeatureController.ts b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeFeatureController.ts index 9d1ca53d3..7d7c4638a 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeFeatureController.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeFeatureController.ts @@ -3,13 +3,14 @@ import { useEthers } from "@usedapp/core"; import { ethers } from "ethers"; import { useMPBBridgeFlow, useG$Decimals, SupportedChains, VALIDATION_REASONS } from "@gooddollar/web3sdk-v2"; -import { BridgeProvider, MPBBridgeProps } from "../types"; +import { BridgeProvider, MPBBridgeProps, MPBBridgeReadOnlyUrls } from "../types"; import { getDefaultTargetChain } from "../utils/chainHelpers"; interface UseMPBBridgeFeatureControllerParams { onBridgeStart?: () => void; onBridgeSuccess?: () => void; onBridgeFailed?: (e: Error) => void; + bridgeReadOnlyUrls?: MPBBridgeReadOnlyUrls; } const ZERO_FEE = { nativeFee: ethers.BigNumber.from(0), zroFee: ethers.BigNumber.from(0) }; @@ -17,7 +18,8 @@ const ZERO_FEE = { nativeFee: ethers.BigNumber.from(0), zroFee: ethers.BigNumber export const useMPBBridgeFeatureController = ({ onBridgeStart, onBridgeSuccess, - onBridgeFailed + onBridgeFailed, + bridgeReadOnlyUrls }: UseMPBBridgeFeatureControllerParams): MPBBridgeProps => { const { chainId, account } = useEthers(); const [bridgeProvider, setBridgeProvider] = useState("layerzero"); @@ -176,6 +178,7 @@ export const useMPBBridgeFeatureController = ({ onBridgeStart: onBridgeStartHandler, onBridgeFailed, onBridgeSuccess, + bridgeReadOnlyUrls, bridgeProvider, onBridgeProviderChange: setBridgeProvider }; 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 ae62f5c1e..f56dd9415 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts @@ -17,6 +17,12 @@ import { useMPBBridgeUiState } from "./useMPBBridgeUiState"; const DEBOUNCE_MS = 300; const TRANSACTION_HISTORY_DEBOUNCE_MS = 2000; +const CHAIN_NAME_TO_ID: Record = { + fuse: SupportedChains.FUSE, + celo: SupportedChains.CELO, + mainnet: SupportedChains.MAINNET, + xdc: SupportedChains.XDC +}; const FLOW_PENDING_STATES = new Set([ "awaiting_network_switch", @@ -134,6 +140,7 @@ export const useMPBBridgeViewController = ({ onBridgeStart, onBridgeFailed, onBridgeSuccess, + bridgeReadOnlyUrls, bridgeProvider: propBridgeProvider, onBridgeProviderChange }: MPBBridgeProps): MPBBridgeViewModel => { @@ -165,8 +172,12 @@ export const useMPBBridgeViewController = ({ closeAllDropdowns } = useMPBBridgeUiState(); + const bridgeHistoryChainIds = useMemo( + () => Array.from(new Set([CHAIN_NAME_TO_ID[sourceChain], CHAIN_NAME_TO_ID[targetChain]].filter(Boolean))), + [sourceChain, targetChain] + ); const { realTransactionHistory, historyLoading, historyRefreshing, historyErrorsByChain, refreshHistory } = - useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS); + useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS, bridgeReadOnlyUrls, bridgeHistoryChainIds); const { getBalanceForChain } = useChainBalances(); const gdValue = getBalanceForChain(sourceChain); diff --git a/packages/good-design/src/apps/bridge/mpbridge/hooks.ts b/packages/good-design/src/apps/bridge/mpbridge/hooks.ts index eee5e6cb2..d273f6dd5 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/hooks.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/hooks.ts @@ -3,7 +3,7 @@ import { CurrencyValue } from "@usedapp/core"; import { useG$Amounts, useProductionG$Balance, G$Amount, useGetEnvChainId } from "@gooddollar/web3sdk-v2"; import { BigNumber } from "ethers"; import { fetchBridgeFees, useMPBBridgeHistory } from "@gooddollar/web3sdk-v2"; -import type { IMPBFees, IMPBLimits } from "./types"; +import type { IMPBFees, IMPBLimits, MPBBridgeHistoryChainIds, MPBBridgeReadOnlyUrls } from "./types"; import { convertTransaction } from "./utils"; const CACHE_KEY = "mpb-bridge-fees-cache"; @@ -175,14 +175,21 @@ export const useChainBalances = () => { return { getBalanceForChain }; }; -export const useDebouncedTransactionHistory = (delay = 1000) => { +export const useDebouncedTransactionHistory = ( + delay = 1000, + bridgeReadOnlyUrls?: MPBBridgeReadOnlyUrls, + bridgeHistoryChainIds?: MPBBridgeHistoryChainIds +) => { const { historySorted: realTransactionHistory, initialLoading, refreshing, errorsByChain, refreshHistory - } = useMPBBridgeHistory() ?? {}; + } = useMPBBridgeHistory({ + readOnlyUrls: bridgeReadOnlyUrls, + chainIds: bridgeHistoryChainIds + }) ?? {}; const [debouncedHistory, setDebouncedHistory] = useState(realTransactionHistory); const timeoutRef = useRef(); diff --git a/packages/good-design/src/apps/bridge/mpbridge/index.ts b/packages/good-design/src/apps/bridge/mpbridge/index.ts index 637ff65b3..5d6036727 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/index.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/index.ts @@ -2,7 +2,15 @@ export { MPBBridge } from "./MPBBridge"; export { MPBBridgeController } from "./MPBBridgeController"; export { useMPBBridgeFeatureController } from "./feature/useMPBBridgeFeatureController"; export { BridgeTransactionCard, BridgeTransactionList } from "./MPBBridgeTransactionCard"; -export type { MPBBridgeProps, IMPBLimits, IMPBFees, BridgeProvider, BridgeTransaction } from "./types"; +export type { + MPBBridgeProps, + MPBBridgeHistoryChainIds, + MPBBridgeReadOnlyUrls, + IMPBLimits, + IMPBFees, + BridgeProvider, + BridgeTransaction +} from "./types"; export { ChainSelector } from "./ChainSelector"; export { BridgeProviderSelector } from "./BridgeProviderSelector"; diff --git a/packages/good-design/src/apps/bridge/mpbridge/types.ts b/packages/good-design/src/apps/bridge/mpbridge/types.ts index b5192ebe8..830be1f4f 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/types.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/types.ts @@ -1,7 +1,11 @@ import { BigNumber } from "ethers"; +import type { SupportedChains } from "@gooddollar/web3sdk-v2"; export type BridgeProvider = "axelar" | "layerzero"; +export type MPBBridgeReadOnlyUrls = Partial>; +export type MPBBridgeHistoryChainIds = SupportedChains[]; + export type BridgeTransaction = { id: string; transactionHash: string; @@ -62,6 +66,7 @@ export interface MPBBridgeProps { onBridgeStart?: (sourceChain: string, targetChain: string) => Promise; onBridgeFailed?: (error: Error) => void; onBridgeSuccess?: () => void; + bridgeReadOnlyUrls?: MPBBridgeReadOnlyUrls; bridgeProvider?: BridgeProvider; onBridgeProviderChange?: (provider: BridgeProvider) => void; } diff --git a/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx b/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx index b3015c405..4eef71aaa 100644 --- a/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx +++ b/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Result } from "@ethersproject/abi"; import { BigNumber } from "@ethersproject/bignumber"; -import { BaseProvider, JsonRpcProvider, Provider } from "@ethersproject/providers"; +import { BaseProvider, JsonRpcProvider, Provider, StaticJsonRpcProvider } from "@ethersproject/providers"; import { Contract } from "ethers"; import { noop } from "lodash"; @@ -86,7 +86,7 @@ export const useReadOnlyProvider = (chainId: number) => { return (factory as any)() as JsonRpcProvider; } - const provider = new JsonRpcProvider(factory as any); + const provider = new StaticJsonRpcProvider(factory as any, chainId); provider.pollingInterval = pollingInterval; return provider; diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts index 2a7f17839..fcc15cd80 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts @@ -3,6 +3,7 @@ export * from "./useBridgeValidators"; export * from "./useGetMPBBridgeData"; export * from "./useLayerZeroFee"; export * from "./useMPBBridge"; +export * from "./useMPBBridgeHistory.helpers"; export * from "./useMPBBridgeHistory"; export * from "./useMPBG$TokenContract"; export * from "./useProductionG$Balance"; 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 index 5f3e620c5..6aba51e46 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts @@ -1,7 +1,10 @@ /* eslint-env jest */ import { + createAccountEventTopics, createBlockChunks, + dedupeLogs, + getAddressTopic, getErrorsByChain, mergeBridgeHistoryCache, MPBBridgeHistoryCache @@ -16,6 +19,35 @@ describe("useMPBBridgeHistory helpers", () => { ]); }); + it("creates indexed account topics for bridge history log filters", () => { + const account = "0xc1bA0ACD3030321851889309497663998D87D8d6"; + const accountTopic = "0x000000000000000000000000c1ba0acd3030321851889309497663998d87d8d6"; + + expect(getAddressTopic(account)).toBe(accountTopic); + expect(createAccountEventTopics("0xtopic", account)).toEqual([ + ["0xtopic", accountTopic], + ["0xtopic", null, accountTopic] + ]); + }); + + it("falls back to the event topic when the account address is unavailable", () => { + expect(createAccountEventTopics("0xtopic")).toEqual([["0xtopic"]]); + expect(createAccountEventTopics("0xtopic", "invalid")).toEqual([["0xtopic"]]); + }); + + it("dedupes logs that match both indexed account filters", () => { + expect( + dedupeLogs([ + { transactionHash: "0x1", logIndex: 2, value: "from-match" }, + { transactionHash: "0x1", logIndex: 2, value: "to-match" }, + { transactionHash: "0x2", logIndex: 1, value: "other" } + ]) + ).toEqual([ + { transactionHash: "0x1", logIndex: 2, value: "to-match" }, + { transactionHash: "0x2", logIndex: 1, value: "other" } + ]); + }); + 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; diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts index e038d8f7d..46916aef3 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts @@ -65,6 +65,41 @@ export const createBlockChunks = (fromBlock: number, toBlock: number, chunkSize return chunks; }; +export const getAddressTopic = (address?: string) => { + if (!address) { + return undefined; + } + + const normalizedAddress = address.toLowerCase(); + + if (!/^0x[0-9a-f]{40}$/.test(normalizedAddress)) { + return undefined; + } + + return `0x${normalizedAddress.slice(2).padStart(64, "0")}`; +}; + +export const createAccountEventTopics = (eventTopic: string, account?: string) => { + const accountTopic = getAddressTopic(account); + + if (!accountTopic) { + return [[eventTopic]]; + } + + return [ + [eventTopic, accountTopic], + [eventTopic, null, accountTopic] + ]; +}; + +export const dedupeLogs = (logs: T[]) => { + const logsByKey = new Map(); + + logs.forEach(log => logsByKey.set(`${log.transactionHash}:${log.logIndex ?? 0}`, log)); + + return Array.from(logsByKey.values()); +}; + export const pruneExpiredEvents = (events: CachedBridgeEvent[], minTimestamp: number) => events.filter(event => Number(event.timestamp || 0) >= minTimestamp); diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts index dc32bb1b7..c2aad0923 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts @@ -2,9 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEthers } from "@usedapp/core"; import { ethers } from "ethers"; import { first, groupBy, sortBy } from "lodash"; +import Contracts from "@gooddollar/goodprotocol/releases/deployment.json"; +import { CONTRACT_TO_ABI } from "../../base/sdk"; import { AsyncStorage } from "../../storage"; import { SupportedChains, formatAmount } from "../../constants"; -import { useGetContract } from "../../base/react"; +import { useGetEnvChainId } from "../../base/react"; +import { useReadOnlyProvider } from "../../../hooks/useMulticallAtChain"; import { BridgeEventName, CachedBridgeEvent, @@ -12,14 +15,51 @@ import { MPBBridgeHistoryCache, HISTORY_BLOCK_CHUNK_SIZE, HISTORY_WINDOW_SECONDS, + createAccountEventTopics, createBlockChunks, + dedupeLogs, getErrorsByChain, mergeBridgeHistoryCache } from "./useMPBBridgeHistory.helpers"; -const HISTORY_CACHE_VERSION = 2; +const HISTORY_CACHE_VERSION = 5; const CHAIN_IDS = [SupportedChains.FUSE, SupportedChains.CELO, SupportedChains.MAINNET, SupportedChains.XDC]; -const MAX_PARALLEL_CHUNKS = 3; +const HISTORY_REQUEST_DELAY_MS = 500; + +export type MPBBridgeHistoryReadOnlyUrls = Partial>; + +export type UseMPBBridgeHistoryOptions = { + readOnlyUrls?: MPBBridgeHistoryReadOnlyUrls; + chainIds?: SupportedChains[]; +}; + +type SyncChainHistorySettlement = + | { status: "fulfilled"; value: Awaited> } + | { status: "rejected"; reason: unknown }; + +const useMPBBridgeHistoryContract = (chainId: SupportedChains, readOnlyUrls?: MPBBridgeHistoryReadOnlyUrls) => { + const { defaultEnv } = useGetEnvChainId(chainId); + const fallbackProvider = useReadOnlyProvider(chainId); + const overrideUrl = readOnlyUrls?.[chainId]; + + const provider = useMemo(() => { + if (overrideUrl) { + return new ethers.providers.StaticJsonRpcProvider(overrideUrl, chainId); + } + + return fallbackProvider; + }, [chainId, fallbackProvider, overrideUrl]); + + return useMemo(() => { + const deployment = Contracts[defaultEnv as keyof typeof Contracts] as { MpbBridge?: string } | undefined; + + if (!provider || !deployment?.MpbBridge) { + return; + } + + return new ethers.Contract(deployment.MpbBridge, CONTRACT_TO_ABI.MpbBridge.abi, provider); + }, [defaultEnv, provider]); +}; const hydrateCachedEvent = (event: CachedBridgeEvent) => { // Persist plain JSON in storage, then rebuild the BigNumber-shaped fields the rest of the hook expects. @@ -55,27 +95,70 @@ const hydrateCachedEvent = (event: CachedBridgeEvent) => { }; const getErrorMessage = (error: unknown) => { + const simplifyMessage = (message: string) => { + const status = message.match(/status=(\d+)/)?.[1]; + const code = message.match(/code=([A-Z_]+)/)?.[1]; + + if (message.includes("bad response")) { + return `bad response${ + status || code + ? ` (${[status ? `status=${status}` : "", code ? `code=${code}` : ""].filter(Boolean).join(", ")})` + : "" + }`; + } + + if (message.includes("could not detect network")) { + return `could not detect network${code ? ` (code=${code})` : ""}`; + } + + if (message.includes("missing response")) { + return `missing response${code ? ` (code=${code})` : ""}`; + } + + return message.length > 240 ? `${message.slice(0, 237)}...` : message; + }; + if (error instanceof Error && error.message) { - return error.message; + return simplifyMessage(error.message); } if (typeof error === "string") { - return error; + return simplifyMessage(error); } return "Failed to load bridge history from RPC"; }; -const runWithConcurrency = async (tasks: Array<() => Promise>, concurrency: number): Promise => { +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const runSequentiallySettled = async ( + tasks: Array<() => Promise>, + delayMs = HISTORY_REQUEST_DELAY_MS, + options: { stopOnError?: boolean } = {} +) => { const results: T[] = []; + const errors: unknown[] = []; + + for (let index = 0; index < tasks.length; index += 1) { + let shouldStop = false; + + try { + results.push(await tasks[index]()); + } catch (error) { + errors.push(error); + shouldStop = Boolean(options.stopOnError); + } - // 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); + if (shouldStop) { + break; + } + + if (index < tasks.length - 1 && delayMs > 0) { + await delay(delayMs); + } } - return results; + return { results, errors }; }; const findHistoryStartBlock = async ( @@ -114,14 +197,19 @@ const findHistoryStartBlock = async ( const normalizeProviderLogs = ( contract: ethers.Contract, sourceChainId: SupportedChains, + eventName: BridgeEventName, 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 targetChainId = + eventName === "BridgeRequest" ? parsedLog.args?.targetChainId || parsedLog.args?.[2] : sourceChainId; + const amount = + eventName === "BridgeRequest" + ? parsedLog.args?.amount || parsedLog.args?.normalizedAmount || parsedLog.args?.[3] + : parsedLog.args?.amount || parsedLog.args?.normalizedAmount || parsedLog.args?.[2]; + const timestamp = eventName === "BridgeRequest" ? parsedLog.args?.timestamp || parsedLog.args?.[4] : "0"; const bridge = parsedLog.args?.bridge || parsedLog.args?.[5]; const id = parsedLog.args?.id || parsedLog.args?.[6]; @@ -164,31 +252,54 @@ const fetchEventLogs = async ( contract: ethers.Contract, eventName: BridgeEventName, fromBlock: number, - toBlock: number + toBlock: number, + account?: string ) => { if (fromBlock > toBlock) { - return [] as ethers.providers.Log[]; + return { + logs: [] as ethers.providers.Log[], + errors: [] as unknown[] + }; } 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 - }) + const accountTopics = createAccountEventTopics(topic, account); + const chunks = createBlockChunks(fromBlock, toBlock, HISTORY_BLOCK_CHUNK_SIZE).reverse(); + + // Public RPCs are sensitive to bursty eth_getLogs traffic, so log requests stay within 500 blocks and run + // sequentially with a short pause between requests. Indexed wallet topics keep each request narrow. + const { results: logsByChunk, errors } = await runSequentiallySettled( + chunks.flatMap(chunk => + accountTopics.map( + topics => () => + provider.getLogs({ + address: contract.address, + topics: topics as ethers.providers.Filter["topics"], + fromBlock: chunk.fromBlock, + toBlock: chunk.toBlock + }) + ) ), - MAX_PARALLEL_CHUNKS + HISTORY_REQUEST_DELAY_MS, + { stopOnError: true } ); - return logsByChunk.flat(); + return { + logs: dedupeLogs(logsByChunk.flat()), + errors + }; +}; + +const getPartialHistoryErrorMessage = (errors: unknown[]) => { + const uniqueMessages = Array.from(new Set(errors.map(getErrorMessage))); + const [firstMessage, secondMessage] = uniqueMessages; + + if (!secondMessage) { + return firstMessage || "Some history ranges could not refresh"; + } + + return `${firstMessage}; ${secondMessage}`; }; const syncChainHistory = async ( @@ -202,7 +313,7 @@ const syncChainHistory = async ( 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. + // Warm cache: resume from the last synced block. Cold cache: backfill the rolling history window. chainState?.lastSyncedBlock !== undefined ? chainState.lastSyncedBlock + 1 : await findHistoryStartBlock(provider, latestBlock, targetTimestamp); @@ -219,23 +330,37 @@ const syncChainHistory = async ( }; } - const [bridgeRequests, executedTransfers] = await Promise.all([ - fetchEventLogs(contract, "BridgeRequest", fromBlock, latestBlock), - fetchEventLogs(contract, "ExecutedTransfer", fromBlock, latestBlock) - ]); + const bridgeRequestsResult = await fetchEventLogs(contract, "BridgeRequest", fromBlock, latestBlock, account); + const executedTransfersResult = await fetchEventLogs(contract, "ExecutedTransfer", fromBlock, latestBlock, account); + const errors = [...bridgeRequestsResult.errors, ...executedTransfersResult.errors]; return { chainId, - bridgeRequests: filterEventsForAccount(normalizeProviderLogs(contract, chainId, bridgeRequests), account), - executedTransfers: filterEventsForAccount(normalizeProviderLogs(contract, chainId, executedTransfers), account), - chainState: { - lastSyncedBlock: latestBlock, - lastSuccessfulSyncAt: Date.now() - } satisfies ChainSyncState + bridgeRequests: filterEventsForAccount( + normalizeProviderLogs(contract, chainId, "BridgeRequest", bridgeRequestsResult.logs), + account + ), + executedTransfers: filterEventsForAccount( + normalizeProviderLogs(contract, chainId, "ExecutedTransfer", executedTransfersResult.logs), + account + ), + chainState: + errors.length > 0 + ? ({ + ...(chainState || {}), + error: { + message: getPartialHistoryErrorMessage(errors), + updatedAt: Date.now() + } + } satisfies ChainSyncState) + : ({ + lastSyncedBlock: latestBlock, + lastSuccessfulSyncAt: Date.now() + } satisfies ChainSyncState) }; }; -export const useMPBBridgeHistory = () => { +export const useMPBBridgeHistory = ({ readOnlyUrls, chainIds }: UseMPBBridgeHistoryOptions = {}) => { const { account } = useEthers(); const [cacheLoaded, setCacheLoaded] = useState(false); const [historyCache, setHistoryCache] = useState({}); @@ -243,10 +368,10 @@ export const useMPBBridgeHistory = () => { const [syncing, setSyncing] = useState(false); const historyCacheRef = useRef({}); - const fuseBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.FUSE); - const celoBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.CELO); - const mainnetBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.MAINNET); - const xdcBridgeContract = useGetContract("MpbBridge", true, "base", SupportedChains.XDC); + const fuseBridgeContract = useMPBBridgeHistoryContract(SupportedChains.FUSE, readOnlyUrls); + const celoBridgeContract = useMPBBridgeHistoryContract(SupportedChains.CELO, readOnlyUrls); + const mainnetBridgeContract = useMPBBridgeHistoryContract(SupportedChains.MAINNET, readOnlyUrls); + const xdcBridgeContract = useMPBBridgeHistoryContract(SupportedChains.XDC, readOnlyUrls); const contracts = useMemo( () => ({ @@ -257,6 +382,15 @@ export const useMPBBridgeHistory = () => { }), [celoBridgeContract, fuseBridgeContract, mainnetBridgeContract, xdcBridgeContract] ); + const activeChainIds = useMemo(() => { + const requestedChainIds = chainIds?.length ? chainIds : CHAIN_IDS; + const supportedChainIds = new Set(CHAIN_IDS); + const uniqueChainIds = Array.from( + new Set(requestedChainIds.filter((chainId): chainId is SupportedChains => supportedChainIds.has(chainId))) + ); + + return uniqueChainIds.length ? uniqueChainIds : CHAIN_IDS; + }, [chainIds]); const cacheKey = useMemo(() => { if (!account) return undefined; @@ -314,7 +448,7 @@ export const useMPBBridgeHistory = () => { return; } - const chainContracts = CHAIN_IDS.flatMap(chainId => + const chainContracts = activeChainIds.flatMap(chainId => contracts[chainId] ? [{ chainId, contract: contracts[chainId] as ethers.Contract }] : [] ); @@ -329,10 +463,22 @@ export const useMPBBridgeHistory = () => { 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)) - ); + const settledChains: SyncChainHistorySettlement[] = []; + + // Sync every chain independently and sequentially so one RPC cannot rate-limit the others. + for (const { chainId, contract } of chainContracts) { + try { + settledChains.push({ + status: "fulfilled", + value: await syncChainHistory(chainId, contract, currentCache, account) + }); + } catch (reason) { + settledChains.push({ + status: "rejected", + reason + }); + } + } if (cancelled) { return; @@ -389,14 +535,21 @@ export const useMPBBridgeHistory = () => { return () => { cancelled = true; }; - }, [account, cacheKey, cacheLoaded, contracts, refreshTick]); + }, [account, activeChainIds, cacheKey, cacheLoaded, contracts, refreshTick]); const refreshHistory = useCallback(() => { setRefreshTick(current => current + 1); }, []); return useMemo(() => { - const errorsByChain = getErrorsByChain(historyCache); + const allErrorsByChain = getErrorsByChain(historyCache); + const activeErrorsByChain = activeChainIds.reduce((result, chainId) => { + if (allErrorsByChain[chainId]) { + result[chainId] = allErrorsByChain[chainId]; + } + + return result; + }, {} as Record); const hasCachedRows = Boolean( (historyCache.BridgeRequest || []).length || (historyCache.ExecutedTransfer || []).length ); @@ -407,7 +560,7 @@ export const useMPBBridgeHistory = () => { historySorted: undefined, initialLoading: true, refreshing: false, - errorsByChain, + errorsByChain: activeErrorsByChain, refreshHistory }; } @@ -464,8 +617,8 @@ export const useMPBBridgeHistory = () => { historySorted, initialLoading: syncing && !hasCachedRows, refreshing: syncing, - errorsByChain, + errorsByChain: activeErrorsByChain, refreshHistory }; - }, [account, cacheLoaded, historyCache, refreshHistory, syncing]); + }, [account, activeChainIds, cacheLoaded, historyCache, refreshHistory, syncing]); }; From 7135957f573f4e64a7fc7816f1c7e3a3f2815243 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Sat, 4 Jul 2026 12:31:04 +0100 Subject: [PATCH 2/5] fix: finalize MPB bridge history recovery --- .../bridge/mpbridge/TransactionHistory.tsx | 6 +- .../src/sdk/mpbridge/hooks/useMPBBridge.ts | 21 +- .../sdk/mpbridge/hooks/useMPBBridgeHistory.ts | 317 ++++++++++++------ 3 files changed, 223 insertions(+), 121 deletions(-) diff --git a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx index f78a45b8a..1a7b5b03c 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx +++ b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx @@ -21,6 +21,8 @@ export const TransactionHistory: React.FC = ({ onTxDetailsPress }) => { const errorEntries = Object.entries(historyErrorsByChain || {}); + const hasTransactionHistory = realTransactionHistory.length > 0; + const showHistoryErrors = errorEntries.length > 0 && !hasTransactionHistory; return ( @@ -48,7 +50,7 @@ export const TransactionHistory: React.FC = ({ ) : null} - {errorEntries.length > 0 ? ( + {showHistoryErrors ? ( @@ -69,7 +71,7 @@ export const TransactionHistory: React.FC = ({ Loading transaction history... - ) : realTransactionHistory.length > 0 ? ( + ) : hasTransactionHistory ? ( diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts index 6360d70e3..ea021d5db 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts @@ -243,7 +243,7 @@ export const useMPBBridge = (bridgeProvider: BridgeProvider = "axelar"): UseMPBB [bridgeProvider, bridgeTo, computeLayerZeroFee] ); - // Helper function to execute bridge transaction (approve if needed, then bridge) + // Helper function to validate a bridge request, then ask for a fresh approval before bridging. const executeBridgeTransaction = useCallback( async (bridgeRequest: BridgeRequest, fees: any) => { const { source, target } = { @@ -265,26 +265,9 @@ export const useMPBBridge = (bridgeProvider: BridgeProvider = "axelar"): UseMPBB return; } - // Check allowance — skip approval when already sufficient - if (gdContract && account) { - try { - const allowance = await gdContract.allowance(account, bridgeContract.address); - const amountBN = ethers.BigNumber.from(bridgeRequest.amount); - - if (allowance.gte(amountBN)) { - console.log("[useMPBBridge] Allowance sufficient, executing bridgeTo directly"); - // executeBridgeTransfer handles its own locking - await executeBridgeTransfer(bridgeRequest); - return; - } - } catch (error) { - // Failed to check allowance, proceed with approval flow - } - } - void approve.send(bridgeContract.address, bridgeRequest.amount); }, - [bridgeProvider, bridgeContract, account, approve, validateBridgeTransaction, gdContract, executeBridgeTransfer] + [bridgeProvider, bridgeContract, approve, validateBridgeTransaction] ); useEffect(() => { diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts index c2aad0923..6ebcebcc1 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts @@ -22,9 +22,11 @@ import { mergeBridgeHistoryCache } from "./useMPBBridgeHistory.helpers"; -const HISTORY_CACHE_VERSION = 5; +const HISTORY_CACHE_VERSION = 6; const CHAIN_IDS = [SupportedChains.FUSE, SupportedChains.CELO, SupportedChains.MAINNET, SupportedChains.XDC]; const HISTORY_REQUEST_DELAY_MS = 500; +// Celo is a fast chain, so one day of history is far more than a few thousand blocks. +const HISTORY_FAST_SYNC_BLOCKS = HISTORY_BLOCK_CHUNK_SIZE * 240; export type MPBBridgeHistoryReadOnlyUrls = Partial>; @@ -33,9 +35,19 @@ export type UseMPBBridgeHistoryOptions = { chainIds?: SupportedChains[]; }; -type SyncChainHistorySettlement = - | { status: "fulfilled"; value: Awaited> } - | { status: "rejected"; reason: unknown }; +type ChainHistorySyncRange = { + fromBlock: number; + toBlock: number; + commitCursor: boolean; +}; + +type ChainHistoryEventSyncResult = { + chainId: SupportedChains; + eventName: BridgeEventName; + events: CachedBridgeEvent[]; + hasErrors: boolean; + chainState: ChainSyncState; +}; const useMPBBridgeHistoryContract = (chainId: SupportedChains, readOnlyUrls?: MPBBridgeHistoryReadOnlyUrls) => { const { defaultEnv } = useGetEnvChainId(chainId); @@ -96,11 +108,29 @@ const hydrateCachedEvent = (event: CachedBridgeEvent) => { const getErrorMessage = (error: unknown) => { const simplifyMessage = (message: string) => { + const normalizedMessage = message.toLowerCase(); const status = message.match(/status=(\d+)/)?.[1]; const code = message.match(/code=([A-Z_]+)/)?.[1]; + if ( + normalizedMessage.includes("usage limit") || + normalizedMessage.includes("rate limit") || + normalizedMessage.includes("too many requests") || + message.includes("429") + ) { + return "RPC rate limit reached while refreshing history"; + } + + if (normalizedMessage.includes("forbidden") || message.includes("403")) { + return "RPC request was rejected while refreshing history"; + } + + if (normalizedMessage.includes("processing response error")) { + return "RPC response error while refreshing history"; + } + if (message.includes("bad response")) { - return `bad response${ + return `RPC response error while refreshing history${ status || code ? ` (${[status ? `status=${status}` : "", code ? `code=${code}` : ""].filter(Boolean).join(", ")})` : "" @@ -108,11 +138,11 @@ const getErrorMessage = (error: unknown) => { } if (message.includes("could not detect network")) { - return `could not detect network${code ? ` (code=${code})` : ""}`; + return `RPC network could not be detected${code ? ` (code=${code})` : ""}`; } if (message.includes("missing response")) { - return `missing response${code ? ` (code=${code})` : ""}`; + return `RPC did not return a response${code ? ` (code=${code})` : ""}`; } return message.length > 240 ? `${message.slice(0, 237)}...` : message; @@ -253,7 +283,8 @@ const fetchEventLogs = async ( eventName: BridgeEventName, fromBlock: number, toBlock: number, - account?: string + account?: string, + onChunkLogs?: (logs: ethers.providers.Log[]) => void ) => { if (fromBlock > toBlock) { return { @@ -266,27 +297,58 @@ const fetchEventLogs = async ( const topic = contract.interface.getEventTopic(eventName); const accountTopics = createAccountEventTopics(topic, account); const chunks = createBlockChunks(fromBlock, toBlock, HISTORY_BLOCK_CHUNK_SIZE).reverse(); + const logsByChunk: ethers.providers.Log[] = []; + const errors: unknown[] = []; + const topicPasses = accountTopics.length > 1 ? [[accountTopics[0]], accountTopics.slice(1)] : [accountTopics]; // Public RPCs are sensitive to bursty eth_getLogs traffic, so log requests stay within 500 blocks and run // sequentially with a short pause between requests. Indexed wallet topics keep each request narrow. - const { results: logsByChunk, errors } = await runSequentiallySettled( - chunks.flatMap(chunk => - accountTopics.map( - topics => () => - provider.getLogs({ - address: contract.address, - topics: topics as ethers.providers.Filter["topics"], - fromBlock: chunk.fromBlock, - toBlock: chunk.toBlock - }) - ) - ), - HISTORY_REQUEST_DELAY_MS, - { stopOnError: true } - ); + for (let passIndex = 0; passIndex < topicPasses.length; passIndex += 1) { + const topicsForPass = topicPasses[passIndex]; + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) { + const chunk = chunks[chunkIndex]; + const { + results: chunkLogsByTopic, + errors: chunkErrors + } = await runSequentiallySettled( + topicsForPass.map( + topics => () => + provider.getLogs({ + address: contract.address, + topics: topics as ethers.providers.Filter["topics"], + fromBlock: chunk.fromBlock, + toBlock: chunk.toBlock + }) + ), + HISTORY_REQUEST_DELAY_MS, + { stopOnError: true } + ); + + if (chunkErrors.length) { + errors.push(...chunkErrors); + break; + } + + const chunkLogs = dedupeLogs(chunkLogsByTopic.flat()); + logsByChunk.push(...chunkLogs); + + if (chunkLogs.length) { + onChunkLogs?.(chunkLogs); + } + + if (chunkIndex < chunks.length - 1) { + await delay(HISTORY_REQUEST_DELAY_MS); + } + } + + if (errors.length || passIndex >= topicPasses.length - 1) { + break; + } + } return { - logs: dedupeLogs(logsByChunk.flat()), + logs: dedupeLogs(logsByChunk), errors }; }; @@ -302,27 +364,31 @@ const getPartialHistoryErrorMessage = (errors: unknown[]) => { return `${firstMessage}; ${secondMessage}`; }; -const syncChainHistory = async ( +const getChainHistorySyncPlan = async ( chainId: SupportedChains, contract: ethers.Contract, - currentCache: MPBBridgeHistoryCache, - account?: string + currentCache: MPBBridgeHistoryCache ) => { const provider = contract.provider as ethers.providers.Provider; const latestBlock = await provider.getBlockNumber(); const chainState = currentCache.chains?.[chainId]; + const hasCachedChainEvents = Boolean( + currentCache.BridgeRequest?.some(event => event.sourceChainId === chainId) || + currentCache.ExecutedTransfer?.some(event => event.sourceChainId === chainId) + ); + const isWarmCache = chainState?.lastSyncedBlock !== undefined && hasCachedChainEvents; const targetTimestamp = Math.floor(Date.now() / 1000) - HISTORY_WINDOW_SECONDS; const fromBlock = // Warm cache: resume from the last synced block. Cold cache: backfill the rolling history window. - chainState?.lastSyncedBlock !== undefined - ? chainState.lastSyncedBlock + 1 + isWarmCache + ? (chainState?.lastSyncedBlock as number) + 1 : await findHistoryStartBlock(provider, latestBlock, targetTimestamp); if (fromBlock > latestBlock) { return { chainId, - bridgeRequests: [] as CachedBridgeEvent[], - executedTransfers: [] as CachedBridgeEvent[], + latestBlock, + ranges: [] as ChainHistorySyncRange[], chainState: { lastSyncedBlock: latestBlock, lastSuccessfulSyncAt: Date.now() @@ -330,20 +396,54 @@ const syncChainHistory = async ( }; } - const bridgeRequestsResult = await fetchEventLogs(contract, "BridgeRequest", fromBlock, latestBlock, account); - const executedTransfersResult = await fetchEventLogs(contract, "ExecutedTransfer", fromBlock, latestBlock, account); - const errors = [...bridgeRequestsResult.errors, ...executedTransfersResult.errors]; + const recentFromBlock = isWarmCache ? fromBlock : Math.max(fromBlock, latestBlock - HISTORY_FAST_SYNC_BLOCKS); + const ranges = + recentFromBlock > fromBlock + ? [ + { fromBlock: recentFromBlock, toBlock: latestBlock, commitCursor: false }, + { fromBlock, toBlock: recentFromBlock - 1, commitCursor: true } + ] + : [{ fromBlock, toBlock: latestBlock, commitCursor: true }]; return { chainId, - bridgeRequests: filterEventsForAccount( - normalizeProviderLogs(contract, chainId, "BridgeRequest", bridgeRequestsResult.logs), - account - ), - executedTransfers: filterEventsForAccount( - normalizeProviderLogs(contract, chainId, "ExecutedTransfer", executedTransfersResult.logs), - account - ), + latestBlock, + chainState, + ranges + }; +}; + +const syncChainHistoryRange = async ( + chainId: SupportedChains, + contract: ethers.Contract, + eventName: BridgeEventName, + range: ChainHistorySyncRange, + latestBlock: number, + chainState?: ChainSyncState, + account?: string, + onEvents?: (eventName: BridgeEventName, events: CachedBridgeEvent[]) => void +): Promise => { + const eventResult = await fetchEventLogs( + contract, + eventName, + range.fromBlock, + range.toBlock, + account, + logs => { + const events = filterEventsForAccount(normalizeProviderLogs(contract, chainId, eventName, logs), account); + + if (events.length) { + onEvents?.(eventName, events); + } + } + ); + const errors = eventResult.errors; + + return { + chainId, + eventName, + events: filterEventsForAccount(normalizeProviderLogs(contract, chainId, eventName, eventResult.logs), account), + hasErrors: errors.length > 0, chainState: errors.length > 0 ? ({ @@ -353,6 +453,12 @@ const syncChainHistory = async ( updatedAt: Date.now() } } satisfies ChainSyncState) + : !range.commitCursor + ? ({ + ...(chainState || {}), + lastSuccessfulSyncAt: Date.now(), + error: undefined + } satisfies ChainSyncState) : ({ lastSyncedBlock: latestBlock, lastSuccessfulSyncAt: Date.now() @@ -461,69 +567,83 @@ export const useMPBBridgeHistory = ({ readOnlyUrls, chainIds }: UseMPBBridgeHist // 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; - const settledChains: SyncChainHistorySettlement[] = []; + const publishHistoryCache = ( + nextEvents: Partial>, + nextChains: Partial> + ) => { + const nextCache = mergeBridgeHistoryCache(historyCacheRef.current, nextEvents, 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) + ); + }; + + const syncHistory = async () => { // Sync every chain independently and sequentially so one RPC cannot rate-limit the others. for (const { chainId, contract } of chainContracts) { try { - settledChains.push({ - status: "fulfilled", - value: await syncChainHistory(chainId, contract, currentCache, account) - }); - } catch (reason) { - settledChains.push({ - status: "rejected", - reason - }); - } - } - - if (cancelled) { - return; - } - - const nextChains: Partial> = {}; - const nextBridgeRequests: CachedBridgeEvent[] = []; - const nextExecutedTransfers: CachedBridgeEvent[] = []; - - settledChains.forEach((result, index) => { - const { chainId } = chainContracts[index]; + const plan = await getChainHistorySyncPlan(chainId, contract, historyCacheRef.current); - 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; - } + if (cancelled) { + 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() + if (!plan.ranges.length) { + publishHistoryCache({}, { [chainId]: plan.chainState }); + continue; } - }; - }); - const nextCache = mergeBridgeHistoryCache( - currentCache, - { - BridgeRequest: nextBridgeRequests, - ExecutedTransfer: nextExecutedTransfers - }, - nextChains - ); + let chainHadErrors = false; + + for (const range of plan.ranges) { + for (const eventName of ["BridgeRequest", "ExecutedTransfer"] as BridgeEventName[]) { + const shouldCommitCursor = eventName === "ExecutedTransfer" && range.commitCursor && !chainHadErrors; + const result = await syncChainHistoryRange( + chainId, + contract, + eventName, + { ...range, commitCursor: shouldCommitCursor }, + plan.latestBlock, + historyCacheRef.current.chains?.[chainId], + account, + (chunkEventName, events) => { + if (!cancelled) { + publishHistoryCache({ [chunkEventName]: events }, {}); + } + } + ); + + if (cancelled) { + return; + } + + chainHadErrors = chainHadErrors || result.hasErrors; + publishHistoryCache({ [result.eventName]: result.events }, { [chainId]: result.chainState }); + } + } + } catch (reason) { + if (cancelled) { + return; + } - 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) - ); + // Failed chains keep their last good cursor and surface a chain-specific error for the UI. + publishHistoryCache( + {}, + { + [chainId]: { + ...(historyCacheRef.current.chains?.[chainId] || {}), + error: { + message: getErrorMessage(reason), + updatedAt: Date.now() + } + } + } + ); + } + } }; void syncHistory().finally(() => { @@ -550,9 +670,6 @@ export const useMPBBridgeHistory = ({ readOnlyUrls, chainIds }: UseMPBBridgeHist return result; }, {} as Record); - const hasCachedRows = Boolean( - (historyCache.BridgeRequest || []).length || (historyCache.ExecutedTransfer || []).length - ); if (!cacheLoaded) { return { @@ -615,7 +732,7 @@ export const useMPBBridgeHistory = ({ readOnlyUrls, chainIds }: UseMPBBridgeHist return { history: historySorted, historySorted, - initialLoading: syncing && !hasCachedRows, + initialLoading: false, refreshing: syncing, errorsByChain: activeErrorsByChain, refreshHistory From 948845a22df28da4a2fd1f56c195780a378791c8 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Tue, 7 Jul 2026 10:18:23 +0100 Subject: [PATCH 3/5] fix: handle transient MPB bridge block read errors --- .../mpbridge/hooks/useMPBBridge.helpers.ts | 16 ++ .../sdk/mpbridge/hooks/useMPBBridge.test.ts | 21 +++ .../src/sdk/mpbridge/hooks/useMPBBridge.ts | 137 +++++++++++++++++- 3 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.helpers.ts create mode 100644 packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.test.ts diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.helpers.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.helpers.ts new file mode 100644 index 000000000..2cc4e990c --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.helpers.ts @@ -0,0 +1,16 @@ +export const getTransactionErrorMessage = (error: any): string => { + return ( + error?.error?.data?.message || + error?.error?.message || + error?.reason || + error?.data?.message || + error?.message || + "Transaction failed" + ); +}; + +export const isTransientBlockReadError = (error: any): boolean => { + const message = getTransactionErrorMessage(error).toLowerCase(); + + return message.includes("unknown block") || message.includes("no block"); +}; diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.test.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.test.ts new file mode 100644 index 000000000..a3b6ed32b --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.test.ts @@ -0,0 +1,21 @@ +import { getTransactionErrorMessage, isTransientBlockReadError } from "./useMPBBridge.helpers"; + +describe("useMPBBridge transaction error helpers", () => { + it("detects unknown block read errors from common wallet/provider shapes", () => { + expect(isTransientBlockReadError(new Error("Unknown block"))).toBe(true); + expect(isTransientBlockReadError({ reason: "no block found" })).toBe(true); + expect(isTransientBlockReadError({ error: { data: { message: "UNKNOWN BLOCK" } } })).toBe(true); + }); + + it("does not classify regular transaction failures as transient block reads", () => { + expect(isTransientBlockReadError(new Error("user rejected transaction"))).toBe(false); + expect(isTransientBlockReadError({ error: { message: "execution reverted" } })).toBe(false); + }); + + it("extracts a readable transaction error message", () => { + expect(getTransactionErrorMessage({ error: { data: { message: "execution reverted" } } })).toBe( + "execution reverted" + ); + expect(getTransactionErrorMessage({ reason: "user rejected transaction" })).toBe("user rejected transaction"); + }); +}); diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts index ea021d5db..6a18eecf7 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useContractFunction, useEthers } from "@usedapp/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { TransactionStatus, useContractFunction, useEthers } from "@usedapp/core"; import { ethers } from "ethers"; import { useSwitchNetwork } from "../../../contexts"; import { useG$Decimals } from "../../base/react"; @@ -19,6 +19,54 @@ import { useMPBG$TokenContract } from "./useMPBG$TokenContract"; import { useLayerZeroFee } from "./useLayerZeroFee"; import { useBridgeMonitoring } from "./useBridgeMonitoring"; import { useBridgeValidators } from "./useBridgeValidators"; +import { getTransactionErrorMessage, isTransientBlockReadError } from "./useMPBBridge.helpers"; + +const BRIDGE_TO_TRANSACTION_NAME = "MPBBridgeTo"; + +const createIdleTransactionStatus = (transactionName: string): TransactionStatus => ({ + status: "None", + transactionName +}); + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const waitForReceiptAfterSubmission = async ( + transaction: ethers.providers.TransactionResponse, + provider: ethers.providers.Provider +) => { + let lastError: unknown; + + for (let attempt = 0; attempt < 6; attempt++) { + try { + return await transaction.wait(); + } catch (error) { + if (!isTransientBlockReadError(error)) { + throw error; + } + + lastError = error; + await sleep(1000 * (attempt + 1)); + + try { + const receipt = await provider.getTransactionReceipt(transaction.hash); + + if (receipt) { + return receipt; + } + } catch (receiptError) { + if (!isTransientBlockReadError(receiptError)) { + throw receiptError; + } + + lastError = receiptError; + } + } + } + + console.warn("[useMPBBridge] Receipt polling hit transient block read errors after submission", lastError); + + return undefined; +}; export const useMPBBridge = (bridgeProvider: BridgeProvider = "axelar"): UseMPBBridgeReturn => { const bridgeLock = useRef(false); @@ -40,9 +88,88 @@ export const useMPBBridge = (bridgeProvider: BridgeProvider = "axelar"): UseMPBB transactionName: "MPBBridgeApprove" }); - const bridgeTo = useContractFunction(bridgeContractOrNull, "bridgeTo", { - transactionName: "MPBBridgeTo" - }); + const [bridgeToState, setBridgeToState] = useState(() => + createIdleTransactionStatus(BRIDGE_TO_TRANSACTION_NAME) + ); + + const resetBridgeToState = useCallback(() => { + setBridgeToState(createIdleTransactionStatus(BRIDGE_TO_TRANSACTION_NAME)); + }, []); + + const sendBridgeTo = useCallback( + async (...args: any[]) => { + if (!bridgeContractOrNull || !library || !account || !chainId) { + const errorMessage = "Bridge contract is not ready"; + setBridgeToState({ + status: "Exception", + errorMessage, + chainId, + transactionName: BRIDGE_TO_TRANSACTION_NAME + }); + return undefined; + } + + let transaction: ethers.providers.TransactionResponse | undefined; + + setBridgeToState({ + status: "PendingSignature", + chainId, + transactionName: BRIDGE_TO_TRANSACTION_NAME + }); + + try { + const signer = (library as ethers.providers.Web3Provider).getSigner(account); + const bridgeContractWithSigner = bridgeContractOrNull.connect(signer); + const submittedTransaction = (await bridgeContractWithSigner.bridgeTo( + ...args + )) as ethers.providers.TransactionResponse; + transaction = submittedTransaction; + + setBridgeToState({ + status: "Mining", + transaction: submittedTransaction, + chainId, + transactionName: BRIDGE_TO_TRANSACTION_NAME + }); + + const receipt = await waitForReceiptAfterSubmission(submittedTransaction, library); + const didTransactionFail = receipt?.status === 0; + + setBridgeToState({ + status: didTransactionFail ? "Fail" : "Success", + transaction: submittedTransaction, + receipt, + errorMessage: didTransactionFail ? "Bridge transaction failed" : undefined, + chainId, + transactionName: BRIDGE_TO_TRANSACTION_NAME + }); + + return receipt; + } catch (error: any) { + const errorMessage = getTransactionErrorMessage(error); + + setBridgeToState({ + status: transaction ? "Fail" : "Exception", + transaction, + errorMessage, + chainId, + transactionName: BRIDGE_TO_TRANSACTION_NAME + }); + + return undefined; + } + }, + [account, bridgeContractOrNull, chainId, library] + ); + + const bridgeTo = useMemo( + () => ({ + state: bridgeToState, + send: sendBridgeTo, + resetState: resetBridgeToState + }), + [bridgeToState, sendBridgeTo, resetBridgeToState] + ); const { computeLayerZeroFee } = useLayerZeroFee(bridgeContractOrNull, bridgeProvider, account); From c893e2b4c14840109acf52924153040f993e2863 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Tue, 7 Jul 2026 17:21:12 +0100 Subject: [PATCH 4/5] fix: polish MPB bridge transaction details --- .../mpbridge/utils/transactionHelpers.test.ts | 30 +++++++++++++++++++ .../mpbridge/utils/transactionHelpers.ts | 4 ++- .../sdk-v2/src/sdk/mpbridge/hooks/index.ts | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.test.ts diff --git a/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.test.ts b/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.test.ts new file mode 100644 index 000000000..a115dc0c9 --- /dev/null +++ b/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.test.ts @@ -0,0 +1,30 @@ +import { TransactionStatus } from "@usedapp/core"; + +import { createTransactionDetails } from "./transactionHelpers"; + +jest.mock("@gooddollar/web3sdk-v2", () => ({ + getSourceChainId: jest.fn(() => 42220) +})); + +describe("createTransactionDetails", () => { + it("sets a date for the submitted transaction details", () => { + const date = new Date("2026-07-07T10:00:00.000Z"); + + const transaction = createTransactionDetails({ + amountWei: "10000000000000000000", + sourceChain: "celo", + targetChain: "xdc", + bridgeProvider: "layerzero", + bridgeStatus: { + status: "Success", + transaction: { hash: "0xbridge" } + } as Partial, + bridgeToTxHash: undefined, + date + }); + + expect(transaction.date).toBe(date); + expect(transaction.transactionHash).toBe("0xbridge"); + expect(transaction.amount).toBe("10.00"); + }); +}); diff --git a/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.ts b/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.ts index 427cdd3a1..a2e848b6b 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/utils/transactionHelpers.ts @@ -11,10 +11,11 @@ interface CreateTransactionDetailsParams { bridgeProvider: string; bridgeStatus: Partial | undefined; bridgeToTxHash: string | undefined; + date?: Date; } export const createTransactionDetails = (params: CreateTransactionDetailsParams): BridgeTransaction => { - const { amountWei, sourceChain, targetChain, bridgeProvider, bridgeStatus, bridgeToTxHash } = params; + const { amountWei, sourceChain, targetChain, bridgeProvider, bridgeStatus, bridgeToTxHash, date } = params; const amountBN = ethers.BigNumber.from(amountWei || "0"); const amountFormatted = utils.formatEther(amountBN); @@ -37,6 +38,7 @@ export const createTransactionDetails = (params: CreateTransactionDetailsParams) amount: parseFloat(amountFormatted).toFixed(2), bridgeProvider: bridgeProvider as "axelar" | "layerzero", status, + date: date ?? new Date(), chainId: sourceChainId }; }; diff --git a/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts index fcc15cd80..c8797feca 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts @@ -2,6 +2,7 @@ export * from "./useBridgeMonitoring"; export * from "./useBridgeValidators"; export * from "./useGetMPBBridgeData"; export * from "./useLayerZeroFee"; +export * from "./useMPBBridge.helpers"; export * from "./useMPBBridge"; export * from "./useMPBBridgeHistory.helpers"; export * from "./useMPBBridgeHistory"; From 39b91bce075d866ff06efed0c6a2548e39e82e59 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Tue, 14 Jul 2026 11:35:27 +0100 Subject: [PATCH 5/5] fix: address MPB bridge review feedback --- .../bridge/mpbridge/TransactionHistory.tsx | 3 +-- .../feature/useMPBBridgeViewController.ts | 18 +++++++----------- .../sdk-v2/src/hooks/useMulticallAtChain.tsx | 1 + 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx index 1a7b5b03c..5a5ccc340 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx +++ b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx @@ -22,7 +22,6 @@ export const TransactionHistory: React.FC = ({ }) => { const errorEntries = Object.entries(historyErrorsByChain || {}); const hasTransactionHistory = realTransactionHistory.length > 0; - const showHistoryErrors = errorEntries.length > 0 && !hasTransactionHistory; return ( @@ -50,7 +49,7 @@ export const TransactionHistory: React.FC = ({ ) : null} - {showHistoryErrors ? ( + {errorEntries.length > 0 ? ( 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 f56dd9415..5ab7535a5 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts @@ -17,12 +17,12 @@ import { useMPBBridgeUiState } from "./useMPBBridgeUiState"; const DEBOUNCE_MS = 300; const TRANSACTION_HISTORY_DEBOUNCE_MS = 2000; -const CHAIN_NAME_TO_ID: Record = { - fuse: SupportedChains.FUSE, - celo: SupportedChains.CELO, - mainnet: SupportedChains.MAINNET, - xdc: SupportedChains.XDC -}; +const BRIDGE_HISTORY_CHAIN_IDS: SupportedChains[] = [ + SupportedChains.CELO, + SupportedChains.FUSE, + SupportedChains.MAINNET, + SupportedChains.XDC +]; const FLOW_PENDING_STATES = new Set([ "awaiting_network_switch", @@ -172,12 +172,8 @@ export const useMPBBridgeViewController = ({ closeAllDropdowns } = useMPBBridgeUiState(); - const bridgeHistoryChainIds = useMemo( - () => Array.from(new Set([CHAIN_NAME_TO_ID[sourceChain], CHAIN_NAME_TO_ID[targetChain]].filter(Boolean))), - [sourceChain, targetChain] - ); const { realTransactionHistory, historyLoading, historyRefreshing, historyErrorsByChain, refreshHistory } = - useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS, bridgeReadOnlyUrls, bridgeHistoryChainIds); + useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS, bridgeReadOnlyUrls, BRIDGE_HISTORY_CHAIN_IDS); const { getBalanceForChain } = useChainBalances(); const gdValue = getBalanceForChain(sourceChain); diff --git a/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx b/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx index 4eef71aaa..9cf7a0738 100644 --- a/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx +++ b/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx @@ -86,6 +86,7 @@ export const useReadOnlyProvider = (chainId: number) => { return (factory as any)() as JsonRpcProvider; } + // The chain is already known here, so avoid an extra network-detection RPC on rate-limited endpoints. const provider = new StaticJsonRpcProvider(factory as any, chainId); provider.pollingInterval = pollingInterval;