From 2049890fb0dd83f5a3048cec8b9dbb6d032fe30e Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Fri, 10 Jul 2026 12:02:49 +0300 Subject: [PATCH 1/5] feat: support batch sell on quote status manager --- .../src/bridge-status-controller.ts | 43 +++++++++++++++---- .../quote-status-entry-store.ts | 24 +++++++++++ .../quotes-status-manager.ts | 42 ++++++++++++------ .../src/strategy/batch-sell-strategy.ts | 15 ++++++- 4 files changed, 103 insertions(+), 21 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 0295f6929f7..0acb55887ca 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -476,6 +476,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { const historyItem = this.state.txHistory[historyKey]; - if (!historyItem?.quoteId) { + if (!historyItem) { return; } + + // For a 7702/nested batch the parent item lists every quote in `quoteIds` + // (keys into `txHistory`); resolve each to its real quote id. Otherwise fall + // back to the item's own single quote id. + let quoteIds: string[]; + if (historyItem.quoteIds?.length) { + quoteIds = historyItem.quoteIds + .map((quoteKey) => this.state.txHistory[quoteKey]?.quoteId) + .filter((quoteId): quoteId is string => Boolean(quoteId)); + } else if (historyItem.quoteId) { + quoteIds = [historyItem.quoteId]; + } else { + quoteIds = []; + } + + if (quoteIds.length === 0) { + return; + } + // `reportedSubmittedTxHash` is set once `reportSubmitted` is called. // This avoids processing multiple `eportSubmitted` for the // same swap/bridge. if (historyItem.reportedSubmittedTxHash === srcTxHash) { return; } - this.#quoteStatusManager.reportSubmitted( - historyItem.quoteId, - srcTxHash, - txMetaId, - ); + + for (const quoteId of quoteIds) { + this.#quoteStatusManager.reportSubmitted(quoteId, srcTxHash, txMetaId); + } + this.update((state) => { const item = state.txHistory[historyKey]; if (item) { @@ -646,8 +670,11 @@ export class BridgeStatusController extends StaticIntervalPollingController txMeta.id)).size === 1; + // Nested/7702 batch - if (is7702Tx(firstTradeMeta) || hasNestedSwapTransactions(firstTradeMeta)) { + if ( + isSingleBatchTx && + (is7702Tx(firstTradeMeta) || hasNestedSwapTransactions(firstTradeMeta)) + ) { const quoteIds = Array.from( new Set(allTradesInBatch.map(getHistoryKeyForQuote)), ); From e9bb48731d69a76d176fed46f2363bfef211d700 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Tue, 14 Jul 2026 13:19:01 +0300 Subject: [PATCH 2/5] test: add --- .../bridge-status-controller/CHANGELOG.md | 4 + .../bridge-status-controller.test.ts.snap | 28 -- .../src/bridge-status-controller.test.ts | 315 ++++++++++++++++-- .../quote-status-entry-store.test.ts | 62 ++++ .../quotes-status-manager.test.ts | 155 +++++++++ 5 files changed, 514 insertions(+), 50 deletions(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 002b008176f..80d8695a24c 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support batch sell (EIP-7702/nested batch) transactions in the quote-status flow, so every quote submitted under a single batch transaction is reported to the backend. All quotes sharing one batch source transaction are reported as `SUBMITTED` under the shared source transaction hash and `txMetaId`, and are finalized together when that transaction confirms or fails. ([#0000](https://github.com/MetaMask/core/pull/0000)) + ## [74.1.2] ### Changed diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index a36828f538f..6f609400345 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -5137,7 +5137,6 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm "steps": [ { "action": "bridge", - "destAmount": "0.5", "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", @@ -5147,12 +5146,6 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm "symbol": "ETH", }, "destChainId": 1, - "protocol": { - "displayName": "Test Protocol", - "icon": "test-icon", - "name": "test-protocol", - }, - "srcAmount": "1000000000", "srcAsset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", @@ -5543,7 +5536,6 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit "steps": [ { "action": "bridge", - "destAmount": "0.5", "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", @@ -5553,12 +5545,6 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit "symbol": "ETH", }, "destChainId": 1, - "protocol": { - "displayName": "Test Protocol", - "icon": "test-icon", - "name": "test-protocol", - }, - "srcAmount": "1000000000", "srcAsset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", @@ -5952,7 +5938,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "steps": [ { "action": "swap", - "destAmount": "500000000", "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", @@ -5962,12 +5947,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "symbol": "TRX", }, "destChainId": 728126428, - "protocol": { - "displayName": "Test Protocol", - "icon": "test-icon", - "name": "test-protocol", - }, - "srcAmount": "1000000", "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", @@ -6173,7 +6152,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "steps": [ { "action": "swap", - "destAmount": "500000000", "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", @@ -6183,12 +6161,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "symbol": "TRX", }, "destChainId": 728126428, - "protocol": { - "displayName": "Test Protocol", - "icon": "test-icon", - "name": "test-protocol", - }, - "srcAmount": "1000000", "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 790a2a0aaea..65065a953c9 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -50,7 +50,10 @@ import { DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, MAX_ATTEMPTS, } from './constants'; -import { QUOTE_STATUS_BACKFILL_WINDOW_MS } from './quote-status-manager/constants'; +import { + QUOTE_STATUS_BACKFILL_WINDOW_MS, + QuoteStatusState, +} from './quote-status-manager/constants'; import { BridgeClientId } from './types'; import type { BridgeId, @@ -2209,13 +2212,6 @@ describe('BridgeStatusController', () => { decimals: 18, assetId: 'eip155:1/slip44:60', }, - srcAmount: '1000000000', - destAmount: '0.5', - protocol: { - name: 'test-protocol', - displayName: 'Test Protocol', - icon: 'test-icon', - }, }, ], feeData: { @@ -2454,13 +2450,6 @@ describe('BridgeStatusController', () => { decimals: 18, assetId: 'eip155:1/slip44:60', }, - srcAmount: '1000000000', - destAmount: '0.5', - protocol: { - name: 'test-protocol', - displayName: 'Test Protocol', - icon: 'test-icon', - }, }, ], feeData: { @@ -2710,13 +2699,6 @@ describe('BridgeStatusController', () => { decimals: 6, assetId: 'tron:728126428/slip44:195', }, - srcAmount: '1000000', - destAmount: '500000000', - protocol: { - name: 'test-protocol', - displayName: 'Test Protocol', - icon: 'test-icon', - }, }, ], feeData: { @@ -6790,6 +6772,295 @@ describe('BridgeStatusController', () => { ); }); }); + + describe('7702/nested batch sell', () => { + const BATCH_TX_META_ID = 'batchTxMetaId'; + const BATCH_SRC_TX_HASH = '0xbatchSrcTxHash'; + const BATCH_QUOTE_1 = 'batch-quote-1'; + const BATCH_QUOTE_2 = 'batch-quote-2'; + const BATCH_KEY_1 = `${BATCH_QUOTE_1}:${BATCH_SRC_TX_HASH}`; + const BATCH_KEY_2 = `${BATCH_QUOTE_2}:${BATCH_SRC_TX_HASH}`; + + /** + * Builds a 7702/nested batch history: a parent item that lists its child + * quotes via `quoteIds`, plus one child history item per quote. The + * parent's `startTime` is left at the (old) mock default so startup + * seeding is skipped and each test drives reporting via events. + * + * @returns The batch txHistory keyed by history id. + */ + function buildBatchHistory(): Record { + const parent = { + ...MockTxHistory.getPending({ + txMetaId: BATCH_TX_META_ID, + srcTxHash: BATCH_SRC_TX_HASH, + })[BATCH_TX_META_ID], + featureId: FeatureId.BATCH_SELL, + // The parent reports its quotes via `quoteIds`, not its own quoteId. + quoteId: undefined, + quoteIds: ['batchChild1', 'batchChild2'], + }; + const child1 = { + ...MockTxHistory.getPending({ + txMetaId: 'batchChild1', + srcTxHash: BATCH_SRC_TX_HASH, + }).batchChild1, + featureId: FeatureId.BATCH_SELL, + txMetaId: undefined, + quoteId: BATCH_QUOTE_1, + }; + const child2 = { + ...MockTxHistory.getPending({ + txMetaId: 'batchChild2', + srcTxHash: BATCH_SRC_TX_HASH, + }).batchChild2, + featureId: FeatureId.BATCH_SELL, + txMetaId: undefined, + quoteId: BATCH_QUOTE_2, + }; + return { + [BATCH_TX_META_ID]: parent, + batchChild1: child1, + batchChild2: child2, + }; + } + + const getBatchMessengerCall = () => + jest.fn((...args: unknown[]) => { + const action = args[0] as string; + if (action === 'TransactionController:getState') { + return { transactions: [] }; + } + if (action === 'AccountsController:getAccountByAddress') { + return mockSelectedAccount; + } + return undefined; + }); + + it('reports SUBMITTED for every quote in the batch under the shared tx hash', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + // One entry is created per quote in the batch, all keyed by the + // shared source tx hash. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.Submitted); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.Submitted); + expect( + controller.state.txHistory[BATCH_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(BATCH_SRC_TX_HASH); + + controller.resetState(); + }, + ); + }); + + it('does not re-report the batch quotes on a repeat submitted event', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + const submittedEvent = { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }; + + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + submittedEvent, + ); + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + submittedEvent, + ); + + // The already-reported guard keeps the store to exactly one entry + // per quote despite the duplicate event. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + + controller.resetState(); + }, + ); + }); + + it('finalizes every quote in the batch as success when the batch confirms', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller, rootMessenger }) => { + // A confirmed batch reports SUBMITTED for each quote (via the nested + // swap) and then finalizes them all under the shared txMetaId. + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.batch, + status: TransactionStatus.confirmed, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + nestedTransactions: [{ type: TransactionType.swap }], + } as unknown as TransactionMeta, + }, + ); + + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.FinalizedSuccess); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.FinalizedSuccess); + + controller.resetState(); + }, + ); + }); + + it('finalizes every quote in the batch as failure when the batch fails', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller, rootMessenger }) => { + // Report SUBMITTED for the batch quotes first (failure reporting does + // not create entries on its own). + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.failed, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.FinalizedFailed); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.FinalizedFailed); + + controller.resetState(); + }, + ); + }); + + it('seeds a SUBMITTED entry for every batch quote from persisted history on startup', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + ...buildBatchHistory(), + // A recent startTime makes the parent eligible for backfill. + [BATCH_TX_META_ID]: { + ...buildBatchHistory()[BATCH_TX_META_ID], + startTime: Date.now(), + }, + }, + }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller }) => { + // Startup seeding replays reportSubmitted for each quote in the batch + // without waiting for a transaction event. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.txHistory[BATCH_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(BATCH_SRC_TX_HASH); + + controller.resetState(); + }, + ); + }); + }); }); describe('seeding quote status entries from history on startup', () => { diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts index c656bc89d14..a077941a150 100644 --- a/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts @@ -283,6 +283,68 @@ describe('QuoteStatusEntryStore', () => { }); }); + describe('getAllByTxMetaId', () => { + it('returns every entry sharing the txMetaId', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + store.put( + 'quote-2:0xabc', + createPutValue({ + quoteId: 'quote-2', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + ); + store.put( + 'quote-3:0xdef', + createPutValue({ + quoteId: 'quote-3', + srcTxHash: '0xdef', + txMetaId: 'tx-2', + }), + ); + + const matches = store.getAllByTxMetaId('tx-1'); + + expect(matches).toHaveLength(2); + expect(matches.map((entry) => entry.quoteId).sort()).toStrictEqual([ + 'quote-1', + 'quote-2', + ]); + }); + + it('returns an empty array when no entry matches', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + expect(store.getAllByTxMetaId('tx-missing')).toStrictEqual([]); + }); + + it('transitions stale matches to Expired but keeps them', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + store.put( + 'quote-2:0xabc', + createPutValue({ + quoteId: 'quote-2', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + ); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + const matches = store.getAllByTxMetaId('tx-1'); + + expect(matches).toHaveLength(2); + expect( + matches.every( + (entry) => entry.status.state === QuoteStatusState.Expired, + ), + ).toBe(true); + expect(store.size).toBe(2); + }); + }); + describe('update', () => { it('persists when the entry is still tracked', () => { const { store, onPersistUpdates } = createStore(); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts index 67d8ddd6059..2338f2903dd 100644 --- a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts +++ b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts @@ -749,6 +749,161 @@ describe('QuoteStatusUpdateManager', () => { expect(onError).not.toHaveBeenCalled(); expect(mockUpdate).not.toHaveBeenCalled(); }); + + describe('batch (7702/nested) finalization', () => { + /** + * Collects the `{ quoteId, newStatus }` pairs reported to the backend. + * + * @returns The reported quote id / status pairs. + */ + function getReportedQuoteStatuses(): { + quoteId: string; + newStatus: QuoteStatusBackendStatus; + }[] { + return mockUpdate.mock.calls.map(([payload]) => ({ + quoteId: payload.quoteId, + newStatus: payload.newStatus, + })); + } + + it('finalizes every quote sharing the batch txMetaId as success', async () => { + const { manager, onError } = createManager(); + // A single 7702/nested batch submits multiple quotes under one txMetaId. + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + expect(getReportedQuoteStatuses()).toStrictEqual( + expect.arrayContaining([ + { + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }, + { + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }, + ]), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('finalizes every quote sharing the batch txMetaId as failure', async () => { + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', false); + await flush(); + + expect(getReportedQuoteStatuses()).toStrictEqual( + expect.arrayContaining([ + { + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + }, + { + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + }, + ]), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('finalizes only the still-pending quotes and skips terminal siblings', async () => { + const { manager, onError } = createManager({ + initialData: { + 'quote-1:0xabc': createPersistEntry({ + quoteId: 'quote-1', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + 'quote-2:0xdef': createPersistEntry({ + quoteId: 'quote-2', + srcTxHash: '0xdef', + txMetaId: 'tx-1', + status: QuoteStatusState.Completed, + }), + }, + }); + + manager.init(); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + const reported = getReportedQuoteStatuses(); + expect(reported).toContainEqual({ + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }); + // The sibling already in a terminal state cannot transition again, so it + // is skipped rather than re-reported or surfacing an error. + expect(reported).not.toContainEqual({ + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('ignores a duplicate batch finalization once every quote is terminal', async () => { + mockUpdate.mockResolvedValue( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', true); + await flush(); + onError.mockClear(); + mockUpdate.mockClear(); + + // Every entry is now Completed; a repeated batch finalization finds them + // all in a terminal state and no-ops instead of re-reporting or erroring. + manager.reportFinalised('tx-1', true); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('retains every batch entry as Completed once finalization is accepted', async () => { + mockUpdate.mockResolvedValue( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onPersistUpdates } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + + manager.reportFinalised('tx-1', true); + await flush(); + + const lastSnapshot = onPersistUpdates.mock.calls.at(-1)?.[0]; + expect(lastSnapshot).toMatchObject({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + 'quote-2:0xdef': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + }); + }); + }); }); describe('destroy', () => { From e087ed6988b525633496bee5b6105e3ac8352dfd Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Jul 2026 10:17:09 +0300 Subject: [PATCH 3/5] docs: update changelog entry PR reference --- packages/bridge-status-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index c6bf45176a2..c7734ad0493 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Support batch sell (EIP-7702/nested batch) transactions in the quote-status flow, so every quote submitted under a single batch transaction is reported to the backend. All quotes sharing one batch source transaction are reported as `SUBMITTED` under the shared source transaction hash and `txMetaId`, and are finalized together when that transaction confirms or fails. ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Support batch sell (EIP-7702/nested batch) transactions in the quote-status flow, so every quote submitted under a single batch transaction is reported to the backend. All quotes sharing one batch source transaction are reported as `SUBMITTED` under the shared source transaction hash and `txMetaId`, and are finalized together when that transaction confirms or fails. ([#9514](https://github.com/MetaMask/core/pull/9514)) ## [74.2.0] From e99ad0bc118243b772951249f8e6fd0f1c2c8efa Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Jul 2026 10:25:47 +0300 Subject: [PATCH 4/5] test: increase code coverage --- .../src/bridge-status-controller.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 8fbe2a692ba..89dc773d1af 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -6799,6 +6799,38 @@ describe('BridgeStatusController', () => { }, ); }); + + it('does not report SUBMITTED when the rekeyed history item is missing', async () => { + await withController( + { options: { isQuoteStatusManagerEnabled: () => true } }, + async ({ controller, rootMessenger }) => { + registerSubmitTxHandlers(rootMessenger); + + // Simulate a rekey that finds no pre-submission history item: the + // item is never moved to the trade-meta key, so the subsequent + // `#reportSubmittedOnce` runs against a non-existent history item + // and must bail out safely without reporting or throwing. + jest + .spyOn(historyUtils, 'rekeyHistoryItemInState') + .mockReturnValue(false); + + await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmSwapQuoteResponse.trade as TxData).from, + mockEvmSwapQuoteResponse, + false, + ); + controller.stopAllPolling(); + + // Nothing was rekeyed onto the trade-meta id, so no submitted + // status was reported for it. + expect(controller.state.txHistory[EVM_TX_META_ID]).toBeUndefined(); + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + + controller.resetState(); + }, + ); + }); }); describe('7702/nested batch sell', () => { From b4cd9f46a3b25356a8f9d87f2b9c94970eb34159 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Jul 2026 17:57:22 +0300 Subject: [PATCH 5/5] docs: add prop explanation --- packages/bridge-status-controller/src/types.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index c9bdd98f7f1..0f02fce8c76 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -152,10 +152,19 @@ export type BridgeHistoryItem = { * This is defined when the history item corresponds to the 7702 batch's delegation tx. * It contains the list of quoteIds for the BatchSell quotes that are part of the 7702 batch. * Each quote can be retrieved from txHistory as `txHistory[quoteId]`. + * + * On single swaps/bridges this value is an empty array, or absent on history items + * persisted before this field was introduced. */ quoteIds?: string[]; quote: Quote; - quoteId?: string; // Optional: absent on history items persisted before this field was introduced + /** + * This is the the quote id used on single swaps/bridges. On batch sell, it is set + * as the first item of `quoteIds`. + * + * This value is absent on history items persisted before this field was introduced. + */ + quoteId?: string; reportedSubmittedTxHash?: string; status: StatusResponse; startTime: number; // timestamp in ms