Refactor MP bridge history to chunked per-chain log sync - #269
Conversation
|
@copilot can you uncommit the skills folder |
|
@copilot please comment your suggested solution extensively |
* fix: stabilize MPB bridge history reads * fix: finalize MPB bridge history recovery * fix: handle transient MPB bridge block read errors * fix: polish MPB bridge transaction details * fix: address MPB bridge review feedback
There was a problem hiding this comment.
Hey - I've found 4 issues, and left some high level feedback:
- The history cache merge path (
mergeBridgeHistoryCacheand its callers) never prunes old events, so the cache can grow unbounded over time despite the 30‑day/5,000‑block behavior described; consider dropping rows older than the desired horizon when merging to keep storage and in‑memory state bounded. - In
TransactionHistorythe per‑chain error UI ignores thehistoryErrorsByChainmessages and shows a generic warning instead; if you already compute normalized error strings in the hook, surfacing those here (or at least the first one per chain) would materially improve troubleshooting.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The history cache merge path (`mergeBridgeHistoryCache` and its callers) never prunes old events, so the cache can grow unbounded over time despite the 30‑day/5,000‑block behavior described; consider dropping rows older than the desired horizon when merging to keep storage and in‑memory state bounded.
- In `TransactionHistory` the per‑chain error UI ignores the `historyErrorsByChain` messages and shows a generic warning instead; if you already compute normalized error strings in the hook, surfacing those here (or at least the first one per chain) would materially improve troubleshooting.
## Individual Comments
### Comment 1
<location path="packages/good-design/src/apps/bridge/mpbridge/TransactionHistory.tsx" line_range="28-29" />
<code_context>
+ onRefresh,
onTxDetailsPress
}) => {
+ const errorEntries = Object.entries(historyErrorsByChain || {});
+ const hasTransactionHistory = realTransactionHistory.length > 0;
+
return (
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against `realTransactionHistory` being undefined before accessing `.length`.
`useDebouncedTransactionHistory` can return `undefined` for `realTransactionHistory` on the first render, so `realTransactionHistory.length` will throw in that case.
Consider normalising to an array before use, e.g.:
```ts
const history = realTransactionHistory ?? [];
const hasTransactionHistory = history.length > 0;
```
and then use `history` in the JSX. Alternatively, update `useDebouncedTransactionHistory` so `realTransactionHistory` is always an array (default `[]`).
</issue_to_address>
### Comment 2
<location path="packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts" line_range="169-170" />
<code_context>
+ 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"
</code_context>
<issue_to_address>
**issue (bug_risk):** Target chain ID for `ExecutedTransfer` events may be set incorrectly to the source chain.
In `normalizeProviderLogs`, `targetChainId` is always set to `sourceChainId` for `ExecutedTransfer` events:
```ts
const targetChainId =
eventName === "BridgeRequest" ? parsedLog.args?.targetChainId || parsedLog.args?.[2] : sourceChainId;
```
For completion events, the contract usually emits the real target chain (same as the request’s `targetChainId`), not the emitting chain. Overwriting it with `sourceChainId` loses that information and can mislead any logic/UI that uses `targetChainId` for completed transfers.
Since `sourceChainId` already tracks the emitting chain, you can always read the protocol-level destination from the args and fall back only if missing, e.g.:
```ts
const targetChainId = parsedLog.args?.targetChainId || parsedLog.args?.[2] || sourceChainId;
```
(or only fall back when the event truly has no target-chain field).
</issue_to_address>
### Comment 3
<location path="packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridgeHistory.ts" line_range="214" />
<code_context>
- chainId: SupportedChains.CELO as unknown as ChainId,
- fromBlock: -HISTORY_BLOCK_WINDOW,
- refresh: refreshFaster
+const fetchEventLogs = async (
+ contract: ethers.Contract,
+ eventName: BridgeEventName,
</code_context>
<issue_to_address>
**issue (complexity):** Consider collapsing the log fetching, normalization, and account filtering into a single helper so `syncChainHistoryRange` and the hook only deal with events, not low-level RPC details.
You can trim a good chunk of complexity by tightening the log‑fetching API and centralizing account filtering, without changing behaviour.
### 1. Collapse `fetchEventLogs` + normalization + filtering into one helper
Right now:
- `fetchEventLogs` knows about topic passes, chunking and an `onChunkLogs` callback.
- `syncChainHistoryRange` then:
- Calls `fetchEventLogs`.
- Normalizes logs.
- Filters by account twice (per chunk and on the final result).
- The main hook has to understand topics, chunking, and filtering.
You can encapsulate this into a single helper that:
- Takes `contract`, `eventName`, `range`, `account`.
- Handles topics + chunking internally.
- Normalizes and filters logs.
- Exposes an optional `onChunkEvents` callback that already receives `CachedBridgeEvent[]`.
This keeps streaming UI updates, but hides the implementation details from `syncChainHistoryRange` and the main hook.
```ts
type FetchAccountEventLogsOptions = {
contract: ethers.Contract;
chainId: SupportedChains;
eventName: BridgeEventName;
range: ChainHistorySyncRange;
account?: string;
onChunkEvents?: (events: CachedBridgeEvent[]) => void;
};
const fetchAccountEventLogs = async ({
contract,
chainId,
eventName,
range,
account,
onChunkEvents,
}: FetchAccountEventLogsOptions) => {
const { fromBlock, toBlock } = range;
if (fromBlock > toBlock) {
return { events: [] as CachedBridgeEvent[], 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 topicPasses =
accountTopics.length > 1 ? [[accountTopics[0]], accountTopics.slice(1)] : [accountTopics];
const allEvents: CachedBridgeEvent[] = [];
const errors: unknown[] = [];
for (let passIndex = 0; passIndex < topicPasses.length; passIndex += 1) {
const topicsForPass = topicPasses[passIndex];
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const { fromBlock: chunkFrom, toBlock: chunkTo } = chunks[chunkIndex];
try {
const chunkLogsByTopic = await Promise.all(
topicsForPass.map(topics =>
provider.getLogs({
address: contract.address,
topics: topics as ethers.providers.Filter["topics"],
fromBlock: chunkFrom,
toBlock: chunkTo,
})
)
);
const chunkLogs = dedupeLogs(chunkLogsByTopic.flat());
if (chunkLogs.length) {
const chunkEvents = filterEventsForAccount(
normalizeProviderLogs(contract, chainId, eventName, chunkLogs),
account
);
if (chunkEvents.length) {
allEvents.push(...chunkEvents);
onChunkEvents?.(chunkEvents);
}
}
} catch (error) {
errors.push(error);
break;
}
if (chunkIndex < chunks.length - 1) {
await delay(HISTORY_REQUEST_DELAY_MS);
}
}
if (errors.length || passIndex >= topicPasses.length - 1) {
break;
}
}
return { events: dedupeLogs(allEvents), errors };
};
```
Then `syncChainHistoryRange` becomes much easier to read:
```ts
const syncChainHistoryRange = async (
chainId: SupportedChains,
contract: ethers.Contract,
eventName: BridgeEventName,
range: ChainHistorySyncRange,
account?: string,
onEvents?: (eventName: BridgeEventName, events: CachedBridgeEvent[]) => void
): Promise<ChainHistoryEventSyncResult> => {
const { events, errors } = await fetchAccountEventLogs({
contract,
chainId,
eventName,
range,
account,
onChunkEvents: chunkEvents => {
if (chunkEvents.length) {
onEvents?.(eventName, chunkEvents);
}
},
});
return {
chainId,
eventName,
events,
error: errors.length ? getPartialHistoryErrorMessage(errors) : undefined,
};
};
```
Benefits:
- `syncChainHistoryRange` doesn’t need to know about topics, chunking, or log parsing.
- Account filtering is done in one place, on the normalized events.
- You can now confidently remove the extra account‑level filter in the final `historyFiltered` computation, since:
- The cache key is already wallet‑scoped.
- `fetchAccountEventLogs` + `filterEventsForAccount` enforce account scoping at write‑time.
You can simplify the final projection to:
```ts
const historyCombined = bridgeRequests.map(processBridgeRequestEvent);
// account-scoping is already enforced when writing to cache
const historySorted = sortBy(
historyCombined,
(tx: any) => tx.data?.timestamp?.toNumber?.() || 0
).reverse();
return {
history: historySorted,
historySorted,
initialLoading: false,
refreshing: syncing,
errorsByChain: activeErrorsByChain,
refreshHistory,
};
```
If you don’t want to change the public return shape yet, you can keep `history` and `historySorted` both pointing to the same sorted array, but the fetching and filtering path becomes easier to reason about and modify.
</issue_to_address>
### Comment 4
<location path="packages/sdk-v2/src/sdk/mpbridge/hooks/useMPBBridge.ts" line_range="91" />
<code_context>
- const bridgeTo = useContractFunction(bridgeContractOrNull, "bridgeTo", {
- transactionName: "MPBBridgeTo"
- });
+ const [bridgeToState, setBridgeToState] = useState<TransactionStatus>(() =>
+ createIdleTransactionStatus(BRIDGE_TO_TRANSACTION_NAME)
+ );
</code_context>
<issue_to_address>
**issue (complexity):** Consider reusing `useContractFunction` for `bridgeTo` and layering a small retry utility on top rather than re‑implementing the entire transaction lifecycle and state machine in this hook.
You can keep the transient‑error robustness without re‑implementing the whole transaction lifecycle by:
1. Reusing `useContractFunction` for `bridgeTo`.
2. Extracting the retry logic into a small utility.
3. Layering the retry behavior on top of `useContractFunction.send` instead of replacing it.
### 1. Extract the retry logic into a utility
```ts
// waitForReceiptWithRetries.ts
import { ethers } from "ethers";
import { isTransientBlockReadError } from "./useMPBBridge.helpers";
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const waitForReceiptWithRetries = async (
tx: ethers.providers.TransactionResponse,
provider: ethers.providers.Provider,
maxAttempts = 6
) => {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await tx.wait();
} catch (error) {
if (!isTransientBlockReadError(error)) throw error;
lastError = error;
await sleep(1000 * (attempt + 1));
try {
const receipt = await provider.getTransactionReceipt(tx.hash);
if (receipt) return receipt;
} catch (receiptError) {
if (!isTransientBlockReadError(receiptError)) throw receiptError;
lastError = receiptError;
}
}
}
console.warn("[waitForReceiptWithRetries] transient errors after submission", lastError);
return undefined;
};
```
Now `useMPBBridge` doesn’t need to expose the polling details.
### 2. Keep `useContractFunction` and wrap `send`
Instead of managing a parallel `TransactionStatus` state machine, keep the hook:
```ts
const bridgeToBase = useContractFunction(bridgeContractOrNull, "bridgeTo", {
transactionName: "MPBBridgeTo",
});
```
Then add a thin wrapper for the extra robustness:
```ts
const sendBridgeTo = useCallback(
async (...args: any[]) => {
if (!library) {
// optional: short-circuit with a user-visible error
return undefined;
}
// delegate lifecycle/status to useContractFunction
const tx = (await bridgeToBase.send(...args)) as
| ethers.providers.TransactionResponse
| undefined;
if (!tx) return undefined;
// add resilient receipt waiting on top
const receipt = await waitForReceiptWithRetries(tx, library);
// optional: patch state with more accurate receipt if needed
if (receipt && bridgeToBase.state.transaction?.hash === tx.hash) {
// minimal local patch instead of re-implementing the whole state
bridgeToBase.state.receipt = receipt;
if (receipt.status === 0 && bridgeToBase.state.status === "Success") {
bridgeToBase.state.status = "Fail";
bridgeToBase.state.errorMessage = "Bridge transaction failed";
}
}
return receipt;
},
[bridgeToBase, library]
);
const bridgeTo = useMemo(
() => ({
...bridgeToBase,
send: sendBridgeTo,
}),
[bridgeToBase, sendBridgeTo]
);
```
Benefits:
- All status transitions, error vs exception semantics, `resetState`, etc. stay centralized in `useContractFunction`.
- `approve` and `bridgeTo` share the same abstraction, reducing mental overhead and the risk of behavior drift.
- The retry logic becomes a reusable utility (`waitForReceiptWithRetries`) instead of being inlined into the main hook.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| const errorEntries = Object.entries(historyErrorsByChain || {}); | ||
| const hasTransactionHistory = realTransactionHistory.length > 0; |
There was a problem hiding this comment.
issue (bug_risk): Guard against realTransactionHistory being undefined before accessing .length.
useDebouncedTransactionHistory can return undefined for realTransactionHistory on the first render, so realTransactionHistory.length will throw in that case.
Consider normalising to an array before use, e.g.:
const history = realTransactionHistory ?? [];
const hasTransactionHistory = history.length > 0;and then use history in the JSX. Alternatively, update useDebouncedTransactionHistory so realTransactionHistory is always an array (default []).
| const targetChainId = | ||
| eventName === "BridgeRequest" ? parsedLog.args?.targetChainId || parsedLog.args?.[2] : sourceChainId; |
There was a problem hiding this comment.
issue (bug_risk): Target chain ID for ExecutedTransfer events may be set incorrectly to the source chain.
In normalizeProviderLogs, targetChainId is always set to sourceChainId for ExecutedTransfer events:
const targetChainId =
eventName === "BridgeRequest" ? parsedLog.args?.targetChainId || parsedLog.args?.[2] : sourceChainId;For completion events, the contract usually emits the real target chain (same as the request’s targetChainId), not the emitting chain. Overwriting it with sourceChainId loses that information and can mislead any logic/UI that uses targetChainId for completed transfers.
Since sourceChainId already tracks the emitting chain, you can always read the protocol-level destination from the args and fall back only if missing, e.g.:
const targetChainId = parsedLog.args?.targetChainId || parsedLog.args?.[2] || sourceChainId;(or only fall back when the event truly has no target-chain field).
| chainId: SupportedChains.CELO as unknown as ChainId, | ||
| fromBlock: -HISTORY_BLOCK_WINDOW, | ||
| refresh: refreshFaster | ||
| const fetchEventLogs = async ( |
There was a problem hiding this comment.
issue (complexity): Consider collapsing the log fetching, normalization, and account filtering into a single helper so syncChainHistoryRange and the hook only deal with events, not low-level RPC details.
You can trim a good chunk of complexity by tightening the log‑fetching API and centralizing account filtering, without changing behaviour.
1. Collapse fetchEventLogs + normalization + filtering into one helper
Right now:
fetchEventLogsknows about topic passes, chunking and anonChunkLogscallback.syncChainHistoryRangethen:- Calls
fetchEventLogs. - Normalizes logs.
- Filters by account twice (per chunk and on the final result).
- Calls
- The main hook has to understand topics, chunking, and filtering.
You can encapsulate this into a single helper that:
- Takes
contract,eventName,range,account. - Handles topics + chunking internally.
- Normalizes and filters logs.
- Exposes an optional
onChunkEventscallback that already receivesCachedBridgeEvent[].
This keeps streaming UI updates, but hides the implementation details from syncChainHistoryRange and the main hook.
type FetchAccountEventLogsOptions = {
contract: ethers.Contract;
chainId: SupportedChains;
eventName: BridgeEventName;
range: ChainHistorySyncRange;
account?: string;
onChunkEvents?: (events: CachedBridgeEvent[]) => void;
};
const fetchAccountEventLogs = async ({
contract,
chainId,
eventName,
range,
account,
onChunkEvents,
}: FetchAccountEventLogsOptions) => {
const { fromBlock, toBlock } = range;
if (fromBlock > toBlock) {
return { events: [] as CachedBridgeEvent[], 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 topicPasses =
accountTopics.length > 1 ? [[accountTopics[0]], accountTopics.slice(1)] : [accountTopics];
const allEvents: CachedBridgeEvent[] = [];
const errors: unknown[] = [];
for (let passIndex = 0; passIndex < topicPasses.length; passIndex += 1) {
const topicsForPass = topicPasses[passIndex];
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const { fromBlock: chunkFrom, toBlock: chunkTo } = chunks[chunkIndex];
try {
const chunkLogsByTopic = await Promise.all(
topicsForPass.map(topics =>
provider.getLogs({
address: contract.address,
topics: topics as ethers.providers.Filter["topics"],
fromBlock: chunkFrom,
toBlock: chunkTo,
})
)
);
const chunkLogs = dedupeLogs(chunkLogsByTopic.flat());
if (chunkLogs.length) {
const chunkEvents = filterEventsForAccount(
normalizeProviderLogs(contract, chainId, eventName, chunkLogs),
account
);
if (chunkEvents.length) {
allEvents.push(...chunkEvents);
onChunkEvents?.(chunkEvents);
}
}
} catch (error) {
errors.push(error);
break;
}
if (chunkIndex < chunks.length - 1) {
await delay(HISTORY_REQUEST_DELAY_MS);
}
}
if (errors.length || passIndex >= topicPasses.length - 1) {
break;
}
}
return { events: dedupeLogs(allEvents), errors };
};Then syncChainHistoryRange becomes much easier to read:
const syncChainHistoryRange = async (
chainId: SupportedChains,
contract: ethers.Contract,
eventName: BridgeEventName,
range: ChainHistorySyncRange,
account?: string,
onEvents?: (eventName: BridgeEventName, events: CachedBridgeEvent[]) => void
): Promise<ChainHistoryEventSyncResult> => {
const { events, errors } = await fetchAccountEventLogs({
contract,
chainId,
eventName,
range,
account,
onChunkEvents: chunkEvents => {
if (chunkEvents.length) {
onEvents?.(eventName, chunkEvents);
}
},
});
return {
chainId,
eventName,
events,
error: errors.length ? getPartialHistoryErrorMessage(errors) : undefined,
};
};Benefits:
syncChainHistoryRangedoesn’t need to know about topics, chunking, or log parsing.- Account filtering is done in one place, on the normalized events.
- You can now confidently remove the extra account‑level filter in the final
historyFilteredcomputation, since:- The cache key is already wallet‑scoped.
fetchAccountEventLogs+filterEventsForAccountenforce account scoping at write‑time.
You can simplify the final projection to:
const historyCombined = bridgeRequests.map(processBridgeRequestEvent);
// account-scoping is already enforced when writing to cache
const historySorted = sortBy(
historyCombined,
(tx: any) => tx.data?.timestamp?.toNumber?.() || 0
).reverse();
return {
history: historySorted,
historySorted,
initialLoading: false,
refreshing: syncing,
errorsByChain: activeErrorsByChain,
refreshHistory,
};If you don’t want to change the public return shape yet, you can keep history and historySorted both pointing to the same sorted array, but the fetching and filtering path becomes easier to reason about and modify.
| const bridgeTo = useContractFunction(bridgeContractOrNull, "bridgeTo", { | ||
| transactionName: "MPBBridgeTo" | ||
| }); | ||
| const [bridgeToState, setBridgeToState] = useState<TransactionStatus>(() => |
There was a problem hiding this comment.
issue (complexity): Consider reusing useContractFunction for bridgeTo and layering a small retry utility on top rather than re‑implementing the entire transaction lifecycle and state machine in this hook.
You can keep the transient‑error robustness without re‑implementing the whole transaction lifecycle by:
- Reusing
useContractFunctionforbridgeTo. - Extracting the retry logic into a small utility.
- Layering the retry behavior on top of
useContractFunction.sendinstead of replacing it.
1. Extract the retry logic into a utility
// waitForReceiptWithRetries.ts
import { ethers } from "ethers";
import { isTransientBlockReadError } from "./useMPBBridge.helpers";
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const waitForReceiptWithRetries = async (
tx: ethers.providers.TransactionResponse,
provider: ethers.providers.Provider,
maxAttempts = 6
) => {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await tx.wait();
} catch (error) {
if (!isTransientBlockReadError(error)) throw error;
lastError = error;
await sleep(1000 * (attempt + 1));
try {
const receipt = await provider.getTransactionReceipt(tx.hash);
if (receipt) return receipt;
} catch (receiptError) {
if (!isTransientBlockReadError(receiptError)) throw receiptError;
lastError = receiptError;
}
}
}
console.warn("[waitForReceiptWithRetries] transient errors after submission", lastError);
return undefined;
};Now useMPBBridge doesn’t need to expose the polling details.
2. Keep useContractFunction and wrap send
Instead of managing a parallel TransactionStatus state machine, keep the hook:
const bridgeToBase = useContractFunction(bridgeContractOrNull, "bridgeTo", {
transactionName: "MPBBridgeTo",
});Then add a thin wrapper for the extra robustness:
const sendBridgeTo = useCallback(
async (...args: any[]) => {
if (!library) {
// optional: short-circuit with a user-visible error
return undefined;
}
// delegate lifecycle/status to useContractFunction
const tx = (await bridgeToBase.send(...args)) as
| ethers.providers.TransactionResponse
| undefined;
if (!tx) return undefined;
// add resilient receipt waiting on top
const receipt = await waitForReceiptWithRetries(tx, library);
// optional: patch state with more accurate receipt if needed
if (receipt && bridgeToBase.state.transaction?.hash === tx.hash) {
// minimal local patch instead of re-implementing the whole state
bridgeToBase.state.receipt = receipt;
if (receipt.status === 0 && bridgeToBase.state.status === "Success") {
bridgeToBase.state.status = "Fail";
bridgeToBase.state.errorMessage = "Bridge transaction failed";
}
}
return receipt;
},
[bridgeToBase, library]
);
const bridgeTo = useMemo(
() => ({
...bridgeToBase,
send: sendBridgeTo,
}),
[bridgeToBase, sendBridgeTo]
);Benefits:
- All status transitions, error vs exception semantics,
resetState, etc. stay centralized inuseContractFunction. approveandbridgeToshare the same abstraction, reducing mental overhead and the risk of behavior drift.- The retry logic becomes a reusable utility (
waitForReceiptWithRetries) instead of being inlined into the main hook.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3113ef249
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const receipt = await waitForReceiptAfterSubmission(submittedTransaction, library); | ||
| const didTransactionFail = receipt?.status === 0; | ||
|
|
||
| setBridgeToState({ | ||
| status: didTransactionFail ? "Fail" : "Success", |
There was a problem hiding this comment.
Do not mark bridgeTo successful without a receipt
When RPC receipt polling keeps hitting the transient unknown/no block path, waitForReceiptAfterSubmission returns undefined; in that scenario receipt?.status === 0 is false and this sets the bridge transaction to Success even though no mined receipt was confirmed. A flaky or lagging provider can therefore show the success flow for a transaction that is still pending, dropped, or later reverted; keep it mining/erroring until a receipt is available.
Useful? React with 👍 / 👎.
MP bridge history was relying on
useLogsover narrow rolling windows, which made refreshes unreliable on public RPCs and collapsed cached, loading, and failure states into a single UI outcome. This change moves history sync to direct per-chaingetLogsfetching with persistent cursors so cached results render immediately while each chain refreshes independently.SDK: replace
useLogshistory pollingprovider.getLogs(...)BridgeRequestandExecutedTransferin<=500block chunkslastSyncedBlockCache model: persist real sync state
lastSyncedBlocklastSuccessfulSyncAtHistory behavior: make refresh explicit
Promise.allSettledinitialLoading,refreshing,errorsByChain, andrefreshHistory()UI: show real history state
refreshHistory()after a successful bridge actionHistory matching: preserve request/completion merge
BridgeRequestwithExecutedTransferafter all fetched rows are merged into cacheSummary by Sourcery
Refactor MP bridge history fetching to use per-chain chunked getLogs with persistent cursors and expose richer sync state to the UI while improving bridge transaction submission robustness.
New Features:
Enhancements:
Tests: