Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/bridge-status-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

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

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS,
MAX_ATTEMPTS,
} from './constants';
import { QUOTE_STATUS_BACKFILL_WINDOW_MS } from './quote-status-manager/constants';
import { BridgeClientId } from './types';
import type {
BridgeId,
Expand Down Expand Up @@ -6806,6 +6807,285 @@ describe('BridgeStatusController', () => {
});
});

describe('seeding quote status entries from history on startup', () => {
let fetchSpy: jest.SpyInstance;

beforeEach(() => {
jest.useFakeTimers();
// Keep the quote-status update request in-flight so the manager never
// resolves and mutates state after the assertions/teardown.
fetchSpy = jest
.spyOn(globalThis, 'fetch')
.mockReturnValue(new Promise(() => undefined));
});

afterEach(() => {
fetchSpy.mockRestore();
jest.clearAllTimers();
jest.useRealTimers();
});

const getSeedMessengerCall = (transactions: TransactionMeta[] = []) =>
jest.fn((...args: unknown[]) => {
const actionType = args[0] as string;
if (actionType === 'TransactionController:getState') {
return { transactions };
}
if (actionType === 'AuthenticationController:getBearerToken') {
return Promise.resolve('auth-token');
}
return undefined;
});

const getSeedHistoryItem = ({
txMetaId,
quoteId,
srcTxHash,
startTime,
reportedSubmittedTxHash,
}: {
txMetaId: string;
quoteId?: string;
srcTxHash?: string;
startTime: number;
reportedSubmittedTxHash?: string;
}): BridgeHistoryItem => ({
...MockTxHistory.getPending({
txMetaId,
srcTxHash: srcTxHash ?? 'undefined',
startTime,
})[txMetaId],
quoteId,
...(reportedSubmittedTxHash ? { reportedSubmittedTxHash } : {}),
});

it('seeds a missing quote status entry from a recent history item', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx1: getSeedHistoryItem({
txMetaId: 'seedTx1',
quoteId: 'seed-quote-1',
srcTxHash: '0xseedHash1',
startTime: Date.now(),
}),
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(
controller.state.txHistory.seedTx1.reportedSubmittedTxHash,
).toBe('0xseedHash1');
expect(
Object.keys(controller.state.quoteUpdateStatusStore),
).toStrictEqual(['seed-quote-1:0xseedHash1']);

controller.resetState();
},
);
});

it('falls back to the transaction hash when the history item has no source hash', async () => {
const txMeta = {
id: 'seedTx2',
hash: '0xfallbackHash2',
status: TransactionStatus.submitted,
type: TransactionType.bridge,
chainId: numberToHex(42161),
txParams: {} as unknown as TransactionParams,
} as unknown as TransactionMeta;

await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx2: getSeedHistoryItem({
txMetaId: 'seedTx2',
quoteId: 'seed-quote-2',
srcTxHash: 'undefined',
startTime: Date.now(),
}),
},
},
},
mockMessengerCall: getSeedMessengerCall([txMeta]),
},
async ({ controller }) => {
expect(
Object.keys(controller.state.quoteUpdateStatusStore),
).toStrictEqual(['seed-quote-2:0xfallbackHash2']);

controller.resetState();
},
);
});

it('skips history items older than the backfill window', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx3: getSeedHistoryItem({
txMetaId: 'seedTx3',
quoteId: 'seed-quote-3',
srcTxHash: '0xseedHash3',
startTime:
Date.now() - QUOTE_STATUS_BACKFILL_WINDOW_MS - 1000,
}),
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
expect(
controller.state.txHistory.seedTx3.reportedSubmittedTxHash,
).toBeUndefined();
},
);
});

it('skips history items without a quoteId', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx4: getSeedHistoryItem({
txMetaId: 'seedTx4',
srcTxHash: '0xseedHash4',
startTime: Date.now(),
}),
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
},
);
});

it('skips history items without a txMetaId', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx5: {
...getSeedHistoryItem({
txMetaId: 'seedTx5',
quoteId: 'seed-quote-5',
srcTxHash: '0xseedHash5',
startTime: Date.now(),
}),
txMetaId: undefined,
},
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
},
);
});

it('skips when neither the history item nor the transaction has a source hash', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx6: getSeedHistoryItem({
txMetaId: 'seedTx6',
quoteId: 'seed-quote-6',
srcTxHash: 'undefined',
startTime: Date.now(),
}),
},
},
},
mockMessengerCall: getSeedMessengerCall([]),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
},
);
});

it('does not re-seed an entry that was already reported', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => true,
state: {
txHistory: {
seedTx7: getSeedHistoryItem({
txMetaId: 'seedTx7',
quoteId: 'seed-quote-7',
srcTxHash: '0xseedHash7',
startTime: Date.now(),
reportedSubmittedTxHash: '0xseedHash7',
}),
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
},
);
});

it('does not seed when the quote status manager is disabled', async () => {
await withController(
{
options: {
isQuoteStatusManagerEnabled: () => false,
state: {
txHistory: {
seedTx8: getSeedHistoryItem({
txMetaId: 'seedTx8',
quoteId: 'seed-quote-8',
srcTxHash: '0xseedHash8',
startTime: Date.now(),
}),
},
},
},
mockMessengerCall: getSeedMessengerCall(),
},
async ({ controller }) => {
expect(controller.state.quoteUpdateStatusStore).toStrictEqual({});
// The guard must not persist `reportedSubmittedTxHash`: doing so
// would mark the history item as already reported even though no
// store entry or backend update was ever created, so it could never
// be seeded once the manager is re-enabled.
expect(
controller.state.txHistory.seedTx8.reportedSubmittedTxHash,
).toBeUndefined();
},
);
});
});

describe('metadata', () => {
it('includes expected state in debug snapshots', async () => {
await withController(async ({ controller }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
REFRESH_INTERVAL_MS,
} from './constants';
import {
QUOTE_STATUS_BACKFILL_WINDOW_MS,
QUOTE_STATUS_UPDATE_ENTRY_TTL,
QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS,
} from './quote-status-manager/constants';
Expand Down Expand Up @@ -316,6 +317,12 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
},
);

// Seed any missing quote-status entries from persisted history before
// init(), so init()'s reconciliation loop can finalize quotes whose
// deferred entry was never created (e.g. the client closed before
// reportSubmitted ran).
this.#seedQuoteStatusEntriesFromHistory();

// Replay swap/bridge finalizations that resolved while the client was
// closed, before resuming polling (which recovers in-flight bridges).
this.#quoteStatusManager.init();
Expand Down Expand Up @@ -482,6 +489,9 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
if (!historyItem?.quoteId) {
return;
}
// `reportedSubmittedTxHash` is set once `reportSubmitted` is called.
// This avoids processing multiple `eportSubmitted` for the
// same swap/bridge.
if (historyItem.reportedSubmittedTxHash === srcTxHash) {
return;
}
Expand Down Expand Up @@ -614,6 +624,59 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
return this.state.txHistory[txMetaId];
};

/**
* Seeds missing quote-status entries from persisted history on startup.
*
* The deferred quote-status entry (keyed by `${quoteId}:${srcTxHash}`) is only
* created when `reportSubmitted` runs. If the client closes after a swap/bridge
* is submitted but before that happens, the entry is never created and
* `QuoteStatusManager.init()` has nothing to reconcile. The persisted history
* item carries the `quoteId`, source tx hash, and `txMetaId` needed to rebuild
* the entry, so we replay `reportSubmitted` here (idempotent via
* `#reportSubmittedOnce`) before `init()` runs.
*/
readonly #seedQuoteStatusEntriesFromHistory = (): void => {
if (!this.#quoteStatusManager.enabled) {
// Skip seeding to prevent `#reportSubmittedOnce` from persisting
// `reportedSubmittedTxHash` so recent bridge history cannot be marked as
// already reported with no store entry or backend update.
return;
}

for (const [historyKey, historyItem] of Object.entries(
this.state.txHistory,
)) {
const { quoteId, txMetaId } = historyItem;
if (!quoteId || !txMetaId) {
continue;
}

// 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;
}
Comment on lines +654 to +662

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.


// Prefer the history hash; fall back to the tx hash (covers STX swaps
// confirmed while closed, where the history hash was never written).
const srcTxHash =
historyItem.status.srcChain.txHash ??
getTransactionMetaById(this.messenger, txMetaId)?.hash;
if (!srcTxHash) {
continue;
}

// Call `reportSubmittedOnce` for each history item that satisfies
// the above criteria. The method will then take care the rest
// (ie. if it actually needs to be reported or not because it already has been).
this.#reportSubmittedOnce(historyKey, srcTxHash, txMetaId);
}
Comment thread
cursor[bot] marked this conversation as resolved.
};

/**
* Restart polling for txs that are not in a final state
* This is called during initialization
Expand Down
Loading