Skip to content

Refactor MP bridge history to chunked per-chain log sync - #269

Merged
L03TJ3 merged 7 commits into
masterfrom
copilot/fix-mpb-bridge-transaction-history
Jul 15, 2026
Merged

Refactor MP bridge history to chunked per-chain log sync#269
L03TJ3 merged 7 commits into
masterfrom
copilot/fix-mpb-bridge-transaction-history

Conversation

Copilot AI commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

MP bridge history was relying on useLogs over 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-chain getLogs fetching with persistent cursors so cached results render immediately while each chain refreshes independently.

  • SDK: replace useLogs history polling

    • swap the history flow to direct provider.getLogs(...)
    • fetch BridgeRequest and ExecutedTransfer in <=500 block chunks
    • backfill 30 days on empty cache, then resume from per-chain lastSyncedBlock
  • Cache model: persist real sync state

    • keep one wallet+deployment cache entry with:
      • merged normalized history rows
      • per-chain lastSyncedBlock
      • per-chain lastSuccessfulSyncAt
      • per-chain error state
    • prune cached rows outside the rolling 30-day window
    • merge successful chain results without blocking on failed chains
  • History behavior: make refresh explicit

    • hydrate cached rows first for fast first paint
    • run background sync per chain with Promise.allSettled
    • expose initialLoading, refreshing, errorsByChain, and refreshHistory()
    • refresh only fetches deltas from stored cursors
  • UI: show real history state

    • keep cached transactions visible during background sync
    • add a manual Refresh action
    • show inline refreshing state separately from initial loading
    • surface per-chain refresh failures instead of silently treating them as empty history
    • trigger refreshHistory() after a successful bridge action
  • History matching: preserve request/completion merge

    • continue normalizing and matching BridgeRequest with ExecutedTransfer after all fetched rows are merged into cache
const { historySorted, initialLoading, refreshing, errorsByChain, refreshHistory } = useMPBBridgeHistory();

// empty cache:
//   backfill 30 days per chain in 500-block chunks
// warm cache:
//   show cached rows immediately, then fetch cursor+1 -> latest

Summary 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:

  • Add configurable read-only RPC URL support and chain filters for MP bridge history syncing.
  • Expose detailed bridge history sync state including initial loading, refreshing, per-chain errors, and a manual refresh trigger to consumers.
  • Allow bridge controllers to accept custom read-only URLs for history and surface wallet explorer links in the UI.

Enhancements:

  • Replace useLogs-based MP bridge history polling with direct per-chain getLogs in small block chunks persisted via a normalized cache model.
  • Refine bridge transaction submission to manage its own TransactionStatus lifecycle, handle transient block read errors, and poll for receipts more robustly.
  • Improve the transaction history UI to keep cached rows visible during background sync, distinguish initial loading from refresh, and clarify the visible history window.
  • Switch read-only providers to StaticJsonRpcProvider to avoid extra network-detection RPC calls and add helper utilities for bridge history log processing and cache merging.

Tests:

  • Add unit tests for MP bridge history helper utilities including chunking, log deduplication, cache merging, and error extraction.
  • Add tests for transaction helper behavior including timestamp handling and bridge transaction error classification.

Copilot AI changed the title [WIP] Fix MPB Bridge transaction history loading reliability Refactor MP bridge history to chunked per-chain log sync Jun 29, 2026
Copilot AI requested a review from L03TJ3 June 29, 2026 11:22
@L03TJ3

L03TJ3 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@copilot can you uncommit the skills folder

Copilot AI commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@copilot can you uncommit the skills folder

Removed in 3a372f7, and added ignore rules so the Copilot skills artifacts do not get recommitted.

Copilot finished work on behalf of L03TJ3 July 1, 2026 06:13
@L03TJ3

L03TJ3 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@copilot please comment your suggested solution extensively

Copilot AI commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please comment your suggested solution extensively

Added extensive inline comments in ff95f8c covering the cache window, chunked log fetching, per-chain sync/error handling, cache persistence, and request/completion matching flow.

edehvictor and others added 2 commits July 14, 2026 18:50
* 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
@L03TJ3
L03TJ3 marked this pull request as ready for review July 15, 2026 16:17
@L03TJ3
L03TJ3 requested a review from a team July 15, 2026 16:17
@L03TJ3
L03TJ3 merged commit 76ee41e into master Jul 15, 2026
4 checks passed
@L03TJ3
L03TJ3 deleted the copilot/fix-mpb-bridge-transaction-history branch July 15, 2026 16:18
@github-project-automation github-project-automation Bot moved this from In Review to Deploy and Verify in GoodBounties Jul 15, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +28 to +29
const errorEntries = Object.entries(historyErrorsByChain || {});
const hasTransactionHistory = realTransactionHistory.length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 []).

Comment on lines +169 to +170
const targetChainId =
eventName === "BridgeRequest" ? parsedLog.args?.targetChainId || parsedLog.args?.[2] : sourceChainId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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:

  • 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:

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>(() =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

// 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 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +135 to +139
const receipt = await waitForReceiptAfterSubmission(submittedTransaction, library);
const didTransactionFail = receipt?.status === 0;

setBridgeToState({
status: didTransactionFail ? "Fail" : "Success",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Deploy and Verify

Development

Successfully merging this pull request may close these issues.

3 participants