Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ yalc.lock
/dist/
.vercel
.continueignore
.agents/
e2e.log
docs/specs/**
codex-resume
codex-resume
skills-lock.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,67 @@
import React from "react";
import { Box, Spinner, Text, VStack } from "native-base";
import { Box, Button, HStack, Spinner, Text, VStack } from "native-base";
import { BridgeTransactionList } from "./MPBBridgeTransactionCard";
import { capitalizeChain, getChainName } from "./utils";

interface TransactionHistoryProps {
realTransactionHistory: any[];
historyLoading: boolean;
historyRefreshing: boolean;
historyErrorsByChain: Record<number, string>;
onRefresh: () => void;
onTxDetailsPress: (tx: any) => void;
}

export const TransactionHistory: React.FC<TransactionHistoryProps> = ({
realTransactionHistory,
historyLoading,
historyRefreshing,
historyErrorsByChain,
onRefresh,
onTxDetailsPress
}) => {
const errorEntries = Object.entries(historyErrorsByChain || {});

return (
<VStack space={4} width="100%">
<Text fontFamily="heading" fontSize="xl" fontWeight="700" color="goodBlue.600">
Recent Transactions
</Text>
<HStack justifyContent="space-between" alignItems="center" space={3}>
<Text fontFamily="heading" fontSize="xl" fontWeight="700" color="goodBlue.600">
Recent Transactions
</Text>
<Button
variant="outline"
size="sm"
borderColor="goodBlue.500"
_text={{ color: "goodBlue.500", fontWeight: "600" }}
isDisabled={historyLoading || historyRefreshing}
isLoading={historyRefreshing}
onPress={onRefresh}
>
Refresh
</Button>
</HStack>
{historyRefreshing && !historyLoading ? (
<HStack alignItems="center" space={2}>
<Spinner size="sm" color="goodBlue.500" />
<Text fontSize="xs" color="goodGrey.600">
Refreshing transaction history...
</Text>
</HStack>
) : null}
{errorEntries.length > 0 ? (
<Box p={4} bg="yellow.50" borderRadius="lg" borderWidth="1" borderColor="yellow.200">
<VStack space={2}>
<Text fontSize="sm" color="yellow.800" fontWeight="600">
Some networks could not refresh right now.
</Text>
{errorEntries.map(([chainId, message]) => (
<Text key={chainId} fontSize="xs" color="yellow.700">
{capitalizeChain(getChainName(Number(chainId)))}: {message}
</Text>
))}
</VStack>
</Box>
) : null}
{historyLoading ? (
<Box p={6} bg="goodGrey.50" borderRadius="lg" alignItems="center">
<Spinner size="sm" color="goodBlue.500" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export interface MPBBridgeViewModel {
transactionHistoryProps: {
realTransactionHistory: any[];
historyLoading: boolean;
historyRefreshing: boolean;
historyErrorsByChain: Record<number, string>;
onRefresh: () => void;
onTxDetailsPress: (tx: any) => void;
};
}
Expand Down Expand Up @@ -162,7 +165,8 @@ export const useMPBBridgeViewController = ({
closeAllDropdowns
} = useMPBBridgeUiState();

const { realTransactionHistory, historyLoading } = useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS);
const { realTransactionHistory, historyLoading, historyRefreshing, historyErrorsByChain, refreshHistory } =
useDebouncedTransactionHistory(TRANSACTION_HISTORY_DEBOUNCE_MS);
const { getBalanceForChain } = useChainBalances();

const gdValue = getBalanceForChain(sourceChain);
Expand Down Expand Up @@ -380,6 +384,7 @@ export const useMPBBridgeViewController = ({
successHandled.current = true;
setBridgingStatus(effectiveFlow.statusLabel || "Bridge completed successfully!");
setBridging(false);
refreshHistory?.();

if (!successModalOpen && !successModalDismissedRef.current) {
setSuccessModalOpen(true);
Expand Down Expand Up @@ -435,6 +440,7 @@ export const useMPBBridgeViewController = ({
successModalOpen,
onBridgeSuccess,
onBridgeFailed,
refreshHistory,
setBridging,
setBridgingStatus,
setSuccessModalOpen,
Expand Down Expand Up @@ -598,6 +604,9 @@ export const useMPBBridgeViewController = ({
transactionHistoryProps: {
realTransactionHistory: recentTransactions,
historyLoading,
historyRefreshing,
historyErrorsByChain,
onRefresh: refreshHistory,
onTxDetailsPress
}
};
Expand Down
13 changes: 11 additions & 2 deletions packages/good-design/src/apps/bridge/mpbridge/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ export const useChainBalances = () => {
};

export const useDebouncedTransactionHistory = (delay = 1000) => {
const { historySorted: realTransactionHistory } = useMPBBridgeHistory() ?? {};
const {
historySorted: realTransactionHistory,
initialLoading,
refreshing,
errorsByChain,
refreshHistory
} = useMPBBridgeHistory() ?? {};
const [debouncedHistory, setDebouncedHistory] = useState(realTransactionHistory);
const timeoutRef = useRef<NodeJS.Timeout>();

Expand All @@ -190,7 +196,10 @@ export const useDebouncedTransactionHistory = (delay = 1000) => {

return {
realTransactionHistory: debouncedHistory,
historyLoading: !realTransactionHistory
historyLoading: Boolean(initialLoading),
historyRefreshing: Boolean(refreshing),
historyErrorsByChain: errorsByChain || {},
refreshHistory: refreshHistory || (() => undefined)
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* eslint-env jest */

import {
createBlockChunks,
getErrorsByChain,
mergeBridgeHistoryCache,
MPBBridgeHistoryCache
} from "./useMPBBridgeHistory.helpers";

describe("useMPBBridgeHistory helpers", () => {
it("splits log ranges into 500-block chunks", () => {
expect(createBlockChunks(100, 1201)).toEqual([
{ fromBlock: 100, toBlock: 599 },
{ fromBlock: 600, toBlock: 1099 },
{ fromBlock: 1100, toBlock: 1201 }
]);
});

it("merges history rows, prunes old cache entries, and keeps chain sync state", () => {
const nowMs = new Date("2026-06-29T00:00:00.000Z").getTime();
const recentTimestamp = Math.floor(nowMs / 1000) - 60;
const oldTimestamp = Math.floor(nowMs / 1000) - 31 * 24 * 60 * 60;
const currentCache: MPBBridgeHistoryCache = {
BridgeRequest: [
{
transactionHash: "0xold",
blockHash: "0xblock-old",
blockNumber: 1,
transactionIndex: 0,
removed: false,
sourceChainId: 122,
from: "0xfrom",
to: "0xto",
targetChainId: "42220",
amount: "10",
timestamp: oldTimestamp.toString(),
id: "1"
},
{
transactionHash: "0xkeep",
blockHash: "0xblock-keep",
blockNumber: 10,
transactionIndex: 0,
removed: false,
sourceChainId: 122,
from: "0xfrom",
to: "0xto",
targetChainId: "42220",
amount: "20",
timestamp: recentTimestamp.toString(),
id: "2"
}
],
ExecutedTransfer: [],
chains: {
122: {
lastSyncedBlock: 20,
error: {
message: "old error",
updatedAt: 1
}
}
}
};

const nextCache = mergeBridgeHistoryCache(
currentCache,
{
BridgeRequest: [
{
transactionHash: "0xkeep-updated",
blockHash: "0xblock-keep-updated",
blockNumber: 12,
transactionIndex: 1,
removed: false,
sourceChainId: 122,
from: "0xfrom",
to: "0xto",
targetChainId: "42220",
amount: "30",
timestamp: recentTimestamp.toString(),
id: "2"
}
],
ExecutedTransfer: [
{
transactionHash: "0xcompleted",
blockHash: "0xblock-completed",
blockNumber: 30,
transactionIndex: 0,
removed: false,
sourceChainId: 42220,
from: "0xfrom",
to: "0xto",
targetChainId: "122",
amount: "30",
timestamp: recentTimestamp.toString(),
id: "2"
}
]
},
{
122: {
lastSyncedBlock: 40,
lastSuccessfulSyncAt: nowMs
},
42220: {
error: {
message: "rpc failed",
updatedAt: nowMs
}
}
},
nowMs
);

expect(nextCache.BridgeRequest).toHaveLength(1);
expect(nextCache.BridgeRequest?.[0].transactionHash).toBe("0xkeep-updated");
expect(nextCache.ExecutedTransfer).toHaveLength(1);
expect(nextCache.chains?.[122]).toEqual({
lastSyncedBlock: 40,
lastSuccessfulSyncAt: nowMs
});
expect(getErrorsByChain(nextCache)).toEqual({
42220: "rpc failed"
});
});
});
110 changes: 110 additions & 0 deletions packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export type BridgeEventName = "BridgeRequest" | "ExecutedTransfer";

export type CachedBridgeEvent = {
transactionHash: string;
blockHash: string;
blockNumber: number;
transactionIndex: number;
removed: boolean;
sourceChainId: number;
from?: string;
to?: string;
targetChainId: string;
amount: string;
timestamp: string;
bridge?: string;
id?: string;
};

export type ChainSyncErrorState = {
message: string;
updatedAt: number;
};

export type ChainSyncState = {
lastSyncedBlock?: number;
lastSuccessfulSyncAt?: number;
error?: ChainSyncErrorState;
};

export type MPBBridgeHistoryCache = {
BridgeRequest?: CachedBridgeEvent[];
ExecutedTransfer?: CachedBridgeEvent[];
chains?: Partial<Record<number, ChainSyncState>>;
};

// The cache only keeps a rolling 30-day history so first paint stays useful without growing forever.
export const HISTORY_WINDOW_DAYS = 30;
export const HISTORY_WINDOW_SECONDS = HISTORY_WINDOW_DAYS * 24 * 60 * 60;
// Public RPCs were failing on large getLogs windows, so every sync is split into small ranges.
export const HISTORY_BLOCK_CHUNK_SIZE = 500;

const getEventCacheKey = (event: CachedBridgeEvent) =>
event.id ? `${event.sourceChainId}:${event.id}` : `${event.sourceChainId}:${event.transactionHash}`;

const sortBridgeEvents = (events: CachedBridgeEvent[]) =>
events.sort((a, b) =>
a.blockNumber === b.blockNumber ? a.transactionIndex - b.transactionIndex : a.blockNumber - b.blockNumber
);

export const createBlockChunks = (fromBlock: number, toBlock: number, chunkSize = HISTORY_BLOCK_CHUNK_SIZE) => {
if (fromBlock > toBlock) {
return [];
}

const chunks: Array<{ fromBlock: number; toBlock: number }> = [];

// Build inclusive ranges so callers can safely fetch [fromBlock, toBlock] without gaps or overlaps.
for (let cursor = fromBlock; cursor <= toBlock; cursor += chunkSize) {
chunks.push({
fromBlock: cursor,
toBlock: Math.min(cursor + chunkSize - 1, toBlock)
});
}

return chunks;
};

export const pruneExpiredEvents = (events: CachedBridgeEvent[], minTimestamp: number) =>
events.filter(event => Number(event.timestamp || 0) >= minTimestamp);

export const getHistoryWindowStartTimestamp = (nowMs = Date.now()) => Math.floor(nowMs / 1000) - HISTORY_WINDOW_SECONDS;

export const mergeBridgeHistoryCache = (
current: MPBBridgeHistoryCache,
nextEvents: Partial<Record<BridgeEventName, CachedBridgeEvent[]>>,
nextChains: Partial<Record<number, ChainSyncState>>,
nowMs = Date.now()
): MPBBridgeHistoryCache => {
const minTimestamp = getHistoryWindowStartTimestamp(nowMs);
const mergedRequests = new Map<string, CachedBridgeEvent>();
const mergedTransfers = new Map<string, CachedBridgeEvent>();

// Merge new rows into the cached window and let the event key dedupe repeated fetches across refreshes.
pruneExpiredEvents((current.BridgeRequest || []).concat(nextEvents.BridgeRequest || []), minTimestamp).forEach(
event => mergedRequests.set(getEventCacheKey(event), event)
);

pruneExpiredEvents((current.ExecutedTransfer || []).concat(nextEvents.ExecutedTransfer || []), minTimestamp).forEach(
event => mergedTransfers.set(getEventCacheKey(event), event)
);

return {
BridgeRequest: sortBridgeEvents(Array.from(mergedRequests.values())),
ExecutedTransfer: sortBridgeEvents(Array.from(mergedTransfers.values())),
chains: {
// Per-chain sync state is merged independently so one failing RPC does not wipe successful cursors.
...(current.chains || {}),
...nextChains
}
};
};

export const getErrorsByChain = (cache: MPBBridgeHistoryCache) =>
Object.entries(cache.chains || {}).reduce((result, [chainId, state]) => {
if (state?.error?.message) {
result[Number(chainId)] = state.error.message;
}

return result;
}, {} as Record<number, string>);
Loading