diff --git a/.gitignore b/.gitignore index 94096bab5..ce4def8c0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ yalc.lock .continueignore e2e.log docs/specs/** -codex-resume \ No newline at end of file +codex-resume +.bounties \ 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/TransactionHistory.tsx b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx index 441a3f391..7197349aa 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx +++ b/packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx @@ -1,23 +1,90 @@ import React from "react"; -import { Box, Spinner, Text, VStack } from "native-base"; +import { Box, Button, HStack, Spinner, Text, VStack } from "native-base"; +import { ExplorerLink } from "../../../core"; import { BridgeTransactionList } from "./MPBBridgeTransactionCard"; +import { capitalizeChain, getChainName } from "./utils"; interface TransactionHistoryProps { realTransactionHistory: any[]; historyLoading: boolean; + historyRefreshing: boolean; + historyErrorsByChain: Record; + explorerChainId?: number; + explorerAddress?: string; + onRefresh: () => void; onTxDetailsPress: (tx: any) => void; } export const TransactionHistory: React.FC = ({ realTransactionHistory, historyLoading, + historyRefreshing, + historyErrorsByChain, + explorerChainId, + explorerAddress, + onRefresh, onTxDetailsPress }) => { + const errorEntries = Object.entries(historyErrorsByChain || {}); + const hasTransactionHistory = realTransactionHistory.length > 0; + return ( - - Recent Transactions - + + + Recent Transactions + + + + + + History builds as you use this bridge. We check up to the latest 5,000 blocks on each supported network. + + + Older transactions or activity from another device may not appear. + + {explorerChainId && explorerAddress ? ( + + ) : null} + + {historyRefreshing && !historyLoading ? ( + + + + Refreshing transaction history... + + + ) : null} + {errorEntries.length > 0 ? ( + + + + Some transaction history could not be refreshed. + + {errorEntries.map(([chainId]) => ( + + Could not fetch history for {capitalizeChain(getChainName(Number(chainId)))} at this time. You can try + to reload or try later. + + ))} + + + ) : null} {historyLoading ? ( @@ -25,14 +92,14 @@ export const TransactionHistory: React.FC = ({ Loading transaction history... - ) : realTransactionHistory.length > 0 ? ( + ) : hasTransactionHistory ? ( ) : ( - No recent bridge transactions found + No bridge transactions found in the latest 5,000 blocks Make sure your wallet is connected to see your bridge transactions 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 ca01b079f..5310a1b5b 100644 --- a/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts +++ b/packages/good-design/src/apps/bridge/mpbridge/feature/useMPBBridgeViewController.ts @@ -1,4 +1,5 @@ import { useEffect, useCallback, useState, useMemo, useRef } from "react"; +import { useEthers } from "@usedapp/core"; import { SupportedChains, deriveMPBBridgeFlowState } from "@gooddollar/web3sdk-v2"; import { ethers } from "ethers"; @@ -17,6 +18,12 @@ import { useMPBBridgeUiState } from "./useMPBBridgeUiState"; const DEBOUNCE_MS = 300; const TRANSACTION_HISTORY_DEBOUNCE_MS = 2000; +const BRIDGE_HISTORY_CHAIN_IDS: SupportedChains[] = [ + SupportedChains.CELO, + SupportedChains.FUSE, + SupportedChains.MAINNET, + SupportedChains.XDC +]; const FLOW_PENDING_STATES = new Set([ "awaiting_network_switch", @@ -113,6 +120,11 @@ export interface MPBBridgeViewModel { transactionHistoryProps: { realTransactionHistory: any[]; historyLoading: boolean; + historyRefreshing: boolean; + historyErrorsByChain: Record; + explorerChainId?: number; + explorerAddress?: string; + onRefresh: () => void; onTxDetailsPress: (tx: any) => void; }; } @@ -131,9 +143,11 @@ export const useMPBBridgeViewController = ({ onBridgeStart, onBridgeFailed, onBridgeSuccess, + bridgeReadOnlyUrls, bridgeProvider: propBridgeProvider, onBridgeProviderChange }: MPBBridgeProps): MPBBridgeViewModel => { + const { account, chainId } = useEthers(); const [isBridging, setBridging] = useState(false); const [localBridgeProvider, setLocalBridgeProvider] = useState("axelar"); const bridgeProvider = propBridgeProvider || localBridgeProvider; @@ -162,7 +176,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, bridgeReadOnlyUrls, BRIDGE_HISTORY_CHAIN_IDS); const { getBalanceForChain } = useChainBalances(); const gdValue = getBalanceForChain(sourceChain); @@ -380,6 +395,7 @@ export const useMPBBridgeViewController = ({ successHandled.current = true; setBridgingStatus(effectiveFlow.statusLabel || "Bridge completed successfully!"); setBridging(false); + refreshHistory?.(); if (!successModalOpen && !successModalDismissedRef.current) { setSuccessModalOpen(true); @@ -435,6 +451,7 @@ export const useMPBBridgeViewController = ({ successModalOpen, onBridgeSuccess, onBridgeFailed, + refreshHistory, setBridging, setBridgingStatus, setSuccessModalOpen, @@ -598,6 +615,11 @@ export const useMPBBridgeViewController = ({ transactionHistoryProps: { realTransactionHistory: recentTransactions, historyLoading, + historyRefreshing, + historyErrorsByChain, + explorerChainId: chainId, + explorerAddress: account, + 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 e110f1867..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,8 +175,21 @@ export const useChainBalances = () => { return { getBalanceForChain }; }; -export const useDebouncedTransactionHistory = (delay = 1000) => { - const { historySorted: realTransactionHistory } = useMPBBridgeHistory() ?? {}; +export const useDebouncedTransactionHistory = ( + delay = 1000, + bridgeReadOnlyUrls?: MPBBridgeReadOnlyUrls, + bridgeHistoryChainIds?: MPBBridgeHistoryChainIds +) => { + const { + historySorted: realTransactionHistory, + initialLoading, + refreshing, + errorsByChain, + refreshHistory + } = useMPBBridgeHistory({ + readOnlyUrls: bridgeReadOnlyUrls, + chainIds: bridgeHistoryChainIds + }) ?? {}; const [debouncedHistory, setDebouncedHistory] = useState(realTransactionHistory); const timeoutRef = useRef(); @@ -190,7 +203,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/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/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/hooks/useMulticallAtChain.tsx b/packages/sdk-v2/src/hooks/useMulticallAtChain.tsx index b3015c405..9cf7a0738 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,8 @@ export const useReadOnlyProvider = (chainId: number) => { return (factory as any)() as JsonRpcProvider; } - const provider = new JsonRpcProvider(factory as any); + // 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; 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..c8797feca 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/index.ts @@ -2,7 +2,9 @@ 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"; export * from "./useMPBG$TokenContract"; export * from "./useProductionG$Balance"; 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 6360d70e3..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); @@ -243,7 +370,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 +392,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.helpers.test.ts b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts new file mode 100644 index 000000000..9dd1cd743 --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.test.ts @@ -0,0 +1,166 @@ +/* eslint-env jest */ + +import { + createAccountEventTopics, + createBlockChunks, + dedupeLogs, + getAddressTopic, + getErrorsByChain, + getHistoryStartBlock, + 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("limits cold and stale-cursor syncs to the latest 5,000 blocks", () => { + expect(getHistoryStartBlock(10_000)).toBe(5001); + expect(getHistoryStartBlock(10_000, 100)).toBe(5001); + expect(getHistoryStartBlock(10_000, 9800)).toBe(9801); + expect(getHistoryStartBlock(100)).toBe(0); + }); + + 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 cached history rows and keeps chain sync state", () => { + const nowMs = new Date("2026-06-29T00:00:00.000Z").getTime(); + const recentTimestamp = Math.floor(nowMs / 1000) - 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: "1", + 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 + } + } + } + ); + + expect(nextCache.BridgeRequest).toHaveLength(2); + expect(nextCache.BridgeRequest?.[1].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 000000000..c4e8add0b --- /dev/null +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts @@ -0,0 +1,138 @@ +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>; +}; + +// Public RPCs were failing on large getLogs windows, so every sync is split into small ranges. +export const HISTORY_BLOCK_CHUNK_SIZE = 500; +export const HISTORY_LOOKBACK_BLOCKS = 5000; + +export const getHistoryStartBlock = (latestBlock: number, lastSyncedBlock?: number) => + Math.max(0, latestBlock - HISTORY_LOOKBACK_BLOCKS + 1, (lastSyncedBlock ?? -1) + 1); + +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 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 mergeBridgeHistoryCache = ( + current: MPBBridgeHistoryCache, + nextEvents: Partial>, + nextChains: Partial> +): MPBBridgeHistoryCache => { + const mergedRequests = new Map(); + const mergedTransfers = new Map(); + + (current.BridgeRequest || []) + .concat(nextEvents.BridgeRequest || []) + .forEach(event => mergedRequests.set(getEventCacheKey(event), event)); + + (current.ExecutedTransfer || []) + .concat(nextEvents.ExecutedTransfer || []) + .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 47d826c39..8e2d8f0cb 100644 --- a/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts +++ b/packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts @@ -1,40 +1,76 @@ -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 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"; - -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; +import { useGetEnvChainId } from "../../base/react"; +import { useReadOnlyProvider } from "../../../hooks/useMulticallAtChain"; +import { + BridgeEventName, + CachedBridgeEvent, + ChainSyncState, + MPBBridgeHistoryCache, + HISTORY_BLOCK_CHUNK_SIZE, + createAccountEventTopics, + createBlockChunks, + dedupeLogs, + getErrorsByChain, + getHistoryStartBlock, + mergeBridgeHistoryCache +} from "./useMPBBridgeHistory.helpers"; + +const HISTORY_CACHE_VERSION = 7; +const CHAIN_IDS = [SupportedChains.FUSE, SupportedChains.CELO, SupportedChains.MAINNET, SupportedChains.XDC]; +const HISTORY_REQUEST_DELAY_MS = 500; + +export type MPBBridgeHistoryReadOnlyUrls = Partial>; + +export type UseMPBBridgeHistoryOptions = { + readOnlyUrls?: MPBBridgeHistoryReadOnlyUrls; + chainIds?: SupportedChains[]; }; -type MPBBridgeHistoryCache = Partial>; +type ChainHistorySyncRange = { + fromBlock: number; + toBlock: number; +}; -const HISTORY_CACHE_VERSION = 1; +type ChainHistoryEventSyncResult = { + chainId: SupportedChains; + eventName: BridgeEventName; + events: CachedBridgeEvent[]; + error?: string; +}; -// 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; -const CHAIN_IDS = [SupportedChains.FUSE, SupportedChains.CELO, SupportedChains.MAINNET, SupportedChains.XDC]; +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. const targetChainId = ethers.BigNumber.from(event.targetChainId); const amount = ethers.BigNumber.from(event.amount); const timestamp = ethers.BigNumber.from(event.timestamp); @@ -66,148 +102,304 @@ 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]; - - // 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 - }; - }) - ); - -export const useMPBBridgeHistory = () => { - const { account } = useEthers(); - const refresh = useRefreshOrNever(5); - const refreshFaster = useRefreshOrNever(2); - const [cacheLoaded, setCacheLoaded] = useState(false); - const [historyCache, setHistoryCache] = useState({}); - - 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 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"; + } - const contracts = useMemo( - () => ({ - [SupportedChains.FUSE]: fuseBridgeContract, - [SupportedChains.CELO]: celoBridgeContract, - [SupportedChains.MAINNET]: mainnetBridgeContract, - [SupportedChains.XDC]: xdcBridgeContract - }), - [celoBridgeContract, fuseBridgeContract, mainnetBridgeContract, xdcBridgeContract] - ); + if (normalizedMessage.includes("forbidden") || message.includes("403")) { + return "RPC request was rejected while refreshing history"; + } - // 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 + if (normalizedMessage.includes("processing response error")) { + return "RPC response error while refreshing history"; } - ); - const celoBridgeRequests = useLogs( - celoBridgeContract ? { contract: celoBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.CELO as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh + if (message.includes("bad response")) { + return `RPC response error while refreshing history${ + status || code + ? ` (${[status ? `status=${status}` : "", code ? `code=${code}` : ""].filter(Boolean).join(", ")})` + : "" + }`; } - ); - const mainnetBridgeRequests = useLogs( - mainnetBridgeContract ? { contract: mainnetBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.MAINNET as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh + if (message.includes("could not detect network")) { + return `RPC network could not be detected${code ? ` (code=${code})` : ""}`; } - ); - const xdcBridgeRequests = useLogs( - xdcBridgeContract ? { contract: xdcBridgeContract, event: "BridgeRequest", args: [] } : undefined, - { - chainId: SupportedChains.XDC as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh + if (message.includes("missing response")) { + return `RPC did not return a response${code ? ` (code=${code})` : ""}`; } - ); - const fuseBridgeCompleted = useLogs( - fuseBridgeContract ? { contract: fuseBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.FUSE as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster + return message.length > 240 ? `${message.slice(0, 237)}...` : message; + }; + + if (error instanceof Error && error.message) { + return simplifyMessage(error.message); + } + + if (typeof error === "string") { + return simplifyMessage(error); + } + + return "Failed to load bridge history from RPC"; +}; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +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 = + 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]; + + 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 celoBridgeCompleted = useLogs( - celoBridgeContract ? { contract: celoBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.CELO as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster +const fetchEventLogs = async ( + contract: ethers.Contract, + eventName: BridgeEventName, + fromBlock: number, + toBlock: number, + account?: string, + onChunkLogs?: (logs: ethers.providers.Log[]) => void +) => { + if (fromBlock > toBlock) { + return { + logs: [] as ethers.providers.Log[], + errors: [] as unknown[] + }; + } + + const provider = contract.provider as ethers.providers.Provider; + 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. + 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]; + try { + const chunkLogsByTopic = await Promise.all( + topicsForPass.map(topics => + provider.getLogs({ + address: contract.address, + topics: topics as ethers.providers.Filter["topics"], + fromBlock: chunk.fromBlock, + toBlock: chunk.toBlock + }) + ) + ); + + const chunkLogs = dedupeLogs(chunkLogsByTopic.flat()); + logsByChunk.push(...chunkLogs); + + if (chunkLogs.length) { + onChunkLogs?.(chunkLogs); + } + } catch (error) { + errors.push(error); + break; + } + + if (chunkIndex < chunks.length - 1) { + await delay(HISTORY_REQUEST_DELAY_MS); + } } - ); - const mainnetBridgeCompleted = useLogs( - mainnetBridgeContract ? { contract: mainnetBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.MAINNET as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster + if (errors.length || passIndex >= topicPasses.length - 1) { + break; } - ); + } + + return { + logs: dedupeLogs(logsByChunk), + 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 getChainHistorySyncPlan = async ( + chainId: SupportedChains, + contract: ethers.Contract, + currentCache: MPBBridgeHistoryCache +) => { + const provider = contract.provider as ethers.providers.Provider; + const latestBlock = await provider.getBlockNumber(); + const chainState = currentCache.chains?.[chainId]; + const fromBlock = getHistoryStartBlock(latestBlock, chainState?.lastSyncedBlock); + + if (fromBlock > latestBlock) { + return { + chainId, + latestBlock, + range: undefined, + chainState: { + lastSyncedBlock: latestBlock, + lastSuccessfulSyncAt: Date.now() + } satisfies ChainSyncState + }; + } + + return { + chainId, + latestBlock, + chainState, + range: { fromBlock, toBlock: latestBlock } satisfies ChainHistorySyncRange + }; +}; - const xdcBridgeCompleted = useLogs( - xdcBridgeContract ? { contract: xdcBridgeContract, event: "ExecutedTransfer", args: [] } : undefined, - { - chainId: SupportedChains.XDC as unknown as ChainId, - fromBlock: -HISTORY_BLOCK_WINDOW, - refresh: refreshFaster +const syncChainHistoryRange = async ( + chainId: SupportedChains, + contract: ethers.Contract, + eventName: BridgeEventName, + range: ChainHistorySyncRange, + 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), + error: errors.length ? getPartialHistoryErrorMessage(errors) : undefined + }; +}; + +export const useMPBBridgeHistory = ({ readOnlyUrls, chainIds }: UseMPBBridgeHistoryOptions = {}) => { + const { account } = useEthers(); + const [cacheLoaded, setCacheLoaded] = useState(false); + const [historyCache, setHistoryCache] = useState({}); + const [refreshTick, setRefreshTick] = useState(0); + const [syncing, setSyncing] = useState(false); + const historyCacheRef = useRef({}); + const storageWriteRef = useRef>(Promise.resolve()); + + 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( + () => ({ + [SupportedChains.FUSE]: fuseBridgeContract, + [SupportedChains.CELO]: celoBridgeContract, + [SupportedChains.MAINNET]: mainnetBridgeContract, + [SupportedChains.XDC]: xdcBridgeContract + }), + [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; - // 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 +408,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 +428,200 @@ 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 + if (!cacheLoaded || !cacheKey) { + return; + } + + const chainContracts = activeChainIds.flatMap(chainId => + contracts[chainId] ? [{ chainId, contract: contracts[chainId] as ethers.Contract }] : [] + ); + + if (!chainContracts.length) { + return; + } + + let cancelled = false; + + // Keep cached rows on screen and expose a separate refreshing state while each chain sync runs. + setSyncing(true); + + const publishHistoryCache = ( + nextEvents: Partial>, + nextChains: Partial> + ) => { + const nextCache = mergeBridgeHistoryCache(historyCacheRef.current, nextEvents, nextChains); + + setHistoryCache(nextCache); + historyCacheRef.current = nextCache; + storageWriteRef.current = storageWriteRef.current + .then(() => AsyncStorage.setItem(cacheKey, nextCache)) + .catch(error => console.warn("Failed to store MPB bridge history cache", error)); }; - }, [ - fuseBridgeRequests, - celoBridgeRequests, - mainnetBridgeRequests, - xdcBridgeRequests, - fuseBridgeCompleted, - celoBridgeCompleted, - mainnetBridgeCompleted, - xdcBridgeCompleted - ]); - 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 syncChain = async ({ chainId, contract }: (typeof chainContracts)[number]) => { + try { + const plan = await getChainHistorySyncPlan(chainId, contract, historyCacheRef.current); - (current.ExecutedTransfer || []).concat(freshCache.ExecutedTransfer).forEach(event => { - const key = event.id ? `${event.sourceChainId}:${event.id}` : `${event.sourceChainId}:${event.transactionHash}`; - completedTransfers.set(key, event); - }); + if (cancelled) { + 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 - ) - }; + if (!plan.range) { + publishHistoryCache({}, { [chainId]: plan.chainState }); + return; + } - // 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)); + let chainError: string | undefined; + + for (const eventName of ["BridgeRequest", "ExecutedTransfer"] as BridgeEventName[]) { + const result = await syncChainHistoryRange( + chainId, + contract, + eventName, + plan.range, + account, + (chunkEventName, events) => { + if (!cancelled) { + publishHistoryCache({ [chunkEventName]: events }, {}); + } + } + ); + + if (cancelled) { + return; + } + + chainError = chainError || result.error; + publishHistoryCache({ [result.eventName]: result.events }, {}); + } - return next; + publishHistoryCache( + {}, + { + [chainId]: chainError + ? { + ...(historyCacheRef.current.chains?.[chainId] || {}), + error: { message: chainError, updatedAt: Date.now() } + } + : { + lastSyncedBlock: plan.latestBlock, + lastSuccessfulSyncAt: Date.now() + } + } + ); + } catch (reason) { + if (cancelled) { + return; + } + + publishHistoryCache( + {}, + { + [chainId]: { + ...(historyCacheRef.current.chains?.[chainId] || {}), + error: { + message: getErrorMessage(reason), + updatedAt: Date.now() + } + } + } + ); + } + }; + + const syncHistory = async () => { + // Each chain has its own RPC, cursor, and error state, so bounded recent scans can run together. + await Promise.all( + chainContracts.map(async chainContract => { + try { + await syncChain(chainContract); + } catch (reason) { + console.warn("Unexpected MPB bridge history sync failure", reason); + } + }) + ); + }; + + void syncHistory().finally(() => { + if (!cancelled) { + setSyncing(false); + } }); - }, [cacheKey, cacheLoaded, freshCache]); + + return () => { + cancelled = true; + }; + }, [account, activeChainIds, cacheKey, cacheLoaded, contracts, refreshTick]); + + const refreshHistory = useCallback(() => { + const clearedCache: MPBBridgeHistoryCache = { + ...historyCacheRef.current, + chains: Object.entries(historyCacheRef.current.chains || {}).reduce((result, [chainId, state]) => { + result[Number(chainId)] = { ...state, error: undefined }; + return result; + }, {} as Partial>) + }; + + historyCacheRef.current = clearedCache; + setHistoryCache(clearedCache); + + if (cacheKey) { + storageWriteRef.current = storageWriteRef.current + .then(() => AsyncStorage.setItem(cacheKey, clearedCache)) + .catch(error => console.warn("Failed to clear MPB bridge history errors", error)); + } + + setRefreshTick(current => current + 1); + }, [cacheKey]); return useMemo(() => { + const allErrorsByChain = getErrorsByChain(historyCache); + const activeErrorsByChain = activeChainIds.reduce((result, chainId) => { + if (allErrorsByChain[chainId]) { + result[chainId] = allErrorsByChain[chainId]; + } + + return result; + }, {} as Record); + if (!cacheLoaded) { - return { historySorted: undefined }; + return { + history: undefined, + historySorted: undefined, + initialLoading: true, + refreshing: false, + errorsByChain: activeErrorsByChain, + 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 +637,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 +645,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: false, + refreshing: syncing, + errorsByChain: activeErrorsByChain, + refreshHistory + }; + }, [account, activeChainIds, cacheLoaded, historyCache, refreshHistory, syncing]); };