Skip to content

feat: backfill quote status manager entries after app close#9405

Merged
GeorgeGkas merged 5 commits into
mainfrom
swaps-4704
Jul 7, 2026
Merged

feat: backfill quote status manager entries after app close#9405
GeorgeGkas merged 5 commits into
mainfrom
swaps-4704

Conversation

@GeorgeGkas

@GeorgeGkas GeorgeGkas commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Explanation

The QuoteStatusManager keeps the bridge-api backend in sync with the outcome of every submitted quote by reporting SUBMITTED and later FINALISED. It does this through a persisted store of deferred "quote-status entries" keyed by the composite ${quoteId}:${srcTxHash}. On startup, QuoteStatusManager.init() walks only the entries that already exist in that store and reconciles each one against the current transaction status.

The problem is that a deferred entry is only ever created when reportSubmitted runs. If the client closes after a swap/bridge source transaction has been broadcast but before reportSubmitted executes (or before its entry was persisted), no entry is written. On the next launch init() has nothing to reconcile for that quote, so its terminal status is never reported to the backend, a permanently lost update. Smart-transaction (STX) swaps that confirm while the client is closed are a notable trigger, because the history item's status hash was never written.

The key realization is that the persisted transaction history already carries everything needed to rebuild a lost entry. On each BridgeHistoryItem we persist the quoteId, the source tx hash (status.srcChain.txHash, with the underlying transaction's hash as a fallback), and the txMetaId. Those are exactly the identity fields of a quote-status entry (quoteId:srcTxHash + txMetaId). quoteId + source-tx-hash therefore behave like a foreign key from a history row into the quote-status store, which lets us treat history as the source of truth and losslessly reconstruct any missing entry:

erDiagram
    TX_HISTORY_ITEM ||--o| QUOTE_STATUS_ENTRY : "quoteId + srcTxHash (composite FK)"

    TX_HISTORY_ITEM {
        string txMetaId PK "key in state.txHistory; also correlates finalisation"
        string quoteId FK "part of composite FK"
        string status_srcChain_txHash FK "srcTxHash; part of composite FK (falls back to txMeta.hash)"
        string reportedSubmittedTxHash "guard: prevents duplicate reportSubmitted"
        number startTime "compared against entry TTL"
    }

    QUOTE_STATUS_ENTRY {
        string entryKey PK "hash() => `quoteId:srcTxHash` virtual column, does not exist"
        string quoteId
        string srcTxHash
        string txMetaId "used by reportFinalised()"
        QuoteStatusState status
        number createdAt
        number lastAttemptAt
    }
Loading

The solution adds #seedQuoteStatusEntriesFromHistory(), invoked once immediately before this.#quoteStatusManager.init() in the constructor. It iterates persisted txHistory and, for each item that has both a quoteId and a txMetaId and is within the entry TTL, derives the srcTxHash and replays submission through the existing idempotent #reportSubmittedOnce helper. That rebuilds the missing deferred entry, and the subsequent init() reconciliation loop finalizes it exactly as it would in the normal (client-stayed-open) flow.

Why this is robust:

  • Idempotent by construction. #reportSubmittedOnce short-circuits when historyItem.reportedSubmittedTxHash === srcTxHash, and QuoteStatusManager.reportSubmitted additionally ignores quotes it is already tracking. Re-seeding on every startup can never double-report or resurrect a completed quote.
  • TTL-bounded. Items older than QUOTE_STATUS_UPDATE_ENTRY_TTL are skipped, because the backend would reject them anyway. This bounds the amount of work at startup and avoids pointless network requests.
  • No new source of truth. Entries are derived from data we already persist on the history item, so the backfill introduces no new state to keep consistent, history remains authoritative and the store is a rebuildable projection of it.
  • STX-safe fallback. When the history status hash was never written (STX swaps confirmed while closed), it falls back to the transaction meta's hash via getTransactionMetaById, so those cases are covered too.
  • Disabled-safe & correctly ordered. The whole path is gated behind the manager's isEnabled check, and seeding runs before init() so the reconciliation loop always sees the freshly seeded entries.

Changes whose purpose may not be obvious to those unfamiliar with the domain: the startTime TTL comparison and the status.srcChain.txHash ?? txMeta.hash fallback both exist purely to mirror backend acceptance rules and the STX edge case, not to change the reporting semantics.

References

https://consensyssoftware.atlassian.net/browse/SWAPS-4704

Checklist

  • I've updated the test suite for new or updated code as appropriate (272 lines of new coverage in bridge-status-controller.test.ts).
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate (JSDoc on #seedQuoteStatusEntriesFromHistory, plus a CHANGELOG.md entry).
  • I've communicated my changes to consumers by updating changelogs for packages I've changed.
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them. (N/A — additive, non-breaking startup behavior.)

Note

Medium Risk
Changes startup reconciliation and backend quote-status reporting, plus shortens how long failed updates are retried; behavior is idempotent and feature-gated but affects swap/bridge observability.

Overview
On startup, missing quote-status store entries are rebuilt from persisted bridge transaction history so quotes submitted before the client closed still get SUBMITTED / final status reported to the backend.

Before QuoteStatusManager.init(), #seedQuoteStatusEntriesFromHistory() walks recent txHistory items and replays the existing idempotent #reportSubmittedOnce path using quoteId, txMetaId, and source hash (status.srcChain.txHash or transaction meta hash). Items outside a new 12h QUOTE_STATUS_BACKFILL_WINDOW_MS, or without required fields, are skipped; seeding is a no-op when the quote-status manager is disabled (so reportedSubmittedTxHash is not set incorrectly).

TTL tuning: per-entry local retry lifetime QUOTE_STATUS_UPDATE_ENTRY_TTL is shortened from 12h to 3h, while the separate backfill window stays at 12h so multi-session in-flight bridges can still be resumed.

Reviewed by Cursor Bugbot for commit def1682. Bugbot is set up for automated code reviews on this repo. Configure here.

@GeorgeGkas GeorgeGkas requested review from a team as code owners July 7, 2026 13:28

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a6c5e4b. Configure here.

Comment thread packages/bridge-status-controller/src/bridge-status-controller.ts
@GeorgeGkas

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-preview

Comment on lines +654 to +662
// Skip items older than the backfill window: the backend would reject
// a status report for a quote that old, so there is no point creating
// an entry or making a request.
if (
Date.now() - historyItem.startTime >
QUOTE_STATUS_BACKFILL_WINDOW_MS
) {
continue;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does the backend throw a fetch error? If not, I'd leave the filtering to the backend for flexibility (i.e. if the backend age limit changes)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

no it does not, the notion of "backfill" is currently unsupported by the backend, this means we can also backfill swaps that were done a year ago if we change this condition (and other conditions are met). Yet this is not what we want either right now. This PR is introduced to make things more robust (client operates on best-effort basis). Once we get the first production data in two-three weeks we can revise the strategy.

@GeorgeGkas GeorgeGkas added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit 75ac83b Jul 7, 2026
417 checks passed
@GeorgeGkas GeorgeGkas deleted the swaps-4704 branch July 7, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants