Skip to content

Commit 75ac83b

Browse files
authored
feat: backfill quote status manager entries after app close (#9405)
## 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: ```mermaid 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 } ``` 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 - [x] I've updated the test suite for new or updated code as appropriate (272 lines of new coverage in `bridge-status-controller.test.ts`). - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate (JSDoc on `#seedQuoteStatusEntriesFromHistory`, plus a `CHANGELOG.md` entry). - [x] 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.)_ <!-- CURSOR_SUMMARY --> --- > [!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. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit def1682. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9b2a8b1 commit 75ac83b

4 files changed

Lines changed: 361 additions & 1 deletion

File tree

packages/bridge-status-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Source transaction status from the `/getQuoteStatus` backend during polling for history items with an associated `quoteId`, falling back to the `/getTxStatus` bridge-api endpoint when no quote-status backend status is available yet or the QuoteStatusManager is disabled. ([#9389](https://github.com/MetaMask/core/pull/9389))
13+
- Seed missing quote-status entries from persisted transaction history on startup, so quotes submitted before the client closed still get their status reported to the backend. Entries are rebuilt from the history item's `quoteId`, source transaction hash (falling back to the transaction's hash), and `txMetaId`; history items older than the quote-status entry TTL are skipped. ([#9405](https://github.com/MetaMask/core/pull/9405))
1314

1415
### Changed
1516

packages/bridge-status-controller/src/bridge-status-controller.test.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS,
5151
MAX_ATTEMPTS,
5252
} from './constants';
53+
import { QUOTE_STATUS_BACKFILL_WINDOW_MS } from './quote-status-manager/constants';
5354
import { BridgeClientId } from './types';
5455
import type {
5556
BridgeId,
@@ -6806,6 +6807,285 @@ describe('BridgeStatusController', () => {
68066807
});
68076808
});
68086809

6810+
describe('seeding quote status entries from history on startup', () => {
6811+
let fetchSpy: jest.SpyInstance;
6812+
6813+
beforeEach(() => {
6814+
jest.useFakeTimers();
6815+
// Keep the quote-status update request in-flight so the manager never
6816+
// resolves and mutates state after the assertions/teardown.
6817+
fetchSpy = jest
6818+
.spyOn(globalThis, 'fetch')
6819+
.mockReturnValue(new Promise(() => undefined));
6820+
});
6821+
6822+
afterEach(() => {
6823+
fetchSpy.mockRestore();
6824+
jest.clearAllTimers();
6825+
jest.useRealTimers();
6826+
});
6827+
6828+
const getSeedMessengerCall = (transactions: TransactionMeta[] = []) =>
6829+
jest.fn((...args: unknown[]) => {
6830+
const actionType = args[0] as string;
6831+
if (actionType === 'TransactionController:getState') {
6832+
return { transactions };
6833+
}
6834+
if (actionType === 'AuthenticationController:getBearerToken') {
6835+
return Promise.resolve('auth-token');
6836+
}
6837+
return undefined;
6838+
});
6839+
6840+
const getSeedHistoryItem = ({
6841+
txMetaId,
6842+
quoteId,
6843+
srcTxHash,
6844+
startTime,
6845+
reportedSubmittedTxHash,
6846+
}: {
6847+
txMetaId: string;
6848+
quoteId?: string;
6849+
srcTxHash?: string;
6850+
startTime: number;
6851+
reportedSubmittedTxHash?: string;
6852+
}): BridgeHistoryItem => ({
6853+
...MockTxHistory.getPending({
6854+
txMetaId,
6855+
srcTxHash: srcTxHash ?? 'undefined',
6856+
startTime,
6857+
})[txMetaId],
6858+
quoteId,
6859+
...(reportedSubmittedTxHash ? { reportedSubmittedTxHash } : {}),
6860+
});
6861+
6862+
it('seeds a missing quote status entry from a recent history item', async () => {
6863+
await withController(
6864+
{
6865+
options: {
6866+
isQuoteStatusManagerEnabled: () => true,
6867+
state: {
6868+
txHistory: {
6869+
seedTx1: getSeedHistoryItem({
6870+
txMetaId: 'seedTx1',
6871+
quoteId: 'seed-quote-1',
6872+
srcTxHash: '0xseedHash1',
6873+
startTime: Date.now(),
6874+
}),
6875+
},
6876+
},
6877+
},
6878+
mockMessengerCall: getSeedMessengerCall(),
6879+
},
6880+
async ({ controller }) => {
6881+
expect(
6882+
controller.state.txHistory.seedTx1.reportedSubmittedTxHash,
6883+
).toBe('0xseedHash1');
6884+
expect(
6885+
Object.keys(controller.state.quoteUpdateStatusStore),
6886+
).toStrictEqual(['seed-quote-1:0xseedHash1']);
6887+
6888+
controller.resetState();
6889+
},
6890+
);
6891+
});
6892+
6893+
it('falls back to the transaction hash when the history item has no source hash', async () => {
6894+
const txMeta = {
6895+
id: 'seedTx2',
6896+
hash: '0xfallbackHash2',
6897+
status: TransactionStatus.submitted,
6898+
type: TransactionType.bridge,
6899+
chainId: numberToHex(42161),
6900+
txParams: {} as unknown as TransactionParams,
6901+
} as unknown as TransactionMeta;
6902+
6903+
await withController(
6904+
{
6905+
options: {
6906+
isQuoteStatusManagerEnabled: () => true,
6907+
state: {
6908+
txHistory: {
6909+
seedTx2: getSeedHistoryItem({
6910+
txMetaId: 'seedTx2',
6911+
quoteId: 'seed-quote-2',
6912+
srcTxHash: 'undefined',
6913+
startTime: Date.now(),
6914+
}),
6915+
},
6916+
},
6917+
},
6918+
mockMessengerCall: getSeedMessengerCall([txMeta]),
6919+
},
6920+
async ({ controller }) => {
6921+
expect(
6922+
Object.keys(controller.state.quoteUpdateStatusStore),
6923+
).toStrictEqual(['seed-quote-2:0xfallbackHash2']);
6924+
6925+
controller.resetState();
6926+
},
6927+
);
6928+
});
6929+
6930+
it('skips history items older than the backfill window', async () => {
6931+
await withController(
6932+
{
6933+
options: {
6934+
isQuoteStatusManagerEnabled: () => true,
6935+
state: {
6936+
txHistory: {
6937+
seedTx3: getSeedHistoryItem({
6938+
txMetaId: 'seedTx3',
6939+
quoteId: 'seed-quote-3',
6940+
srcTxHash: '0xseedHash3',
6941+
startTime:
6942+
Date.now() - QUOTE_STATUS_BACKFILL_WINDOW_MS - 1000,
6943+
}),
6944+
},
6945+
},
6946+
},
6947+
mockMessengerCall: getSeedMessengerCall(),
6948+
},
6949+
async ({ controller }) => {
6950+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
6951+
expect(
6952+
controller.state.txHistory.seedTx3.reportedSubmittedTxHash,
6953+
).toBeUndefined();
6954+
},
6955+
);
6956+
});
6957+
6958+
it('skips history items without a quoteId', async () => {
6959+
await withController(
6960+
{
6961+
options: {
6962+
isQuoteStatusManagerEnabled: () => true,
6963+
state: {
6964+
txHistory: {
6965+
seedTx4: getSeedHistoryItem({
6966+
txMetaId: 'seedTx4',
6967+
srcTxHash: '0xseedHash4',
6968+
startTime: Date.now(),
6969+
}),
6970+
},
6971+
},
6972+
},
6973+
mockMessengerCall: getSeedMessengerCall(),
6974+
},
6975+
async ({ controller }) => {
6976+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
6977+
},
6978+
);
6979+
});
6980+
6981+
it('skips history items without a txMetaId', async () => {
6982+
await withController(
6983+
{
6984+
options: {
6985+
isQuoteStatusManagerEnabled: () => true,
6986+
state: {
6987+
txHistory: {
6988+
seedTx5: {
6989+
...getSeedHistoryItem({
6990+
txMetaId: 'seedTx5',
6991+
quoteId: 'seed-quote-5',
6992+
srcTxHash: '0xseedHash5',
6993+
startTime: Date.now(),
6994+
}),
6995+
txMetaId: undefined,
6996+
},
6997+
},
6998+
},
6999+
},
7000+
mockMessengerCall: getSeedMessengerCall(),
7001+
},
7002+
async ({ controller }) => {
7003+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
7004+
},
7005+
);
7006+
});
7007+
7008+
it('skips when neither the history item nor the transaction has a source hash', async () => {
7009+
await withController(
7010+
{
7011+
options: {
7012+
isQuoteStatusManagerEnabled: () => true,
7013+
state: {
7014+
txHistory: {
7015+
seedTx6: getSeedHistoryItem({
7016+
txMetaId: 'seedTx6',
7017+
quoteId: 'seed-quote-6',
7018+
srcTxHash: 'undefined',
7019+
startTime: Date.now(),
7020+
}),
7021+
},
7022+
},
7023+
},
7024+
mockMessengerCall: getSeedMessengerCall([]),
7025+
},
7026+
async ({ controller }) => {
7027+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
7028+
},
7029+
);
7030+
});
7031+
7032+
it('does not re-seed an entry that was already reported', async () => {
7033+
await withController(
7034+
{
7035+
options: {
7036+
isQuoteStatusManagerEnabled: () => true,
7037+
state: {
7038+
txHistory: {
7039+
seedTx7: getSeedHistoryItem({
7040+
txMetaId: 'seedTx7',
7041+
quoteId: 'seed-quote-7',
7042+
srcTxHash: '0xseedHash7',
7043+
startTime: Date.now(),
7044+
reportedSubmittedTxHash: '0xseedHash7',
7045+
}),
7046+
},
7047+
},
7048+
},
7049+
mockMessengerCall: getSeedMessengerCall(),
7050+
},
7051+
async ({ controller }) => {
7052+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
7053+
},
7054+
);
7055+
});
7056+
7057+
it('does not seed when the quote status manager is disabled', async () => {
7058+
await withController(
7059+
{
7060+
options: {
7061+
isQuoteStatusManagerEnabled: () => false,
7062+
state: {
7063+
txHistory: {
7064+
seedTx8: getSeedHistoryItem({
7065+
txMetaId: 'seedTx8',
7066+
quoteId: 'seed-quote-8',
7067+
srcTxHash: '0xseedHash8',
7068+
startTime: Date.now(),
7069+
}),
7070+
},
7071+
},
7072+
},
7073+
mockMessengerCall: getSeedMessengerCall(),
7074+
},
7075+
async ({ controller }) => {
7076+
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
7077+
// The guard must not persist `reportedSubmittedTxHash`: doing so
7078+
// would mark the history item as already reported even though no
7079+
// store entry or backend update was ever created, so it could never
7080+
// be seeded once the manager is re-enabled.
7081+
expect(
7082+
controller.state.txHistory.seedTx8.reportedSubmittedTxHash,
7083+
).toBeUndefined();
7084+
},
7085+
);
7086+
});
7087+
});
7088+
68097089
describe('metadata', () => {
68107090
it('includes expected state in debug snapshots', async () => {
68117091
await withController(async ({ controller }) => {

packages/bridge-status-controller/src/bridge-status-controller.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
REFRESH_INTERVAL_MS,
4242
} from './constants';
4343
import {
44+
QUOTE_STATUS_BACKFILL_WINDOW_MS,
4445
QUOTE_STATUS_UPDATE_ENTRY_TTL,
4546
QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS,
4647
} from './quote-status-manager/constants';
@@ -316,6 +317,12 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
316317
},
317318
);
318319

320+
// Seed any missing quote-status entries from persisted history before
321+
// init(), so init()'s reconciliation loop can finalize quotes whose
322+
// deferred entry was never created (e.g. the client closed before
323+
// reportSubmitted ran).
324+
this.#seedQuoteStatusEntriesFromHistory();
325+
319326
// Replay swap/bridge finalizations that resolved while the client was
320327
// closed, before resuming polling (which recovers in-flight bridges).
321328
this.#quoteStatusManager.init();
@@ -482,6 +489,9 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
482489
if (!historyItem?.quoteId) {
483490
return;
484491
}
492+
// `reportedSubmittedTxHash` is set once `reportSubmitted` is called.
493+
// This avoids processing multiple `eportSubmitted` for the
494+
// same swap/bridge.
485495
if (historyItem.reportedSubmittedTxHash === srcTxHash) {
486496
return;
487497
}
@@ -614,6 +624,59 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
614624
return this.state.txHistory[txMetaId];
615625
};
616626

627+
/**
628+
* Seeds missing quote-status entries from persisted history on startup.
629+
*
630+
* The deferred quote-status entry (keyed by `${quoteId}:${srcTxHash}`) is only
631+
* created when `reportSubmitted` runs. If the client closes after a swap/bridge
632+
* is submitted but before that happens, the entry is never created and
633+
* `QuoteStatusManager.init()` has nothing to reconcile. The persisted history
634+
* item carries the `quoteId`, source tx hash, and `txMetaId` needed to rebuild
635+
* the entry, so we replay `reportSubmitted` here (idempotent via
636+
* `#reportSubmittedOnce`) before `init()` runs.
637+
*/
638+
readonly #seedQuoteStatusEntriesFromHistory = (): void => {
639+
if (!this.#quoteStatusManager.enabled) {
640+
// Skip seeding to prevent `#reportSubmittedOnce` from persisting
641+
// `reportedSubmittedTxHash` so recent bridge history cannot be marked as
642+
// already reported with no store entry or backend update.
643+
return;
644+
}
645+
646+
for (const [historyKey, historyItem] of Object.entries(
647+
this.state.txHistory,
648+
)) {
649+
const { quoteId, txMetaId } = historyItem;
650+
if (!quoteId || !txMetaId) {
651+
continue;
652+
}
653+
654+
// Skip items older than the backfill window: the backend would reject
655+
// a status report for a quote that old, so there is no point creating
656+
// an entry or making a request.
657+
if (
658+
Date.now() - historyItem.startTime >
659+
QUOTE_STATUS_BACKFILL_WINDOW_MS
660+
) {
661+
continue;
662+
}
663+
664+
// Prefer the history hash; fall back to the tx hash (covers STX swaps
665+
// confirmed while closed, where the history hash was never written).
666+
const srcTxHash =
667+
historyItem.status.srcChain.txHash ??
668+
getTransactionMetaById(this.messenger, txMetaId)?.hash;
669+
if (!srcTxHash) {
670+
continue;
671+
}
672+
673+
// Call `reportSubmittedOnce` for each history item that satisfies
674+
// the above criteria. The method will then take care the rest
675+
// (ie. if it actually needs to be reported or not because it already has been).
676+
this.#reportSubmittedOnce(historyKey, srcTxHash, txMetaId);
677+
}
678+
};
679+
617680
/**
618681
* Restart polling for txs that are not in a final state
619682
* This is called during initialization

0 commit comments

Comments
 (0)