diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js index 08c991c44f6..a81dd6fed09 100644 --- a/packages/bridge-status-controller/jest.config.js +++ b/packages/bridge-status-controller/jest.config.js @@ -19,10 +19,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 92.06, + branches: 93.31, functions: 100, - lines: 99.75, - statements: 99.75, + lines: 100, + statements: 100, }, }, }); 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 f8325db8bb3..6c8552156bd 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 @@ -114,6 +114,9 @@ Object { ], }, "slippagePercentage": 0, + "srcTxHashes": Array [ + "0xsrcTxHash1", + ], "startTime": 1729964825189, "status": Object { "destChain": Object { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts new file mode 100644 index 00000000000..0354d809a89 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts @@ -0,0 +1,147 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { StatusTypes } from '@metamask/bridge-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import { IntentStatusManager } from './bridge-status-controller.intent'; +import type { BridgeHistoryItem } from './types'; +import { translateIntentOrderToBridgeStatus } from './utils/intent-api'; +import { IntentOrderStatus } from './utils/validators'; + +const makeHistoryItem = ( + overrides?: Partial, +): BridgeHistoryItem => + ({ + quote: { + srcChainId: 1, + destChainId: 1, + intent: { protocol: 'cowswap' }, + }, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '' }, + }, + account: '0xaccount1', + estimatedProcessingTimeInSeconds: 10, + slippagePercentage: 0, + hasApprovalTx: false, + ...overrides, + }) as BridgeHistoryItem; + +describe('IntentStatusManager', () => { + it('returns early when no original tx id is present', () => { + const messenger = { + call: jest.fn(), + } as any; + const updateTransactionFn = jest.fn(); + const manager = new IntentStatusManager({ + messenger, + updateTransactionFn, + }); + + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }, + 1, + ); + + manager.syncTransactionFromIntentStatus( + 'order-1', + makeHistoryItem({ + txMetaId: undefined, + originalTransactionId: undefined, + }), + translation, + IntentOrderStatus.SUBMITTED, + ); + + expect(updateTransactionFn).not.toHaveBeenCalled(); + expect(messenger.call).not.toHaveBeenCalled(); + }); + + it('logs when TransactionController access throws', () => { + const messenger = { + call: jest.fn(() => { + throw new Error('boom'); + }), + } as any; + const updateTransactionFn = jest.fn(); + const manager = new IntentStatusManager({ + messenger, + updateTransactionFn, + }); + + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-2', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }, + 1, + ); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + manager.syncTransactionFromIntentStatus( + 'order-2', + makeHistoryItem({ originalTransactionId: 'tx-1' }), + translation, + IntentOrderStatus.SUBMITTED, + ); + + expect(consoleSpy).toHaveBeenCalled(); + expect(updateTransactionFn).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('updates transaction meta when tx is found', () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: { status: '0x0' }, + }; + const messenger = { + call: jest.fn(() => ({ transactions: [existingTxMeta] })), + } as any; + const updateTransactionFn = jest.fn(); + const manager = new IntentStatusManager({ + messenger, + updateTransactionFn, + }); + + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-3', + status: IntentOrderStatus.COMPLETED, + txHash: '0xhash', + metadata: {}, + }, + 1, + ); + + manager.syncTransactionFromIntentStatus( + 'order-3', + makeHistoryItem({ originalTransactionId: 'tx-2' }), + translation, + IntentOrderStatus.COMPLETED, + ); + + expect(updateTransactionFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'tx-2', + status: TransactionStatus.confirmed, + hash: '0xhash', + txReceipt: expect.objectContaining({ + transactionHash: '0xhash', + status: '0x1', + }), + }), + expect.stringContaining('Intent order status updated'), + ); + }); +}); 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 be6a8b6c2df..d6dd4e49f35 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 @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable jest/no-restricted-matchers */ import { StatusTypes, UnifiedSwapBridgeEventName, @@ -21,8 +22,8 @@ type Tx = Pick & { const seedIntentHistory = (controller: any): any => { controller.update((state: any) => { - state.txHistory['intent:1'] = { - txMetaId: 'intent:1', + state.txHistory['order-1'] = { + txMetaId: 'order-1', originalTransactionId: 'tx1', quote: { srcChainId: 1, @@ -183,6 +184,11 @@ const loadControllerWithMocks = (): any => { const fetchBridgeTxStatusMock = jest.fn(); const getStatusRequestWithSrcTxHashMock = jest.fn(); + const getStatusRequestParamsMock = jest.fn().mockReturnValue({ + srcChainId: 1, + destChainId: 1, + srcTxHash: '', + }); // ADD THIS const shouldSkipFetchDueToFetchFailuresMock = jest @@ -227,11 +233,7 @@ const loadControllerWithMocks = (): any => { handleMobileHardwareWalletDelay: jest.fn().mockResolvedValue(undefined), // keep your existing getStatusRequestParams stub here if you have it - getStatusRequestParams: jest.fn().mockReturnValue({ - srcChainId: 1, - destChainId: 1, - srcTxHash: '', - }), + getStatusRequestParams: getStatusRequestParamsMock, }; }); @@ -264,10 +266,14 @@ const loadControllerWithMocks = (): any => { fetchBridgeTxStatusMock, getStatusRequestWithSrcTxHashMock, shouldSkipFetchDueToFetchFailuresMock, + getStatusRequestParamsMock, }; }; -const setup = (options?: { selectedChainId?: string }): any => { +const setup = (options?: { + selectedChainId?: string; + approvalStatus?: TransactionStatus; +}): any => { const accountAddress = '0xAccount1'; const { messenger, transactions } = createMessengerHarness( accountAddress, @@ -281,6 +287,7 @@ const setup = (options?: { selectedChainId?: string }): any => { fetchBridgeTxStatusMock, getStatusRequestWithSrcTxHashMock, shouldSkipFetchDueToFetchFailuresMock, + getStatusRequestParamsMock, } = loadControllerWithMocks(); const addTransactionFn = jest.fn(async (txParams: any, reqOpts: any) => { @@ -294,7 +301,7 @@ const setup = (options?: { selectedChainId?: string }): any => { const approvalTx: Tx = { id: 'approvalTxId1', type: reqOpts.type, - status: TransactionStatus.failed, // makes #waitForTxConfirmation throw quickly + status: options?.approvalStatus ?? TransactionStatus.failed, chainId: txParams.chainId, hash, }; @@ -355,6 +362,7 @@ const setup = (options?: { selectedChainId?: string }): any => { fetchBridgeTxStatusMock, getStatusRequestWithSrcTxHashMock, shouldSkipFetchDueToFetchFailuresMock, + getStatusRequestParamsMock, }; }; @@ -391,25 +399,142 @@ describe('BridgeStatusController (intent swaps)', () => { }, }); + const promise = controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + expect(await promise.catch((error: any) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/approval/iu), + }), + ); + + // Since we throw before intent order submission succeeds, we should not create the history item + // (and therefore should not start polling). + const historyKey = orderUid; + expect(controller.state.txHistory[historyKey]).toBeUndefined(); + + expect(startPollingSpy).not.toHaveBeenCalled(); + + // Optional: ensure we never called the intent API submit + expect(submitIntentMock).not.toHaveBeenCalled(); + }); + + it('submitIntent: completes when approval tx confirms', async () => { + const { controller, accountAddress, submitIntentMock } = setup({ + approvalStatus: TransactionStatus.confirmed, + }); + + const orderUid = 'order-uid-approve-1'; + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse({ + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + await expect( controller.submitIntent({ quoteResponse, signature: '0xsig', accountAddress, }), - ).rejects.toThrow(/approval/iu); + ).resolves.toBeDefined(); - // Since we throw before intent order submission succeeds, we should not create the intent:* history item - // (and therefore should not start polling). - const historyKey = `intent:${orderUid}`; - expect(controller.state.txHistory[historyKey]).toBeUndefined(); + expect(submitIntentMock).toHaveBeenCalled(); + }); - expect(startPollingSpy).not.toHaveBeenCalled(); + it('submitIntent: throws when approval tx is rejected', async () => { + const { controller, accountAddress, submitIntentMock } = setup({ + approvalStatus: TransactionStatus.rejected, + }); + + const orderUid = 'order-uid-approve-2'; + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse({ + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + + const promise = controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + expect(await promise.catch((error: any) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/approval/iu), + }), + ); - // Optional: ensure we never called the intent API submit expect(submitIntentMock).not.toHaveBeenCalled(); }); + it('submitIntent: logs error when history update fails but still returns tx meta', async () => { + const { + controller, + accountAddress, + submitIntentMock, + getStatusRequestParamsMock, + } = setup(); + + const orderUid = 'order-uid-log-1'; + + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + getStatusRequestParamsMock.mockImplementation(() => { + throw new Error('boom'); + }); + + const quoteResponse = minimalIntentQuoteResponse(); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const result = await controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + + expect(result).toBeDefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to add to bridge history'), + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + it('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { const { controller, @@ -436,7 +561,7 @@ describe('BridgeStatusController (intent swaps)', () => { accountAddress, }); - const historyKey = `intent:${orderUid}`; + const historyKey = orderUid; // Seed existing hashes via controller.update (state is frozen) controller.update((state: any) => { @@ -488,7 +613,7 @@ describe('BridgeStatusController (intent swaps)', () => { accountAddress, }); - const historyKey = `intent:${orderUid}`; + const historyKey = orderUid; // Remove TC tx so update branch logs "transaction not found" transactions.splice(0, transactions.length); @@ -537,7 +662,7 @@ describe('BridgeStatusController (intent swaps)', () => { accountAddress, }); - const historyKey = `intent:${orderUid}`; + const historyKey = orderUid; // Prime attempts so next failure hits MAX_ATTEMPTS controller.update((state: any) => { @@ -1369,9 +1494,9 @@ describe('BridgeStatusController (target uncovered branches)', () => { metadata: { txHashes: [] }, }); - await controller._executePoll({ bridgeTxMetaId: 'intent:1' }); + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); - expect(controller.state.txHistory['intent:1'].status.status).toBe( + expect(controller.state.txHistory['order-1'].status.status).toBe( StatusTypes.PENDING, ); }); @@ -1388,9 +1513,9 @@ describe('BridgeStatusController (target uncovered branches)', () => { metadata: { txHashes: [] }, }); - await controller._executePoll({ bridgeTxMetaId: 'intent:1' }); + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); - expect(controller.state.txHistory['intent:1'].status.status).toBe( + expect(controller.state.txHistory['order-1'].status.status).toBe( StatusTypes.SUBMITTED, ); }); @@ -1407,10 +1532,18 @@ describe('BridgeStatusController (target uncovered branches)', () => { metadata: { txHashes: [] }, }); - await controller._executePoll({ bridgeTxMetaId: 'intent:1' }); + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); - expect(controller.state.txHistory['intent:1'].status.status).toBe( + expect(controller.state.txHistory['order-1'].status.status).toBe( StatusTypes.UNKNOWN, ); }); + + it('bridge polling: returns early when history item is missing', async () => { + const { controller, fetchBridgeTxStatusMock } = setup(); + + await controller._executePoll({ bridgeTxMetaId: 'missing-history' }); + + expect(fetchBridgeTxStatusMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts new file mode 100644 index 00000000000..4f251df5540 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts @@ -0,0 +1,90 @@ +import { StatusTypes } from '@metamask/bridge-controller'; +import type { TransactionController } from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +import type { BridgeStatusControllerMessenger } from './types'; +import type { BridgeHistoryItem } from './types'; +import { IntentStatusTranslation } from './utils/intent-api'; + +export class IntentStatusManager { + readonly #messenger: BridgeStatusControllerMessenger; + + readonly #updateTransactionFn: typeof TransactionController.prototype.updateTransaction; + + constructor({ + messenger, + updateTransactionFn, + }: { + messenger: BridgeStatusControllerMessenger; + updateTransactionFn: typeof TransactionController.prototype.updateTransaction; + }) { + this.#messenger = messenger; + this.#updateTransactionFn = updateTransactionFn; + } + + syncTransactionFromIntentStatus = ( + bridgeTxMetaId: string, + historyItem: BridgeHistoryItem, + intentTranslation: IntentStatusTranslation, + intentOrderStatus: string, + ): void => { + // Update the actual transaction in TransactionController to sync with intent status + // Use the original transaction ID (not the bridge history key) + const originalTxId = + historyItem.originalTransactionId ?? historyItem.txMetaId; + if (!originalTxId) { + return; + } + + try { + // Merge with existing TransactionMeta to avoid wiping required fields + const { transactions } = this.#messenger.call( + 'TransactionController:getState', + ); + const existingTxMeta = transactions.find( + (tx: TransactionMeta) => tx.id === originalTxId, + ); + if (!existingTxMeta) { + console.warn( + '📝 [Intent polling] Skipping update; transaction not found', + { originalTxId, bridgeHistoryKey: bridgeTxMetaId }, + ); + return; + } + + const { txHash } = intentTranslation; + const isComplete = + intentTranslation.status.status === StatusTypes.COMPLETE; + const existingTxReceipt = ( + existingTxMeta as { txReceipt?: Record } + ).txReceipt; + const txReceiptUpdate = txHash + ? { + txReceipt: { + ...existingTxReceipt, + transactionHash: txHash, + status: (isComplete ? '0x1' : '0x0') as unknown as string, + }, + } + : {}; + + const updatedTxMeta: TransactionMeta = { + ...existingTxMeta, + status: intentTranslation.transactionStatus, + ...(txHash ? { hash: txHash } : {}), + ...txReceiptUpdate, + } as TransactionMeta; + + this.#updateTransactionFn( + updatedTxMeta, + `BridgeStatusController - Intent order status updated: ${intentOrderStatus}`, + ); + } catch (error) { + console.error('📝 [Intent polling] Failed to update transaction status', { + originalTxId, + bridgeHistoryKey: bridgeTxMetaId, + error, + }); + } + }; +} 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 5af1c07f817..04cbeea9c4c 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -416,6 +416,7 @@ const MockTxHistory = { srcTxHash, srcChainId, }), + srcTxHashes: srcTxHash ? [srcTxHash] : undefined, targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', initialDestAssetBalance: undefined, pricingData: { @@ -457,6 +458,7 @@ const MockTxHistory = { txHash: srcTxHash, }, }, + srcTxHashes: srcTxHash ? [srcTxHash] : undefined, targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', initialDestAssetBalance: undefined, pricingData: { @@ -492,6 +494,7 @@ const MockTxHistory = { srcTxHash, srcChainId, }), + srcTxHashes: srcTxHash ? [srcTxHash] : undefined, targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', initialDestAssetBalance: undefined, pricingData: { @@ -529,6 +532,7 @@ const MockTxHistory = { slippagePercentage: 0, account, status: MockStatusResponse.getComplete({ srcTxHash }), + srcTxHashes: srcTxHash ? [srcTxHash] : undefined, targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', initialDestAssetBalance: undefined, pricingData: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 7383d6d963e..dadf85b7292 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -37,6 +37,7 @@ import type { import { numberToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { IntentStatusManager } from './bridge-status-controller.intent'; import { BRIDGE_PROD_API_BASE_URL, BRIDGE_STATUS_CONTROLLER_NAME, @@ -59,10 +60,16 @@ import { getStatusRequestWithSrcTxHash, shouldSkipFetchDueToFetchFailures, } from './utils/bridge-status'; +import { + rekeyHistoryItemInState, + waitForTxConfirmation, +} from './utils/bridge-status-controller-helpers'; import { getTxGasEstimates } from './utils/gas'; import { IntentApiImpl, + IntentStatusTranslation, mapIntentOrderStatusToTransactionStatus, + translateIntentOrderToBridgeStatus, } from './utils/intent-api'; import { getFinalizedTxProperties, @@ -86,7 +93,6 @@ import { handleNonEvmTxResponse, generateActionId, } from './utils/transaction'; -import { IntentOrder, IntentOrderStatus } from './utils/validators'; const metadata: StateMetadata = { // We want to persist the bridge status state so that we can show the proper data for the Activity list @@ -113,6 +119,8 @@ export class BridgeStatusController extends StaticIntervalPollingController { #pollingTokensByTxMetaId: Record = {}; + readonly #intentStatusManager: IntentStatusManager; + readonly #clientId: BridgeClientId; readonly #fetchFn: FetchFunction; @@ -178,6 +186,10 @@ export class BridgeStatusController extends StaticIntervalPollingController fn?.()) as TraceCallback); + this.#intentStatusManager = new IntentStatusManager({ + messenger: this.messenger, + updateTransactionFn: this.#updateTransactionFn, + }); // Register action handlers this.messenger.registerActionHandler( @@ -546,30 +558,8 @@ export class BridgeStatusController extends StaticIntervalPollingController { - const historyItem = this.state.txHistory[actionId]; - if (!historyItem) { - return; - } - this.update((state) => { - // Update fields that weren't available pre-submission - const updatedItem: BridgeHistoryItem = { - ...historyItem, - txMetaId: txMeta.id, - originalTransactionId: historyItem.originalTransactionId ?? txMeta.id, - status: { - ...historyItem.status, - srcChain: { - ...historyItem.status.srcChain, - txHash: txMeta.hash ?? historyItem.status.srcChain?.txHash, - }, - }, - }; - - // Add under new key (txMeta.id) - state.txHistory[txMeta.id] = updatedItem; - // Remove old key (actionId) - delete state.txHistory[actionId]; + rekeyHistoryItemInState(state, actionId, txMeta); }); }; @@ -585,7 +575,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - const { txHistory } = this.state; + // 1. Check for history item - // Intent-based items: poll intent provider instead of Bridge API - if (bridgeTxMetaId.startsWith('intent:')) { - await this.#fetchIntentOrderStatus({ bridgeTxMetaId }); + const { txHistory } = this.state; + const historyItem = txHistory[bridgeTxMetaId]; + if (!historyItem) { return; } - if ( - shouldSkipFetchDueToFetchFailures(txHistory[bridgeTxMetaId]?.attempts) - ) { + // 2. Check for previous failures + + if (shouldSkipFetchDueToFetchFailures(historyItem.attempts)) { return; } + // 3. Fetch transcation status + try { - // We try here because we receive 500 errors from Bridge API if we try to fetch immediately after submitting the source tx - // Oddly mostly happens on Optimism, never on Arbitrum. By the 2nd fetch, the Bridge API responds properly. - // Also srcTxHash may not be available immediately for STX, so we don't want to fetch in those cases - const historyItem = txHistory[bridgeTxMetaId]; - const srcTxHash = this.#getSrcTxHash(bridgeTxMetaId); - if (!srcTxHash) { - return; - } + let status: BridgeHistoryItem['status']; + let validationFailures: string[] = []; + let intentTranslation: IntentStatusTranslation | null = null; + let intentOrderStatus: string | undefined; + let intentHashes: string[] = []; - this.#updateSrcTxHash(bridgeTxMetaId, srcTxHash); + const isIntent = Boolean(historyItem.quote.intent); - const statusRequest = getStatusRequestWithSrcTxHash( - historyItem.quote, - srcTxHash, - ); - const { status, validationFailures } = await fetchBridgeTxStatus( - statusRequest, - this.#clientId, - this.#fetchFn, - this.#config.customBridgeApiBaseUrl, - ); + if (isIntent) { + const { srcChainId } = historyItem.quote; + + const intentApi = new IntentApiImpl( + this.#config.customBridgeApiBaseUrl, + this.#fetchFn, + ); + const intentOrder = await intentApi.getOrderStatus( + bridgeTxMetaId, + historyItem.quote.intent?.protocol ?? '', + srcChainId.toString(), + this.#clientId, + ); + + intentOrderStatus = intentOrder.status; + intentTranslation = translateIntentOrderToBridgeStatus( + intentOrder, + srcChainId, + historyItem.status.srcChain.txHash, + ); + status = intentTranslation.status; + intentHashes = intentTranslation.srcTxHashes; + } else { + // We try here because we receive 500 errors from Bridge API if we try to fetch immediately after submitting the source tx + // Oddly mostly happens on Optimism, never on Arbitrum. By the 2nd fetch, the Bridge API responds properly. + // Also srcTxHash may not be available immediately for STX, so we don't want to fetch in those cases + const srcTxHash = this.#getSrcTxHash(bridgeTxMetaId); + if (!srcTxHash) { + return; + } + + this.#updateSrcTxHash(bridgeTxMetaId, srcTxHash); + + const statusRequest = getStatusRequestWithSrcTxHash( + historyItem.quote, + srcTxHash, + ); + const response = await fetchBridgeTxStatus( + statusRequest, + this.#clientId, + this.#fetchFn, + this.#config.customBridgeApiBaseUrl, + ); + status = response.status; + validationFailures = response.validationFailures; + } if (validationFailures.length > 0) { this.#trackUnifiedSwapBridgeEvent( @@ -726,6 +751,18 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - /* c8 ignore start */ - const { txHistory } = this.state; - const historyItem = txHistory[bridgeTxMetaId]; - - if (!historyItem) { - return; - } - - // Backoff handling - - if (shouldSkipFetchDueToFetchFailures(historyItem.attempts)) { - return; - } - - try { - const orderId = bridgeTxMetaId.replace(/^intent:/u, ''); - const { srcChainId } = historyItem.quote; - - // Extract provider name from order metadata or default to empty - const providerName = historyItem.quote.intent?.protocol ?? ''; - - const intentApi = new IntentApiImpl( - this.#config.customBridgeApiBaseUrl, - this.#fetchFn, - ); - const intentOrder = await intentApi.getOrderStatus( - orderId, - providerName, - srcChainId.toString(), - this.#clientId, - ); - - // Update bridge history with intent order status - this.#updateBridgeHistoryFromIntentOrder( - bridgeTxMetaId, - intentOrder, - historyItem, - ); - } catch (error) { - console.error('Failed to fetch intent order status:', error); - this.#handleFetchFailure(bridgeTxMetaId); - } - /* c8 ignore stop */ - }; - - #updateBridgeHistoryFromIntentOrder( - bridgeTxMetaId: string, - intentOrder: IntentOrder, - historyItem: BridgeHistoryItem, - ): void { - const { srcChainId } = historyItem.quote; - - // Map intent order status to bridge status using enum values - let statusType: StatusTypes; - const isComplete = [ - IntentOrderStatus.CONFIRMED, - IntentOrderStatus.COMPLETED, - ].includes(intentOrder.status); - const isFailed = [ - IntentOrderStatus.FAILED, - IntentOrderStatus.EXPIRED, - IntentOrderStatus.CANCELLED, - ].includes(intentOrder.status); - const isPending = [IntentOrderStatus.PENDING].includes(intentOrder.status); - const isSubmitted = [IntentOrderStatus.SUBMITTED].includes( - intentOrder.status, - ); - - if (isComplete) { - statusType = StatusTypes.COMPLETE; - } else if (isFailed) { - statusType = StatusTypes.FAILED; - } else if (isPending) { - statusType = StatusTypes.PENDING; - } else if (isSubmitted) { - statusType = StatusTypes.SUBMITTED; - } else { - statusType = StatusTypes.UNKNOWN; - } - - // Extract transaction hashes from intent order - const txHash = intentOrder.txHash ?? ''; - // Check metadata for additional transaction hashes - const metadataTxHashes = Array.isArray(intentOrder.metadata.txHashes) - ? intentOrder.metadata.txHashes - : []; - - let allHashes: string[]; - if (metadataTxHashes.length > 0) { - allHashes = metadataTxHashes; - } else if (txHash) { - allHashes = [txHash]; - } else { - allHashes = []; - } - - const newStatus = { - status: statusType, - srcChain: { - chainId: srcChainId, - txHash: txHash ?? historyItem.status.srcChain.txHash ?? '', - }, - } as typeof historyItem.status; - - const newBridgeHistoryItem = { - ...historyItem, - status: newStatus, - completionTime: - newStatus.status === StatusTypes.COMPLETE || - newStatus.status === StatusTypes.FAILED - ? Date.now() - : undefined, - attempts: undefined, - srcTxHashes: - allHashes.length > 0 - ? Array.from( - new Set([...(historyItem.srcTxHashes ?? []), ...allHashes]), - ) - : historyItem.srcTxHashes, - }; - - this.update((state) => { - state.txHistory[bridgeTxMetaId] = newBridgeHistoryItem; - }); - - // Update the actual transaction in TransactionController to sync with intent status - // Use the original transaction ID (not the intent: prefixed bridge history key) - const originalTxId = - historyItem.originalTransactionId ?? historyItem.txMetaId; - if (originalTxId && !originalTxId.startsWith('intent:')) { - try { - const transactionStatus = mapIntentOrderStatusToTransactionStatus( - intentOrder.status, - ); - - // Merge with existing TransactionMeta to avoid wiping required fields - const { transactions } = this.messenger.call( - 'TransactionController:getState', - ); - const existingTxMeta = transactions.find( - (tx: TransactionMeta) => tx.id === originalTxId, - ); - if (existingTxMeta) { - const updatedTxMeta: TransactionMeta = { - ...existingTxMeta, - status: transactionStatus, - ...(txHash ? { hash: txHash } : {}), - ...(txHash - ? ({ - txReceipt: { - ...( - existingTxMeta as unknown as { - txReceipt: Record; - } - ).txReceipt, - transactionHash: txHash, - status: (isComplete ? '0x1' : '0x0') as unknown as string, - }, - } as Partial) - : {}), - } as TransactionMeta; - - this.#updateTransactionFn( - updatedTxMeta, - `BridgeStatusController - Intent order status updated: ${intentOrder.status}`, - ); - } else { - console.warn( - '📝 [fetchIntentOrderStatus] Skipping update; transaction not found', - { originalTxId, bridgeHistoryKey: bridgeTxMetaId }, - ); - } - } catch (error) { - /* c8 ignore start */ - console.error( - '📝 [fetchIntentOrderStatus] Failed to update transaction status', - { - originalTxId, - bridgeHistoryKey: bridgeTxMetaId, - error, - }, - ); - } - /* c8 ignore stop */ - } - - const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; - const isFinal = - newStatus.status === StatusTypes.COMPLETE || - newStatus.status === StatusTypes.FAILED; - if (isFinal && pollingToken) { - this.stopPollingByPollingToken(pollingToken); - delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; - - if (newStatus.status === StatusTypes.COMPLETE) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - bridgeTxMetaId, - ); - } else if (newStatus.status === StatusTypes.FAILED) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - bridgeTxMetaId, - ); - } - } - } - readonly #getSrcTxHash = (bridgeTxMetaId: string): string | undefined => { const { txHistory } = this.state; // Prefer the srcTxHash from bridgeStatusState so we don't have to l ook up in TransactionController @@ -1155,39 +993,10 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - /* c8 ignore start */ - const start = Date.now(); - // Poll the TransactionController state for status changes - // We intentionally keep this simple to avoid extra wiring/subscriptions in this controller - // and because we only need it for the rare intent+approval path. - while (true) { - const { transactions } = this.messenger.call( - 'TransactionController:getState', - ); - const meta = transactions.find((tx: TransactionMeta) => tx.id === txId); - - if (meta) { - // Treat both 'confirmed' and 'finalized' as success to match TC lifecycle - - if (meta.status === TransactionStatus.confirmed) { - return meta; - } - if ( - meta.status === TransactionStatus.failed || - meta.status === TransactionStatus.dropped || - meta.status === TransactionStatus.rejected - ) { - throw new Error('Approval transaction did not confirm'); - } - } - - if (Date.now() - start > timeoutMs) { - throw new Error('Timed out waiting for approval confirmation'); - } - - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - /* c8 ignore stop */ + return await waitForTxConfirmation(this.messenger, txId, { + timeoutMs, + pollMs, + }); }; readonly #handleApprovalTx = async ( @@ -1853,13 +1662,13 @@ export class BridgeStatusController extends StaticIntervalPollingController, +): BridgeStatusControllerState => + ({ + txHistory: {}, + ...overrides, + }) as BridgeStatusControllerState; + +describe('bridge-status-controller helpers', () => { + it('rekeyHistoryItemInState returns false when history item missing', () => { + const state = makeState(); + const result = rekeyHistoryItemInState(state, 'missing', { + id: 'tx1', + hash: '0xhash', + }); + expect(result).toBe(false); + }); + + it('rekeyHistoryItemInState rekeys and preserves srcTxHash', () => { + const state = makeState({ + txHistory: { + action1: { + txMetaId: undefined, + actionId: 'action1', + originalTransactionId: undefined, + quote: { srcChainId: 1, destChainId: 10 } as any, + status: { + status: TransactionStatus.submitted, + srcChain: { chainId: 1, txHash: '0xold' }, + } as any, + account: '0xaccount', + estimatedProcessingTimeInSeconds: 1, + slippagePercentage: 0, + hasApprovalTx: false, + }, + }, + }); + + const result = rekeyHistoryItemInState(state, 'action1', { + id: 'tx1', + hash: '0xnew', + }); + + expect(result).toBe(true); + expect(state.txHistory.action1).toBeUndefined(); + expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xnew'); + }); + + it('waitForTxConfirmation resolves when confirmed', async () => { + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { id: 'tx1', status: TransactionStatus.confirmed } as any, + ], + })), + } as any; + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 10, + pollMs: 1, + }); + expect(await promise).toStrictEqual(expect.objectContaining({ id: 'tx1' })); + }); + + it('waitForTxConfirmation throws when rejected', async () => { + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { id: 'tx1', status: TransactionStatus.rejected } as any, + ], + })), + } as any; + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 10, + pollMs: 1, + }); + expect(await promise.catch((error) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/did not confirm/iu), + }), + ); + }); + + it('waitForTxConfirmation times out when status never changes', async () => { + jest.useFakeTimers(); + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { id: 'tx1', status: TransactionStatus.submitted } as any, + ], + })), + } as any; + const nowSpy = jest.spyOn(Date, 'now'); + let now = 0; + nowSpy.mockImplementation(() => now); + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 5, + pollMs: 1, + }); + + now = 10; + jest.advanceTimersByTime(1); + await Promise.resolve(); + + expect(await promise.catch((error) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/Timed out/iu), + }), + ); + + nowSpy.mockRestore(); + jest.useRealTimers(); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/bridge-status-controller-helpers.ts b/packages/bridge-status-controller/src/utils/bridge-status-controller-helpers.ts new file mode 100644 index 00000000000..4062a462f40 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge-status-controller-helpers.ts @@ -0,0 +1,65 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import type { BridgeStatusControllerMessenger } from '../types'; +import type { BridgeStatusControllerState } from '../types'; + +export const rekeyHistoryItemInState = ( + state: BridgeStatusControllerState, + actionId: string, + txMeta: { id: string; hash?: string }, +): boolean => { + const historyItem = state.txHistory[actionId]; + if (!historyItem) { + return false; + } + + state.txHistory[txMeta.id] = { + ...historyItem, + txMetaId: txMeta.id, + originalTransactionId: historyItem.originalTransactionId ?? txMeta.id, + status: { + ...historyItem.status, + srcChain: { + ...historyItem.status.srcChain, + txHash: txMeta.hash ?? historyItem.status.srcChain?.txHash, + }, + }, + }; + delete state.txHistory[actionId]; + return true; +}; + +export const waitForTxConfirmation = async ( + messenger: BridgeStatusControllerMessenger, + txId: string, + { + timeoutMs = 5 * 60_000, + pollMs = 3_000, + }: { timeoutMs?: number; pollMs?: number } = {}, +): Promise => { + const start = Date.now(); + while (true) { + const { transactions } = messenger.call('TransactionController:getState'); + const meta = transactions.find((tx: TransactionMeta) => tx.id === txId); + + if (meta) { + if (meta.status === TransactionStatus.confirmed) { + return meta; + } + if ( + meta.status === TransactionStatus.failed || + meta.status === TransactionStatus.dropped || + meta.status === TransactionStatus.rejected + ) { + throw new Error('Approval transaction did not confirm'); + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out waiting for approval confirmation'); + } + + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +}; diff --git a/packages/bridge-status-controller/src/utils/intent-api.test.ts b/packages/bridge-status-controller/src/utils/intent-api.test.ts index 2f15fdfa214..14a2220cf30 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.test.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.test.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { IntentApiImpl } from './intent-api'; +import { StatusTypes } from '@metamask/bridge-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import { + IntentApiImpl, + translateIntentOrderToBridgeStatus, +} from './intent-api'; import type { IntentSubmissionParams } from './intent-api'; import { IntentOrderStatus } from './validators'; import type { FetchFunction } from '../types'; @@ -138,4 +144,87 @@ describe('IntentApiImpl', () => { 'Failed to get order status: Invalid getOrderStatus response', ); }); + + describe('translateIntentOrderToBridgeStatus', () => { + it('maps completed intent to COMPLETE and confirmed transaction status', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-1', + status: IntentOrderStatus.COMPLETED, + txHash: '0xhash1', + metadata: { txHashes: ['0xhash1', '0xhash2'] }, + }, + 1, + ); + + expect(translation.status).toStrictEqual({ + status: StatusTypes.COMPLETE, + srcChain: { + chainId: 1, + txHash: '0xhash1', + }, + }); + expect(translation.srcTxHashes).toStrictEqual(['0xhash1', '0xhash2']); + expect(translation.transactionStatus).toBe(TransactionStatus.confirmed); + }); + + it('maps cancelled intent to FAILED and falls back to metadata tx hash', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-2', + status: IntentOrderStatus.CANCELLED, + metadata: { txHashes: '0xmetadatahash' }, + }, + 10, + '0xfallback', + ); + + expect(translation.status.status).toBe(StatusTypes.FAILED); + expect(translation.status.srcChain).toStrictEqual({ + chainId: 10, + txHash: '0xfallback', + }); + expect(translation.srcTxHashes).toStrictEqual(['0xmetadatahash']); + expect(translation.transactionStatus).toBe(TransactionStatus.failed); + }); + it('prefers txHash when metadata is empty and returns empty hashes when none exist', () => { + const withTxHash = translateIntentOrderToBridgeStatus( + { + id: 'order-3', + status: IntentOrderStatus.SUBMITTED, + txHash: '0xonlyhash', + metadata: { txHashes: [] }, + }, + 1, + ); + + expect(withTxHash.srcTxHashes).toStrictEqual(['0xonlyhash']); + + const withoutHashes = translateIntentOrderToBridgeStatus( + { + id: 'order-4', + status: IntentOrderStatus.SUBMITTED, + metadata: { txHashes: '' }, + }, + 1, + ); + + expect(withoutHashes.srcTxHashes).toStrictEqual([]); + expect(withoutHashes.status.status).toBe(StatusTypes.SUBMITTED); + + const emptyMetadataWithTxHash = translateIntentOrderToBridgeStatus( + { + id: 'order-5', + status: IntentOrderStatus.SUBMITTED, + txHash: '0xfallbackhash', + metadata: { txHashes: '' }, + }, + 1, + ); + + expect(emptyMetadataWithTxHash.srcTxHashes).toStrictEqual([ + '0xfallbackhash', + ]); + }); + }); }); diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index 928f946c15a..e4933fdbabd 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -1,3 +1,4 @@ +import { StatusTypes } from '@metamask/bridge-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { @@ -5,7 +6,7 @@ import { IntentOrderStatus, validateIntentOrderResponse, } from './validators'; -import type { FetchFunction } from '../types'; +import type { FetchFunction, StatusResponse } from '../types'; export type IntentSubmissionParams = { srcChainId: string; @@ -96,6 +97,78 @@ export class IntentApiImpl implements IntentApi { } } +export type IntentStatusTranslation = { + status: StatusResponse; + srcTxHashes: string[]; + txHash?: string; + transactionStatus: TransactionStatus; +}; + +const normalizeIntentTxHashes = (intentOrder: IntentOrder): string[] => { + const { txHashes } = intentOrder.metadata ?? {}; + if (Array.isArray(txHashes)) { + if (txHashes.length > 0) { + return txHashes; + } + if (intentOrder.txHash) { + return [intentOrder.txHash]; + } + return []; + } + if (typeof txHashes === 'string' && txHashes.length > 0) { + return [txHashes]; + } + if (intentOrder.txHash) { + return [intentOrder.txHash]; + } + return []; +}; + +export const translateIntentOrderToBridgeStatus = ( + intentOrder: IntentOrder, + srcChainId: number, + fallbackTxHash?: string, +): IntentStatusTranslation => { + let statusType: StatusTypes; + switch (intentOrder.status) { + case IntentOrderStatus.CONFIRMED: + case IntentOrderStatus.COMPLETED: + statusType = StatusTypes.COMPLETE; + break; + case IntentOrderStatus.FAILED: + case IntentOrderStatus.EXPIRED: + case IntentOrderStatus.CANCELLED: + statusType = StatusTypes.FAILED; + break; + case IntentOrderStatus.PENDING: + statusType = StatusTypes.PENDING; + break; + case IntentOrderStatus.SUBMITTED: + statusType = StatusTypes.SUBMITTED; + break; + default: + statusType = StatusTypes.UNKNOWN; + } + + const txHash = intentOrder.txHash ?? fallbackTxHash ?? ''; + const status: StatusResponse = { + status: statusType, + srcChain: { + chainId: srcChainId, + txHash, + }, + }; + + return { + status, + txHash: intentOrder.txHash, + srcTxHashes: normalizeIntentTxHashes(intentOrder), + transactionStatus: mapIntentOrderStatusToTransactionStatus( + intentOrder.status, + ), + }; +}; + export function mapIntentOrderStatusToTransactionStatus( intentStatus: IntentOrderStatus, ): TransactionStatus { @@ -108,6 +181,7 @@ export function mapIntentOrderStatusToTransactionStatus( return TransactionStatus.confirmed; case IntentOrderStatus.FAILED: case IntentOrderStatus.EXPIRED: + case IntentOrderStatus.CANCELLED: return TransactionStatus.failed; default: return TransactionStatus.submitted;