diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index e301948a137..c7734ad0493 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. ([#9514](https://github.com/MetaMask/core/pull/9514)) + ## [74.2.0] ### Added 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 c877b498d37..7bf3b83936a 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 @@ -5138,7 +5138,6 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm "steps": [ { "action": "bridge", - "destAmount": "0.5", "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", @@ -5148,12 +5147,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", @@ -5544,7 +5537,6 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit "steps": [ { "action": "bridge", - "destAmount": "0.5", "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", @@ -5554,12 +5546,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", @@ -5953,7 +5939,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "steps": [ { "action": "swap", - "destAmount": "500000000", "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", @@ -5963,12 +5948,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", @@ -6174,7 +6153,6 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "steps": [ { "action": "swap", - "destAmount": "500000000", "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", @@ -6184,12 +6162,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 acf32d7a62f..89dc773d1af 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, @@ -2217,13 +2220,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: { @@ -2462,13 +2458,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: { @@ -2730,13 +2719,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: { @@ -6817,6 +6799,327 @@ 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', () => { + 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(); + }, + ); + }); }); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 1bb41b86c7c..27ae844c795 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 { }); }); + 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/quote-status-entry-store.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts index a20c849428e..bc72760892f 100644 --- a/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts @@ -195,6 +195,30 @@ export class QuoteStatusEntryStore { return null; } + /** + * Looks up every entry sharing the given transaction metadata identifier. + * + * A single 7702/nested batch transaction submits multiple quotes under one + * source tx hash and one `txMetaId`, producing several entries that must all + * be finalized together. Each matching entry has its TTL checked (and is + * transitioned to {@link QuoteStatusState.Expired} if stale) before being + * returned. + * + * @param txMetaId - Transaction metadata identifier to search for. + * @returns The matching entries (empty when none exist). + */ + getAllByTxMetaId(txMetaId: string): QuoteStatusRuntimeEntry[] { + const matches: QuoteStatusRuntimeEntry[] = []; + for (const entry of this.#items.values()) { + if (entry.txMetaId === txMetaId) { + this.expireEntryIfStale(entry); + matches.push(entry); + } + } + + return matches; + } + /** * Retrieves an entry by key. * 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', () => { diff --git a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts index 2ea0f1f510a..1998177b281 100644 --- a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts +++ b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts @@ -143,7 +143,10 @@ export class QuoteStatusManager { * manager is disabled, and surfaces an error when the entry is missing or * cannot transition to the finalized state. * - * @param txMetaId - Transaction metadata id of the finalized quote. + * A single 7702/nested batch transaction submits multiple quotes under one + * `txMetaId`, so every entry sharing that id is finalized together. + * + * @param txMetaId - Transaction metadata id of the finalized quote(s). * @param success - Whether the transaction finalized successfully. */ reportFinalised(txMetaId: string, success: boolean): void { @@ -151,9 +154,9 @@ export class QuoteStatusManager { return; } - const entry = this.#quoteStatusEntryStore.getByTxMetaId(txMetaId); + const entries = this.#quoteStatusEntryStore.getAllByTxMetaId(txMetaId); - if (!entry) { + if (entries.length === 0) { this.#onError?.( new QuoteStatusUpdateError( 'reporting finalization status but entry was not found', @@ -167,19 +170,34 @@ export class QuoteStatusManager { ? QuoteStatusState.FinalizedSuccess : QuoteStatusState.FinalizedFailed; - if (!entry.status.canTransitionTo(nextState)) { - // This is expected, there are race conditions where - // reportFinalized can be called twice. If the second - // call fails due to the first completed sucesfully - // backend will report that we cannot transition outside - // a final state, which is correct and we can safely abort - // the flow. + let hasEntryToProcess = false; + + for (const entry of entries) { + if (!entry.status.canTransitionTo(nextState)) { + // This is expected, there are race conditions where + // reportFinalized can be called twice. If the second + // call fails due to the first completed sucesfully + // backend will report that we cannot transition outside + // a final state, which is correct and we can safely skip + // this entry. + continue; + } + + entry.status.transitionTo(nextState); + hasEntryToProcess = true; + } + + if (!hasEntryToProcess) { return; } - entry.status.transitionTo(nextState); this.#ensureRetryTimerRunning(); - this.#processEntry(entry); + + for (const entry of entries) { + if (entry.status.state === nextState) { + this.#processEntry(entry); + } + } } /** diff --git a/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts index 147e84c5fd2..27b1f2c4626 100644 --- a/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts @@ -105,8 +105,21 @@ export async function* submitBatchSellHandler( }, }; + // Each quote must be reported to the reconsiler service (through QuoteStatusManager) with the hash of the tx that + // actually executed it. So the number of distinct txs the batch produced + // decides how quotes are tracked: + // - one tx (atomic 7702 batch): all quotes share the single hash. + // - many txs (e.g. STX/sendBundle): each quote has its own hash. + // `is7702Tx` only means the account is delegated (true even for many-tx + // batches), so count the unique txs instead. + const isSingleBatchTx = + new Set(allTradesInBatch.map(({ txMeta }) => 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)), ); 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