diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 1c56ee55eec..1001f8faddb 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- No longer report `SUBMITTED` or finalized quote statuses to the quote status API for intent-based swaps, since the bridge backend observes intent settlement and owns those statuses ([#10171](https://github.com/MetaMask/core/pull/10171)) - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.0` ([#10184](https://github.com/MetaMask/core/pull/10184)) ## [76.1.0] diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index aa782fb2a18..7ce127fedb5 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -292,6 +292,8 @@ const setup = (options?: { clientId?: BridgeClientId; keyringType?: string; mockTxHistory?: any; + isQuoteStatusManagerEnabled?: () => boolean; + onQuoteStatusManagerError?: (error: unknown) => void; }) => { const accountAddress = '0xAccount1' as const; const { messenger, transactions } = createMessengerHarness( @@ -312,6 +314,8 @@ const setup = (options?: { fetchFn: (...args: any[]) => mockFetchFn(...args), config: { customBridgeApiBaseUrl: 'http://localhost' }, traceFn: (_req: any, fn?: any): any => fn?.(), + isQuoteStatusManagerEnabled: options?.isQuoteStatusManagerEnabled, + onQuoteStatusManagerError: options?.onQuoteStatusManagerError as any, }); const startPollingSpy = jest @@ -745,6 +749,56 @@ describe('BridgeStatusController (intent swaps)', () => { expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); }); + it.each([ + { description: 'COMPLETED', orderStatus: IntentOrderStatus.COMPLETED }, + { description: 'EXPIRED', orderStatus: IntentOrderStatus.EXPIRED }, + ])( + 'intent polling: does not report any quote status when the order reaches $description', + async ({ orderStatus }) => { + const onQuoteStatusManagerError = jest.fn(); + const orderUid = 'order-uid-quote-status-1'; + const { controller } = setup({ + isQuoteStatusManagerEnabled: () => true, + onQuoteStatusManagerError, + mockTxHistory: { + [orderUid]: { + txMetaId: orderUid, + originalTransactionId: orderUid, + quoteId: 'intent-quote-1', + quote: minimalIntentQuoteResponse().quote, + account: '0xAccount1', + startTime: Date.now(), + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }, + }, + }); + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockResolvedValue({ + id: orderUid, + status: orderStatus, + txHash: '0xsettlementhash', + metadata: { txHashes: ['0xsettlementhash'] }, + }); + + await controller._executePoll({ bridgeTxMetaId: orderUid }); + + // The backend owns the quote status of intent orders, so reaching a + // terminal order status must not create or update a tracking entry. + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + expect( + controller.state.txHistory[orderUid]?.reportedSubmittedTxHash, + ).toBeUndefined(); + expect(onQuoteStatusManagerError).not.toHaveBeenCalled(); + + controller.stopAllPolling(); + }, + ); + it('intent polling: stops polling when attempts reach MAX_ATTEMPTS', async () => { const orderUid = 'order-uid-3'; const { controller, stopPollingSpy } = setup({ 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 ea203de2361..0d02217df05 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -7334,6 +7334,153 @@ describe('BridgeStatusController', () => { ); }); }); + + describe('intent-based swaps', () => { + const INTENT_TX_META_ID = 'intentTxMetaId1'; + const INTENT_SRC_TX_HASH = '0xintentSrcTxHash1'; + + /** + * Builds a history item for an intent-based order. The quote carries + * `intent` data, which is what marks the trade as backend-tracked. + * + * @param startTime - When the trade started, used to decide whether + * startup seeding considers the item. + * @returns The intent txHistory keyed by history id. + */ + function buildIntentHistory( + startTime = 1729964825189, + ): Record { + const item = MockTxHistory.getPending({ + txMetaId: INTENT_TX_META_ID, + srcTxHash: INTENT_SRC_TX_HASH, + startTime, + })[INTENT_TX_META_ID]; + + return { + [INTENT_TX_META_ID]: { + ...item, + quoteId: 'intent-quote-1', + quote: { + ...item.quote, + intent: { + protocol: 'cowswap', + order: { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell', + partiallyFillable: false, + sellAmount: '1000', + }, + typedData: { + types: {}, + primaryType: 'Order', + domain: {}, + message: {}, + }, + }, + }, + }, + }; + } + + const getIntentMessengerCall = () => + jest.fn((...args: unknown[]) => { + const action = args[0] as string; + if (action === 'TransactionController:getState') { + return { transactions: [] }; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve('auth-token'); + } + return undefined; + }); + + it.each([ + { + description: 'submitted', + status: TransactionStatus.submitted, + type: TransactionType.swap, + }, + { + description: 'confirmed', + status: TransactionStatus.confirmed, + type: TransactionType.swap, + }, + { + description: 'failed', + status: TransactionStatus.failed, + type: TransactionType.swap, + }, + ])( + 'does not report any quote status when the intent tx is $description', + async ({ status, type }) => { + const onQuoteStatusManagerError = jest.fn(); + + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + onQuoteStatusManagerError, + state: { txHistory: buildIntentHistory() }, + }, + mockMessengerCall: getIntentMessengerCall(), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type, + status, + id: INTENT_TX_META_ID, + hash: INTENT_SRC_TX_HASH, + }, + }, + ); + + // The backend owns the quote status of intent orders, so the + // client neither creates a tracking entry nor marks the history + // item as reported. + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + expect( + controller.state.txHistory[INTENT_TX_META_ID] + .reportedSubmittedTxHash, + ).toBeUndefined(); + // Finalizing an untracked quote surfaces a "entry was not found" + // error, so silence here proves finalization was never attempted. + expect(onQuoteStatusManagerError).not.toHaveBeenCalled(); + }, + ); + }, + ); + + it('does not seed a quote status entry for an intent order on startup', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildIntentHistory(Date.now()) }, + }, + mockMessengerCall: getIntentMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + expect( + controller.state.txHistory[INTENT_TX_META_ID] + .reportedSubmittedTxHash, + ).toBeUndefined(); + }, + ); + }); + }); }); describe('seeding quote status entries from history on startup', () => { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index b07d91aa931..aea9171603f 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -384,6 +384,21 @@ export class BridgeStatusController extends StaticIntervalPollingController + historyKey + ? Boolean(this.state.txHistory[historyKey]?.quote.intent) + : false; + readonly #onTransactionFailed = ({ txMeta, historyKey, @@ -396,9 +411,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { const historyItem = this.state.txHistory[historyKey]; - if (!historyItem) { + if (!historyItem || historyItem.quote.intent) { return; } @@ -994,12 +1007,14 @@ export class BridgeStatusController extends StaticIntervalPollingController