diff --git a/.gitignore b/.gitignore index 6c1e52eb80d..943d49c6e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,8 @@ scripts/coverage !.yarn/versions # typescript -packages/*/*.tsbuildinfo \ No newline at end of file +packages/*/*.tsbuildinfo + +# Emacs +\#*\# +.#* diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 09e332d490f..79b6bc7c88b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -770,25 +770,6 @@ "count": 1 } }, - "packages/bridge-status-controller/src/bridge-status-controller.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 20 - }, - "@typescript-eslint/naming-convention": { - "count": 5 - }, - "camelcase": { - "count": 8 - }, - "id-length": { - "count": 1 - } - }, - "packages/bridge-status-controller/src/types.ts": { - "@typescript-eslint/naming-convention": { - "count": 7 - } - }, "packages/bridge-status-controller/src/utils/bridge-status.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 3 diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 99760d2a8b2..c29e0e0a242 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add intent based transaction support ([#6547](https://github.com/MetaMask/core/pull/6547)) + ### Changed - Bump `@metamask/remote-feature-flag-controller` from `^3.1.0` to `^4.0.0` ([#7546](https://github.com/MetaMask/core/pull/7546)) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 5cd6b421bf8..714397be2a2 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -42,6 +42,8 @@ export type { QuoteResponse, FeeData, TxData, + Intent, + IntentOrderLike, BitcoinTradeData, TronTradeData, BridgeControllerState, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index a2acca431ef..260cbfc847e 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -32,6 +32,7 @@ import type { ChainConfigurationSchema, FeatureId, FeeDataSchema, + IntentSchema, PlatformConfigSchema, ProtocolSchema, QuoteResponseSchema, @@ -223,6 +224,7 @@ export type QuoteRequest< }; export enum StatusTypes { + SUBMITTED = 'SUBMITTED', UNKNOWN = 'UNKNOWN', FAILED = 'FAILED', PENDING = 'PENDING', @@ -251,6 +253,9 @@ export type Quote = Infer; export type TxData = Infer; +export type Intent = Infer; +export type IntentOrderLike = Intent['order']; + export type BitcoinTradeData = Infer; export type TronTradeData = Infer; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 327a3f5eee1..8f25ca4e973 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -192,6 +192,127 @@ export const StepSchema = type({ const RefuelDataSchema = StepSchema; +// Allow digit strings for amounts/validTo for flexibility across providers +const DigitStringOrNumberSchema = union([TruthyDigitStringSchema, number()]); + +/** + * Identifier of the intent protocol used for order creation and submission. + * + * Examples: + * - CoW Swap + * - Other EIP-712–based intent protocols + */ +const IntentProtocolSchema = string(); + +/** + * Schema for an intent-based order used for EIP-712 signing and submission. + * + * This represents the minimal subset of fields required by intent-based + * protocols (e.g. CoW Swap) to build, sign, and submit an order. + */ +export const IntentOrderSchema = type({ + /** + * Address of the token being sold. + */ + sellToken: HexAddressSchema, + + /** + * Address of the token being bought. + */ + buyToken: HexAddressSchema, + + /** + * Optional receiver of the bought tokens. + * If omitted, defaults to the signer / order owner. + */ + receiver: optional(HexAddressSchema), + + /** + * Order expiration time. + * + * Can be provided as a UNIX timestamp in seconds, either as a number + * or as a digit string, depending on provider requirements. + */ + validTo: DigitStringOrNumberSchema, + + /** + * Arbitrary application-specific data attached to the order. + */ + appData: string(), + + /** + * Hash of the `appData` field, used for EIP-712 signing. + */ + appDataHash: HexStringSchema, + + /** + * Fee amount paid for order execution, expressed as a digit string. + */ + feeAmount: TruthyDigitStringSchema, + + /** + * Order kind. + * + * - `sell`: exact sell amount, variable buy amount + * - `buy`: exact buy amount, variable sell amount + */ + kind: enums(['sell', 'buy']), + + /** + * Whether the order can be partially filled. + */ + partiallyFillable: boolean(), + + /** + * Exact amount of the sell token. + * + * Required for `sell` orders. + */ + sellAmount: optional(TruthyDigitStringSchema), + + /** + * Exact amount of the buy token. + * + * Required for `buy` orders. + */ + buyAmount: optional(TruthyDigitStringSchema), + + /** + * Optional order owner / sender address. + * + * Provided for convenience when building the EIP-712 domain and message. + */ + from: optional(HexAddressSchema), +}); + +/** + * Schema representing an intent submission payload. + * + * Wraps the intent order along with protocol and optional routing metadata + * required by the backend or relayer infrastructure. + */ +export const IntentSchema = type({ + /** + * Identifier of the intent protocol used to interpret the order. + */ + protocol: IntentProtocolSchema, + + /** + * The intent order to be signed and submitted. + */ + order: IntentOrderSchema, + + /** + * Optional settlement contract address used for execution. + */ + settlementContract: optional(HexAddressSchema), + + /** + * Optional relayer address responsible for order submission. + */ + relayer: optional(HexAddressSchema), +}); + export const QuoteSchema = type({ requestId: string(), srcChainId: ChainIdSchema, @@ -244,6 +365,7 @@ export const QuoteSchema = type({ totalFeeAmountUsd: optional(string()), }), ), + intent: optional(IntentSchema), /** * A third party sponsors the gas. If true, then gasIncluded7702 is also true. */ diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 942ebbbe17b..8bff1269025 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 +- **BREAKING** Use CrossChain API instead of the intent manager package for intent order submission ([#6547](https://github.com/MetaMask/core/pull/6547)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) - Bump `@metamask/network-controller` from `^27.0.0` to `^27.1.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) - Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +>>>>>>> main - Bump `@metamask/transaction-controller` from `^62.5.0` to `^62.7.0` ([#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494)) - Bump `@metamask/bridge-controller` from `^64.1.0` to `^64.2.0` ([#7509](https://github.com/MetaMask/core/pull/7509)) diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js index 15a04af42e5..ea33c08f392 100644 --- a/packages/bridge-status-controller/jest.config.js +++ b/packages/bridge-status-controller/jest.config.js @@ -14,10 +14,12 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + coverageProvider: 'v8', + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 94, + branches: 91, functions: 100, 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 f8e2980b1aa..df0f8dcb6db 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 @@ -13,6 +13,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "bridgeTxMetaId1", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": undefined, @@ -214,6 +215,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "bridgeTxMetaId1", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": undefined, @@ -415,6 +417,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -650,6 +653,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -885,6 +889,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -1121,6 +1126,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": true, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -1382,6 +1388,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -1617,6 +1624,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -1852,6 +1860,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -2191,6 +2200,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -2446,6 +2456,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -2852,6 +2863,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": true, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -3204,6 +3216,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -3416,6 +3429,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "test-tx-id", "pricingData": Object { "amountSent": "1.234", "amountSentInUsd": "1.01", @@ -3714,6 +3728,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "signature", "pricingData": Object { "amountSent": "1", "amountSentInUsd": "100", @@ -4048,6 +4063,7 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "signature", "pricingData": Object { "amountSent": "1", "amountSentInUsd": "100", @@ -4423,6 +4439,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "bridge-signature", "pricingData": Object { "amountSent": "1", "amountSentInUsd": "1", @@ -4641,6 +4658,7 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "originalTransactionId": "swap-signature", "pricingData": Object { "amountSent": "1", "amountSentInUsd": "1", 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 new file mode 100644 index 00000000000..9eab25cf356 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -0,0 +1,1440 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + StatusTypes, + UnifiedSwapBridgeEventName, +} from '@metamask/bridge-controller'; +import { IntentOrderStatus } from './utils/validators'; + +import { MAX_ATTEMPTS } from './constants'; +import { IntentApiImpl } from './utils/intent-api'; + +type Tx = Pick & { + type?: TransactionType; + chainId?: string; + hash?: string; + txReceipt?: any; +}; + +function seedIntentHistory(controller: any) { + controller.update((s: any) => { + s.txHistory['intent:1'] = { + txMetaId: 'intent:1', + originalTransactionId: 'tx1', + quote: { + srcChainId: 1, + destChainId: 1, + intent: { protocol: 'cowswap' }, + }, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '' }, + }, + attempts: undefined, // IMPORTANT: prevents early return + }; + }); +} + +function minimalIntentQuoteResponse( + accountAddress: string, + overrides?: Partial, +) { + return { + quote: { + requestId: 'req-1', + srcChainId: 1, + destChainId: 1, + srcTokenAmount: '1000', + destTokenAmount: '990', + minDestTokenAmount: '900', + srcAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + name: 'ETH', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + name: 'ETH', + decimals: 18, + }, + feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + intent: { + protocol: 'cowswap', + order: { some: 'order' }, + settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + }, + }, + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + estimatedProcessingTimeInSeconds: 15, + featureId: undefined, + approval: undefined, + resetApproval: undefined, + trade: '0xdeadbeef', + ...overrides, + }; +} + +function minimalBridgeQuoteResponse( + accountAddress: string, + overrides?: Partial, +) { + return { + quote: { + requestId: 'req-bridge-1', + srcChainId: 1, + destChainId: 10, + srcTokenAmount: '1000', + destTokenAmount: '990', + minDestTokenAmount: '900', + srcAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + name: 'ETH', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + name: 'ETH', + decimals: 18, + }, + feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + }, + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + estimatedProcessingTimeInSeconds: 15, + featureId: undefined, + approval: undefined, + resetApproval: undefined, + trade: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + ...overrides, + }; +} + +function createMessengerHarness( + accountAddress: string, + selectedChainId: string = '0x1', +) { + const transactions: Tx[] = []; + + const messenger = { + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), // REQUIRED by BaseController + subscribe: jest.fn(), + publish: jest.fn(), + call: jest.fn((method: string, ...args: any[]) => { + switch (method) { + case 'AccountsController:getAccountByAddress': { + const addr = (args[0] as string) ?? ''; + if (addr.toLowerCase() !== accountAddress.toLowerCase()) { + return undefined; + } + + // REQUIRED so isHardwareWallet() doesn't throw + return { + address: accountAddress, + metadata: { keyring: { type: 'HD Key Tree' } }, + }; + } + case 'TransactionController:getState': + return { transactions }; + case 'NetworkController:findNetworkClientIdByChainId': + return 'network-client-id-1'; + case 'NetworkController:getState': + return { selectedNetworkClientId: 'selected-network-client-id-1' }; + case 'NetworkController:getNetworkClientById': + return { configuration: { chainId: selectedChainId } }; + case 'BridgeController:trackUnifiedSwapBridgeEvent': + return undefined; + case 'GasFeeController:getState': + return { gasFeeEstimates: {} }; + default: + return undefined; + } + }), + }; + + return { messenger, transactions }; +} + +function loadControllerWithMocks() { + const submitIntentMock = jest.fn(); + const getOrderStatusMock = jest.fn(); + + const fetchBridgeTxStatusMock = jest.fn(); + const getStatusRequestWithSrcTxHashMock = jest.fn(); + + // ADD THIS + const shouldSkipFetchDueToFetchFailuresMock = jest + .fn() + .mockReturnValue(false); + + let BridgeStatusController: any; + + jest.resetModules(); + + jest.isolateModules(() => { + jest.doMock('./utils/intent-api', () => ({ + IntentApiImpl: jest.fn().mockImplementation(() => ({ + submitIntent: submitIntentMock, + getOrderStatus: getOrderStatusMock, + })), + })); + + jest.doMock('./utils/bridge-status', () => { + const actual = jest.requireActual('./utils/bridge-status'); + return { + ...actual, + fetchBridgeTxStatus: fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHash: getStatusRequestWithSrcTxHashMock, + shouldSkipFetchDueToFetchFailures: + shouldSkipFetchDueToFetchFailuresMock, + }; + }); + + jest.doMock('./utils/transaction', () => { + const actual = jest.requireActual('./utils/transaction'); + return { + ...actual, + generateActionId: jest + .fn() + .mockReturnValue({ toString: () => 'action-id-1' }), + handleApprovalDelay: jest.fn().mockResolvedValue(undefined), + handleMobileHardwareWalletDelay: jest.fn().mockResolvedValue(undefined), + + // keep your existing getStatusRequestParams stub here if you have it + getStatusRequestParams: jest.fn().mockReturnValue({ + srcChainId: 1, + destChainId: 1, + srcTxHash: '', + }), + }; + }); + + jest.doMock('./utils/metrics', () => ({ + getFinalizedTxProperties: jest.fn().mockReturnValue({}), + getPriceImpactFromQuote: jest.fn().mockReturnValue({}), + getRequestMetadataFromHistory: jest.fn().mockReturnValue({}), + getRequestParamFromHistory: jest.fn().mockReturnValue({ + chain_id_source: 'eip155:1', + chain_id_destination: 'eip155:10', + token_address_source: '0xsrc', + token_address_destination: '0xdest', + }), + getTradeDataFromHistory: jest.fn().mockReturnValue({}), + getEVMTxPropertiesFromTransactionMeta: jest.fn().mockReturnValue({}), + getTxStatusesFromHistory: jest.fn().mockReturnValue({}), + getPreConfirmationPropertiesFromQuote: jest.fn().mockReturnValue({}), + })); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + BridgeStatusController = + require('./bridge-status-controller').BridgeStatusController; + }); + + return { + BridgeStatusController, + submitIntentMock, + getOrderStatusMock, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + shouldSkipFetchDueToFetchFailuresMock, // ADD THIS + }; +} + +function setup(options?: { selectedChainId?: string }) { + const accountAddress = '0xAccount1'; + const { messenger, transactions } = createMessengerHarness( + accountAddress, + options?.selectedChainId ?? '0x1', + ); + + const { + BridgeStatusController, + submitIntentMock, + getOrderStatusMock, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + shouldSkipFetchDueToFetchFailuresMock, + } = loadControllerWithMocks(); + + const addTransactionFn = jest.fn(async (txParams: any, reqOpts: any) => { + // Approval TX path (submitIntent -> #handleApprovalTx -> #handleEvmTransaction) + if ( + reqOpts?.type === TransactionType.bridgeApproval || + reqOpts?.type === TransactionType.swapApproval + ) { + const hash = '0xapprovalhash1'; + + const approvalTx: Tx = { + id: 'approvalTxId1', + type: reqOpts.type, + status: TransactionStatus.failed, // makes #waitForTxConfirmation throw quickly + chainId: txParams.chainId, + hash, + }; + transactions.push(approvalTx); + + return { + result: Promise.resolve(hash), + transactionMeta: approvalTx, + }; + } + + // Intent “display tx” path + const intentTx: Tx = { + id: 'intentDisplayTxId1', + type: reqOpts?.type, + status: TransactionStatus.submitted, + chainId: txParams.chainId, + hash: undefined, + }; + transactions.push(intentTx); + + return { + result: Promise.resolve('0xunused'), + transactionMeta: intentTx, + }; + }); + + const controller = new BridgeStatusController({ + messenger, + clientId: 'extension', + fetchFn: jest.fn(), + addTransactionFn, + addTransactionBatchFn: jest.fn(), + updateTransactionFn: jest.fn(), + estimateGasFeeFn: jest.fn(async () => ({ estimates: {} })), + config: { customBridgeApiBaseUrl: 'http://localhost' }, + traceFn: (_req: any, fn?: any) => fn?.(), + }); + + const startPollingSpy = jest + .spyOn(controller as any, 'startPolling') + .mockReturnValue('poll-token-1'); + + const stopPollingSpy = jest + .spyOn(controller as any, 'stopPollingByPollingToken') + .mockImplementation(() => undefined); + + return { + controller, + messenger, + transactions, + addTransactionFn, + startPollingSpy, + stopPollingSpy, + accountAddress, + submitIntentMock, + getOrderStatusMock, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + shouldSkipFetchDueToFetchFailuresMock, + }; +} + +describe('BridgeStatusController (intent swaps)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('submitIntent: creates TC tx, writes intent:* history item, starts polling, and continues if approval confirmation fails', async () => { + const { controller, accountAddress, submitIntentMock, startPollingSpy } = + setup(); + + const orderUid = 'order-uid-1'; + + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse(accountAddress, { + // Include approval to exercise “continue if approval confirmation fails” + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + + const res = await controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + + const historyKey = `intent:${orderUid}`; + + expect(controller.state.txHistory[historyKey]).toBeDefined(); + expect(controller.state.txHistory[historyKey].originalTransactionId).toBe( + res.id, + ); + + expect(startPollingSpy).toHaveBeenCalledWith({ + bridgeTxMetaId: historyKey, + }); + }); + + test('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { + const { + controller, + accountAddress, + submitIntentMock, + getOrderStatusMock, + stopPollingSpy, + } = setup(); + + const orderUid = 'order-uid-2'; + + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse(accountAddress); + + await controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + + const historyKey = `intent:${orderUid}`; + + // Seed existing hashes via controller.update (state is frozen) + (controller as any).update((s: any) => { + s.txHistory[historyKey].srcTxHashes = ['0xold1']; + }); + + getOrderStatusMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.COMPLETED, + txHash: '0xnewhash', + metadata: { txHashes: ['0xold1', '0xnewhash'] }, + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: historyKey }); + + const updated = controller.state.txHistory[historyKey]; + expect(updated.status.status).toBe(StatusTypes.COMPLETE); + expect(updated.srcTxHashes).toEqual( + expect.arrayContaining(['0xold1', '0xnewhash']), + ); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + }); + + test('intent polling: maps EXPIRED to FAILED, falls back to txHash when metadata hashes empty, and skips TC update if original tx not found', async () => { + const { + controller, + accountAddress, + submitIntentMock, + getOrderStatusMock, + transactions, + stopPollingSpy, + } = setup(); + + const orderUid = 'order-uid-expired-1'; + + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse(accountAddress); + + await controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + + const historyKey = `intent:${orderUid}`; + + // Remove TC tx so update branch logs "transaction not found" + transactions.splice(0, transactions.length); + + getOrderStatusMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.EXPIRED, + txHash: '0xonlyhash', + metadata: { txHashes: [] }, // forces fallback to txHash + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: historyKey }); + + const updated = controller.state.txHistory[historyKey]; + expect(updated.status.status).toBe(StatusTypes.FAILED); + expect(updated.srcTxHashes).toEqual(expect.arrayContaining(['0xonlyhash'])); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + }); + + test('intent polling: stops polling when attempts reach MAX_ATTEMPTS', async () => { + const { + controller, + accountAddress, + submitIntentMock, + getOrderStatusMock, + stopPollingSpy, + } = setup(); + + const orderUid = 'order-uid-3'; + + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse(accountAddress); + + await controller.submitIntent({ + quoteResponse, + signature: '0xsig', + accountAddress, + }); + + const historyKey = `intent:${orderUid}`; + + // Prime attempts so next failure hits MAX_ATTEMPTS + (controller as any).update((s: any) => { + s.txHistory[historyKey].attempts = { + counter: MAX_ATTEMPTS - 1, + lastAttemptTime: 0, + }; + }); + + getOrderStatusMock.mockRejectedValue(new Error('boom')); + + await (controller as any)._executePoll({ bridgeTxMetaId: historyKey }); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + expect(controller.state.txHistory[historyKey].attempts).toEqual( + expect.objectContaining({ counter: MAX_ATTEMPTS }), + ); + }); +}); + +describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('transactionFailed subscription: marks main tx as FAILED and tracks (non-rejected)', async () => { + const { controller, messenger } = setup(); + + // Seed txHistory with a pending bridge tx + (controller as any).update((s: any) => { + s.txHistory['bridgeTxMetaId1'] = { + txMetaId: 'bridgeTxMetaId1', + originalTransactionId: 'bridgeTxMetaId1', + quote: { + srcChainId: 1, + destChainId: 10, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + }; + }); + + const failedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionFailed', + )?.[1]; + expect(typeof failedCb).toBe('function'); + + failedCb({ + transactionMeta: { + id: 'bridgeTxMetaId1', + type: TransactionType.bridge, + status: TransactionStatus.failed, + chainId: '0x1', + }, + }); + + expect(controller.state.txHistory['bridgeTxMetaId1'].status.status).toBe( + StatusTypes.FAILED, + ); + + // ensure tracking was attempted + expect((messenger.call as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + ]), + ]), + ); + }); + + test('transactionFailed subscription: maps approval tx id back to main history item', async () => { + const { controller, messenger } = setup(); + + (controller as any).update((s: any) => { + s.txHistory['mainTx'] = { + txMetaId: 'mainTx', + originalTransactionId: 'mainTx', + approvalTxId: 'approvalTx', + quote: { + srcChainId: 1, + destChainId: 10, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + }; + }); + + const failedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionFailed', + )?.[1]; + + failedCb({ + transactionMeta: { + id: 'approvalTx', + type: TransactionType.bridgeApproval, + status: TransactionStatus.failed, + chainId: '0x1', + }, + }); + + expect(controller.state.txHistory['mainTx'].status.status).toBe( + StatusTypes.FAILED, + ); + }); + + test('transactionConfirmed subscription: tracks swap Completed; starts polling on bridge confirmed', async () => { + const { controller, messenger, startPollingSpy } = setup(); + + // Seed history for bridge id so #startPollingForTxId can startPolling() + (controller as any).update((s: any) => { + s.txHistory['bridgeConfirmed1'] = { + txMetaId: 'bridgeConfirmed1', + originalTransactionId: 'bridgeConfirmed1', + quote: { + srcChainId: 1, + destChainId: 10, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + }; + }); + + const confirmedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionConfirmed', + )?.[1]; + expect(typeof confirmedCb).toBe('function'); + + // Swap -> Completed tracking + confirmedCb({ + id: 'swap1', + type: TransactionType.swap, + chainId: '0x1', + }); + + // Bridge -> startPolling + confirmedCb({ + id: 'bridgeConfirmed1', + type: TransactionType.bridge, + chainId: '0x1', + }); + + expect(startPollingSpy).toHaveBeenCalledWith({ + bridgeTxMetaId: 'bridgeConfirmed1', + }); + }); + + test('restartPollingForFailedAttempts: throws when identifier missing, and when no match found', async () => { + const { controller } = setup(); + + expect(() => controller.restartPollingForFailedAttempts({})).toThrowError( + /Either txMetaId or txHash must be provided/u, + ); + + expect(() => + controller.restartPollingForFailedAttempts({ + txMetaId: 'does-not-exist', + }), + ).toThrowError(/No bridge transaction history found/u); + }); + + test('restartPollingForFailedAttempts: resets attempts and restarts polling via txHash lookup (bridge tx only)', async () => { + const { controller, startPollingSpy } = setup(); + + (controller as any).update((s: any) => { + s.txHistory['bridgeTx1'] = { + txMetaId: 'bridgeTx1', + originalTransactionId: 'bridgeTx1', + quote: { + srcChainId: 1, + destChainId: 10, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, + attempts: { counter: 7, lastAttemptTime: 0 }, + account: '0xAccount1', + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xhash-find-me' }, + }, + }; + }); + + controller.restartPollingForFailedAttempts({ txHash: '0xhash-find-me' }); + + expect(controller.state.txHistory['bridgeTx1'].attempts).toBeUndefined(); + expect(startPollingSpy).toHaveBeenCalledWith({ + bridgeTxMetaId: 'bridgeTx1', + }); + }); + + test('restartPollingForFailedAttempts: does not restart polling for same-chain swap tx', async () => { + const { controller, startPollingSpy } = setup(); + + (controller as any).update((s: any) => { + s.txHistory['swapTx1'] = { + txMetaId: 'swapTx1', + originalTransactionId: 'swapTx1', + quote: { + srcChainId: 1, + destChainId: 1, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:1/slip44:60' }, + }, + attempts: { counter: 7, lastAttemptTime: 0 }, + account: '0xAccount1', + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xhash-samechain' }, + }, + }; + }); + + controller.restartPollingForFailedAttempts({ txMetaId: 'swapTx1' }); + + expect(controller.state.txHistory['swapTx1'].attempts).toBeUndefined(); + expect(startPollingSpy).not.toHaveBeenCalled(); + }); + + test('wipeBridgeStatus(ignoreNetwork=false): stops polling and removes only matching chain+account history', async () => { + const { controller, stopPollingSpy, accountAddress } = setup({ + selectedChainId: '0x1', + }); + + const quoteResponse = minimalBridgeQuoteResponse(accountAddress); + + // Use deprecated method to create history and start polling (so token exists in controller) + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'bridgeToWipe1' }, + statusRequest: { + srcChainId: 1, + srcTxHash: '0xsrc', + destChainId: 10, + }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + expect(controller.state.txHistory['bridgeToWipe1']).toBeDefined(); + + controller.wipeBridgeStatus({ + address: accountAddress, + ignoreNetwork: false, + }); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + expect(controller.state.txHistory['bridgeToWipe1']).toBeUndefined(); + }); + + test('EVM bridge polling: looks up srcTxHash in TC when missing, updates history, stops polling, and publishes completion', async () => { + const { + controller, + transactions, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + stopPollingSpy, + messenger, + } = setup(); + + // Create a history item with missing src tx hash + const quoteResponse = minimalBridgeQuoteResponse(accountAddress); + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'bridgePoll1' }, + statusRequest: { + srcChainId: 1, + srcTxHash: '', // force TC lookup + destChainId: 10, + }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + // Seed TC with tx meta id=bridgePoll1 and a hash for lookup + transactions.push({ + id: 'bridgePoll1', + status: TransactionStatus.confirmed, + type: TransactionType.bridge, + chainId: '0x1', + hash: '0xlooked-up-hash', + }); + + getStatusRequestWithSrcTxHashMock.mockReturnValue({ + srcChainId: 1, + srcTxHash: '0xlooked-up-hash', + destChainId: 10, + }); + + fetchBridgeTxStatusMock.mockResolvedValue({ + status: { + status: StatusTypes.COMPLETE, + srcChain: { chainId: 1, txHash: '0xlooked-up-hash' }, + destChain: { chainId: 10, txHash: '0xdesthash' }, + }, + validationFailures: [], + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'bridgePoll1' }); + + const updated = controller.state.txHistory['bridgePoll1']; + + expect(updated.status.status).toBe(StatusTypes.COMPLETE); + expect(updated.status.srcChain.txHash).toBe('0xlooked-up-hash'); + expect(updated.completionTime).toEqual(expect.any(Number)); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + + expect(messenger.publish).toHaveBeenCalledWith( + 'BridgeStatusController:destinationTransactionCompleted', + quoteResponse.quote.destAsset.assetId, + ); + }); + + test('EVM bridge polling: tracks StatusValidationFailed, increments attempts, and stops polling at MAX_ATTEMPTS', async () => { + const { + controller, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + stopPollingSpy, + } = setup(); + + const quoteResponse = minimalBridgeQuoteResponse(accountAddress); + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'bridgeValidationFail1' }, + statusRequest: { + srcChainId: 1, + srcTxHash: '0xsrc', + destChainId: 10, + }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + // Prime attempts to just below MAX so the next failure stops polling + (controller as any).update((s: any) => { + s.txHistory['bridgeValidationFail1'].attempts = { + counter: MAX_ATTEMPTS - 1, + lastAttemptTime: 0, + }; + }); + + getStatusRequestWithSrcTxHashMock.mockReturnValue({ + srcChainId: 1, + srcTxHash: '0xsrc', + destChainId: 10, + }); + + fetchBridgeTxStatusMock.mockResolvedValue({ + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + validationFailures: ['bad_status_shape'], + }); + + await (controller as any)._executePoll({ + bridgeTxMetaId: 'bridgeValidationFail1', + }); + + expect( + controller.state.txHistory['bridgeValidationFail1'].attempts, + ).toEqual(expect.objectContaining({ counter: MAX_ATTEMPTS })); + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + }); + + test('bridge polling: returns early (does not fetch) when srcTxHash cannot be determined', async () => { + const { + controller, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + } = setup(); + + const quoteResponse = minimalBridgeQuoteResponse(accountAddress); + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'bridgeNoHash1' }, + statusRequest: { + srcChainId: 1, + srcTxHash: '', // missing + destChainId: 10, + }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'bridgeNoHash1' }); + + expect(getStatusRequestWithSrcTxHashMock).not.toHaveBeenCalled(); + expect(fetchBridgeTxStatusMock).not.toHaveBeenCalled(); + }); +}); + +describe('BridgeStatusController (target uncovered branches)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('transactionFailed: returns early for intent txs (swapMetaData.isIntentTx)', () => { + const { controller, messenger } = setup(); + + // seed a history item that would otherwise be marked FAILED + (controller as any).update((s: any) => { + s.txHistory['tx1'] = { + txMetaId: 'tx1', + originalTransactionId: 'tx1', + quote: { srcChainId: 1, destChainId: 10 }, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0x' }, + }, + }; + }); + + const failedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionFailed', + )?.[1]; + + failedCb({ + transactionMeta: { + id: 'tx1', + type: TransactionType.bridge, + status: TransactionStatus.failed, + swapMetaData: { isIntentTx: true }, // <- triggers early return + }, + }); + + expect(controller.state.txHistory['tx1'].status.status).toBe( + StatusTypes.FAILED, + ); + }); + + test('constructor restartPolling: skips items when shouldSkipFetchDueToFetchFailures returns true', () => { + const accountAddress = '0xAccount1'; + const { messenger } = createMessengerHarness(accountAddress); + + const { BridgeStatusController, shouldSkipFetchDueToFetchFailuresMock } = + loadControllerWithMocks(); + + shouldSkipFetchDueToFetchFailuresMock.mockReturnValue(true); + + const startPollingProtoSpy = jest + .spyOn(BridgeStatusController.prototype as any, 'startPolling') + .mockReturnValue('tok'); + + // seed an incomplete bridge history item (PENDING + cross-chain) + const state = { + txHistory: { + init1: { + txMetaId: 'init1', + originalTransactionId: 'init1', + quote: { srcChainId: 1, destChainId: 10 }, + account: accountAddress, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + attempts: { counter: 1, lastAttemptTime: 0 }, + }, + }, + }; + + // constructor calls #restartPollingForIncompleteHistoryItems() + // shouldSkipFetchDueToFetchFailures=true => should NOT call startPolling + // eslint-disable-next-line no-new + new BridgeStatusController({ + messenger, + state, + clientId: 'extension', + fetchFn: jest.fn(), + addTransactionFn: jest.fn(), + addTransactionBatchFn: jest.fn(), + updateTransactionFn: jest.fn(), + estimateGasFeeFn: jest.fn(), + config: { customBridgeApiBaseUrl: 'http://localhost' }, + traceFn: (_r: any, fn?: any) => fn?.(), + }); + + expect(startPollingProtoSpy).not.toHaveBeenCalled(); + startPollingProtoSpy.mockRestore(); + }); + + test('startPollingForTxId: stops existing polling token when restarting same tx', () => { + const { controller, stopPollingSpy, startPollingSpy, accountAddress } = + setup(); + + // make startPolling return different tokens for the same tx + startPollingSpy.mockReturnValueOnce('tok1').mockReturnValueOnce('tok2'); + + const quoteResponse: any = { + quote: { srcChainId: 1, destChainId: 10, destAsset: { assetId: 'x' } }, + estimatedProcessingTimeInSeconds: 1, + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }; + + // first time => starts polling tok1 + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'sameTx' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + // second time => should stop tok1 and start tok2 + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'sameTx' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + expect(stopPollingSpy).toHaveBeenCalledWith('tok1'); + }); + + test('transactionConfirmed bridge: if history item missing, #startPollingForTxId returns early', () => { + const { messenger } = setup(); + + const confirmedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionConfirmed', + )?.[1]; + + // no history seeded for this id + confirmedCb({ + id: 'missingHistory', + type: TransactionType.bridge, + chainId: '0x1', + }); + }); + + test('bridge polling: returns early when shouldSkipFetchDueToFetchFailures returns true', async () => { + const { + controller, + accountAddress, + shouldSkipFetchDueToFetchFailuresMock, + fetchBridgeTxStatusMock, + } = setup(); + + const quoteResponse: any = { + quote: { srcChainId: 1, destChainId: 10, destAsset: { assetId: 'x' } }, + estimatedProcessingTimeInSeconds: 1, + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }; + + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'skipPoll1' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + shouldSkipFetchDueToFetchFailuresMock.mockReturnValueOnce(true); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'skipPoll1' }); + + expect(fetchBridgeTxStatusMock).not.toHaveBeenCalled(); + }); + + test('bridge polling: final FAILED tracks Failed event', async () => { + const { + controller, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + messenger, + } = setup(); + + const quoteResponse: any = { + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'dest' }, + srcAsset: { assetId: 'src' }, + }, + estimatedProcessingTimeInSeconds: 1, + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }; + + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'failFinal1' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + getStatusRequestWithSrcTxHashMock.mockReturnValue({ + srcChainId: 1, + srcTxHash: '0xhash', + destChainId: 10, + }); + + fetchBridgeTxStatusMock.mockResolvedValue({ + status: { + status: StatusTypes.FAILED, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + validationFailures: [], + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'failFinal1' }); + + expect((messenger.call as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Failed, + expect.any(Object), + ]), + ]), + ); + }); + + test('bridge polling: final COMPLETE with featureId set stops polling but skips tracking', async () => { + const { + controller, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + stopPollingSpy, + messenger, + } = setup(); + + const quoteResponse: any = { + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'dest' }, + srcAsset: { assetId: 'src' }, + }, + featureId: 'perps', // <- triggers featureId skip in #fetchBridgeTxStatus + estimatedProcessingTimeInSeconds: 1, + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }; + + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'perps1' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + getStatusRequestWithSrcTxHashMock.mockReturnValue({ + srcChainId: 1, + srcTxHash: '0xhash', + destChainId: 10, + }); + + fetchBridgeTxStatusMock.mockResolvedValue({ + status: { + status: StatusTypes.COMPLETE, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + validationFailures: [], + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'perps1' }); + + expect(stopPollingSpy).toHaveBeenCalled(); + + // should not track Completed because featureId is set + expect((messenger.call as jest.Mock).mock.calls).not.toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Completed, + ]), + ]), + ); + }); + + test('StatusValidationFailed event includes refresh_count from attempts', async () => { + const { + controller, + accountAddress, + fetchBridgeTxStatusMock, + getStatusRequestWithSrcTxHashMock, + messenger, + } = setup(); + + const quoteResponse: any = { + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'dest' }, + srcAsset: { assetId: 'src' }, + }, + estimatedProcessingTimeInSeconds: 1, + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }; + + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'valFail1' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + // ensure attempts exists BEFORE validation failure is tracked + (controller as any).update((s: any) => { + s.txHistory['valFail1'].attempts = { counter: 5, lastAttemptTime: 0 }; + }); + + getStatusRequestWithSrcTxHashMock.mockReturnValue({ + srcChainId: 1, + srcTxHash: '0xhash', + destChainId: 10, + }); + + fetchBridgeTxStatusMock.mockResolvedValue({ + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + validationFailures: ['bad_status'], + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'valFail1' }); + + expect((messenger.call as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.StatusValidationFailed, + expect.objectContaining({ refresh_count: 5 }), + ]), + ]), + ); + }); + + test('track event: txMetaId provided but history missing hits history-not-found branch', () => { + const { messenger } = setup(); + + const confirmedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionConfirmed', + )?.[1]; + + // swap completion tracking with an id that is not in txHistory => historyItem undefined branch + confirmedCb({ + id: 'noHistorySwap', + type: TransactionType.swap, + chainId: '0x1', + }); + }); + + test('track event: history has featureId => #trackUnifiedSwapBridgeEvent returns early (skip tracking)', () => { + const { controller, messenger } = setup(); + + (controller as any).update((s: any) => { + s.txHistory['feat1'] = { + txMetaId: 'feat1', + originalTransactionId: 'feat1', + quote: { srcChainId: 1, destChainId: 10 }, + account: '0xAccount1', + featureId: 'perps', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0x' }, + }, + }; + }); + + const failedCb = (messenger.subscribe as jest.Mock).mock.calls.find( + ([evt]) => evt === 'TransactionController:transactionFailed', + )?.[1]; + + failedCb({ + transactionMeta: { + id: 'feat1', + type: TransactionType.bridge, + status: TransactionStatus.failed, + }, + }); + + // should skip due to featureId + expect((messenger.call as jest.Mock).mock.calls).not.toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + ]), + ]), + ); + }); + + test('submitTx: throws when multichain account is undefined', async () => { + const { controller } = setup(); + + await expect( + controller.submitTx( + '0xNotKnownByHarness', + { featureId: undefined } as any, + false, + ), + ).rejects.toThrow(/undefined multichain account/u); + }); + + test('intent order PENDING maps to bridge PENDING', async () => { + const { controller, getOrderStatusMock } = setup(); + + seedIntentHistory(controller); + + getOrderStatusMock.mockResolvedValueOnce({ + id: 'order-1', + status: IntentOrderStatus.PENDING, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'intent:1' }); + + expect(controller.state.txHistory['intent:1'].status.status).toBe( + StatusTypes.PENDING, + ); + }); + + test('intent order SUBMITTED maps to bridge SUBMITTED', async () => { + const { controller, getOrderStatusMock } = setup(); + + seedIntentHistory(controller); + + getOrderStatusMock.mockResolvedValueOnce({ + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'intent:1' }); + + expect(controller.state.txHistory['intent:1'].status.status).toBe( + StatusTypes.SUBMITTED, + ); + }); + + test('unknown intent order status maps to bridge UNKNOWN', async () => { + const { controller, getOrderStatusMock } = setup(); + + seedIntentHistory(controller); + + getOrderStatusMock.mockResolvedValueOnce({ + id: 'order-1', + status: 'SOME_NEW_STATUS' as any, // force UNKNOWN branch + txHash: undefined, + metadata: { txHashes: [] }, + }); + + await (controller as any)._executePoll({ bridgeTxMetaId: 'intent:1' }); + + expect(controller.state.txHistory['intent:1'].status.status).toBe( + StatusTypes.UNKNOWN, + ); + }); +}); 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 6b8cae77460..d9695b5dfe9 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -348,6 +348,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, @@ -371,6 +372,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, @@ -397,6 +399,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, batchId, quote: getMockQuote({ srcChainId, destChainId }), startTime: 1729964825189, @@ -433,6 +436,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, @@ -468,6 +472,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, @@ -502,6 +507,7 @@ const MockTxHistory = { } = {}): Record => ({ [txMetaId]: { txMetaId, + originalTransactionId: txMetaId, batchId, featureId: undefined, quote: getMockQuote({ srcChainId, destChainId }), diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 844da41cbda..ea507e0ab44 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1,10 +1,12 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { StateMetadata } from '@metamask/base-controller'; import type { + ChainId, QuoteMetadata, RequiredEventContextFromClient, TxData, QuoteResponse, + Intent, Trade, } from '@metamask/bridge-controller'; import { @@ -44,6 +46,7 @@ import { REFRESH_INTERVAL_MS, TraceName, } from './constants'; +import { IntentApiImpl } from './utils/intent-api'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, @@ -79,6 +82,7 @@ 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 @@ -188,6 +192,10 @@ export class BridgeStatusController extends StaticIntervalPollingController { const { type, status, id } = transactionMeta; + if ( type && [ @@ -255,7 +264,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #markTxAsFailed = ({ id }: TransactionMeta): void => { const txHistoryKey = this.state.txHistory[id] ? id : Object.keys(this.state.txHistory).find( @@ -269,7 +278,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + resetState = (): void => { this.update((state) => { state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; }); @@ -281,7 +290,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + }): void => { // Wipe all networks for this address if (ignoreNetwork) { this.update((state) => { @@ -312,7 +321,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + }): void => { const { txMetaId, txHash } = identifier; if (!txMetaId && !txHash) { @@ -385,7 +394,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #restartPollingForIncompleteHistoryItems = (): void => { // Check for historyItems that do not have a status of complete and restart polling const { txHistory } = this.state; const historyItems = Object.values(txHistory); @@ -427,7 +436,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + ): void => { const { bridgeTxMeta, statusRequest, @@ -445,6 +454,10 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #startPollingForTxId = (txId: string): void => { // If we are already polling for this tx, stop polling for it before restarting const existingPollingToken = this.#pollingTokensByTxMetaId[txId]; if (existingPollingToken) { @@ -493,9 +506,9 @@ export class BridgeStatusController extends StaticIntervalPollingController { + ): void => { const { bridgeTxMeta } = txHistoryMeta; this.#addTxToHistory(txHistoryMeta); @@ -523,11 +536,17 @@ export class BridgeStatusController extends StaticIntervalPollingController { + _executePoll = async ( + pollingInput: BridgeStatusPollingInput, + ): Promise => { await this.#fetchBridgeTxStatus(pollingInput); }; - #getMultichainSelectedAccount(accountAddress: string) { + #getMultichainSelectedAccount( + accountAddress: string, + ): + | AccountsControllerState['internalAccounts']['accounts'][string] + | undefined { return this.messenger.call( 'AccountsController:getAccountByAddress', accountAddress, @@ -543,7 +562,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #handleFetchFailure = (bridgeTxMetaId: string): void => { const { attempts } = this.state.txHistory[bridgeTxMetaId]; const newAttempts = attempts @@ -571,9 +590,15 @@ export class BridgeStatusController extends StaticIntervalPollingController { + }: FetchBridgeTxStatusArgs): Promise => { const { txHistory } = this.state; + // Intent-based items: poll intent provider instead of Bridge API + if (bridgeTxMetaId.startsWith('intent:')) { + await this.#fetchIntentOrderStatus({ bridgeTxMetaId }); + return; + } + if ( shouldSkipFetchDueToFetchFailures(txHistory[bridgeTxMetaId]?.attempts) ) { @@ -667,12 +692,222 @@ 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, + ].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 = this.#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 @@ -693,7 +928,10 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #updateSrcTxHash = ( + bridgeTxMetaId: string, + srcTxHash: string, + ): void => { const { txHistory } = this.state; if (txHistory[bridgeTxMetaId].status.srcChain.txHash) { return; @@ -709,7 +947,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + ): void => { const sourceTxMetaIdsToDelete = Object.keys(this.state.txHistory).filter( (txMetaId) => { const bridgeHistoryItem = this.state.txHistory[txMetaId]; @@ -767,7 +1005,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], - ) => { + ): Promise => { if (!selectedAccount.metadata?.snap?.id) { throw new Error( 'Failed to submit cross-chain swap transaction: undefined snap id', @@ -823,6 +1061,54 @@ 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 || + // Some environments move directly to finalized + (TransactionStatus as unknown as { finalized: string }).finalized === + meta.status + ) { + 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 */ + }; + readonly #handleApprovalTx = async ( isBridgeTx: boolean, srcChainId: QuoteResponse['quote']['srcChainId'], @@ -831,7 +1117,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { if (approval) { - const approveTx = async () => { + const approveTx = async (): Promise => { await this.#handleUSDTAllowanceReset(resetApproval); const approvalTxMeta = await this.#handleEvmTransaction({ @@ -910,13 +1196,20 @@ export class BridgeStatusController extends StaticIntervalPollingController[0] = { - ...trade, + ...tradeWithoutGasLimit, chainId: hexChainId, - gasLimit: trade.gasLimit?.toString(), - gas: trade.gasLimit?.toString(), + // Only add gasLimit and gas if they're valid (not undefined/null/zero) + ...(tradeGasLimit && + tradeGasLimit !== 0 && { + gasLimit: tradeGasLimit.toString(), + gas: tradeGasLimit.toString(), + }), }; const transactionParamsWithMaxGas: TransactionParams = { ...transactionParams, @@ -936,7 +1229,9 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #handleUSDTAllowanceReset = async ( + resetApproval?: TxData, + ): Promise => { if (resetApproval) { await this.#handleEvmTransaction({ transactionType: TransactionType.bridgeApproval, @@ -950,16 +1245,18 @@ export class BridgeStatusController extends StaticIntervalPollingController { - const maxGasLimit = toHex(transactionParams.gas ?? 0); - + ): Promise<{ + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; + gas?: Hex; + }> => { // If txFee is provided (gasIncluded case), use the quote's gas fees // Convert to hex since txFee values from the quote are decimal strings if (txFee) { return { maxFeePerGas: toHex(txFee.maxFeePerGas ?? 0), maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas ?? 0), - gas: maxGasLimit, + gas: transactionParams.gas ? toHex(transactionParams.gas) : undefined, }; } @@ -979,7 +1276,7 @@ export class BridgeStatusController extends StaticIntervalPollingController[0], 'messenger' | 'estimateGasFeeFn' >, - ) => { + ): Promise<{ + approvalMeta?: TransactionMeta; + tradeMeta: TransactionMeta; + }> => { const transactionParams = await getAddTransactionBatchParams({ messenger: this.messenger, estimateGasFeeFn: this.#estimateGasFeeFn, @@ -1284,6 +1584,294 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; + signature: string; + accountAddress: string; + }): Promise> => { + const { quoteResponse, signature, accountAddress } = params; + + // Build pre-confirmation properties for error tracking parity with submitTx + const account = this.#getMultichainSelectedAccount(accountAddress); + const isHardwareAccount = Boolean(account) && isHardwareWallet(account); + const preConfirmationProperties = getPreConfirmationPropertiesFromQuote( + quoteResponse, + false, + isHardwareAccount, + ); + + try { + const { intent } = (quoteResponse as QuoteResponse & { intent?: Intent }) + .quote; + + if (!intent) { + throw new Error('submitIntent: missing intent data'); + } + + // If backend provided an approval tx for this intent quote, submit it first (on-chain), + // then proceed with off-chain intent submission. + let approvalTxId: string | undefined; + if (quoteResponse.approval) { + const isBridgeTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + + // Handle approval silently for better UX in intent flows + const approvalTxMeta = await this.#handleApprovalTx( + isBridgeTx, + quoteResponse.quote.srcChainId, + quoteResponse.approval && isEvmTxData(quoteResponse.approval) + ? quoteResponse.approval + : undefined, + quoteResponse.resetApproval, + /* requireApproval */ false, + ); + approvalTxId = approvalTxMeta?.id; + + // Optionally wait for approval confirmation with timeout and graceful fallback + // Intent order can be created before allowance is mined, but waiting helps avoid MEV issues + if (approvalTxId) { + try { + // Wait with a shorter timeout and continue if it fails + await this.#waitForTxConfirmation(approvalTxId, { + timeoutMs: 30_000, // 30 seconds instead of 5 minutes + pollMs: 3_000, // Poll less frequently to avoid rate limits + }); + } catch (error) { + // Log but don't throw - continue with intent order submission + console.warn( + 'Approval confirmation failed, continuing with intent submission:', + error, + ); + } + } + } + + // Create intent quote from bridge quote response + const intentQuote = this.#convertBridgeQuoteToIntentQuote( + quoteResponse, + intent, + ); + + const chainId = quoteResponse.quote.srcChainId; + + const submissionParams = { + srcChainId: chainId.toString(), + quoteId: intentQuote.id, + signature, + order: intentQuote.metadata.order, + userAddress: accountAddress, + aggregatorId: 'cowswap', + }; + const intentApi = new IntentApiImpl( + this.#config.customBridgeApiBaseUrl, + this.#fetchFn, + ); + const intentOrder = (await intentApi.submitIntent( + submissionParams, + this.#clientId, + )) as IntentOrder; + + const orderUid = intentOrder.id; + + // Determine transaction type: swap for same-chain, bridge for cross-chain + const isCrossChainTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + const transactionType = isCrossChainTx + ? TransactionType.bridge + : TransactionType.swap; + + // Create actual transaction in Transaction Controller first + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + formatChainIdToHex(chainId), + ); + + // This is a synthetic transaction whose purpose is to be able + // to track the order status via the history + const intentTransactionParams = { + chainId: formatChainIdToHex(chainId), + from: accountAddress, + to: + intent.settlementContract ?? + '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', // Default settlement contract + data: `0x${orderUid.slice(-8)}`, // Use last 8 chars of orderUid to make each transaction unique + value: '0x0', + gas: '0x5208', // Minimal gas for display purposes + gasPrice: '0x3b9aca00', // 1 Gwei - will be converted to EIP-1559 fees if network supports it + }; + + const { transactionMeta: txMetaPromise } = await this.#addTransactionFn( + intentTransactionParams, + { + origin: 'metamask', + actionId: generateActionId(), + requireApproval: false, + networkClientId, + type: transactionType, + skipInitialGasEstimate: true, + swaps: { + meta: { + // Add token symbols from quoteResponse for proper display + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + destinationTokenSymbol: quoteResponse.quote.destAsset.symbol, + sourceTokenAmount: quoteResponse.quote.srcTokenAmount, + destinationTokenAmount: quoteResponse.quote.destTokenAmount, + sourceTokenDecimals: quoteResponse.quote.srcAsset.decimals, + destinationTokenDecimals: quoteResponse.quote.destAsset.decimals, + sourceTokenAddress: quoteResponse.quote.srcAsset.address, + destinationTokenAddress: quoteResponse.quote.destAsset.address, + swapTokenValue: quoteResponse.sentAmount.amount, + approvalTxId, + swapMetaData: { + isIntentTx: true, + orderUid, + intentType: isCrossChainTx ? 'bridge' : 'swap', + }, + }, + }, + }, + ); + + const intentTxMeta = txMetaPromise; + + // Map intent order status to TransactionController status + const initialTransactionStatus = + this.#mapIntentOrderStatusToTransactionStatus(intentOrder.status); + + // Update transaction with proper initial status based on intent order + const statusUpdatedTxMeta = { + ...intentTxMeta, + status: initialTransactionStatus, + }; + + // Update with actual transaction metadata + const syntheticMeta = { + ...statusUpdatedTxMeta, + isIntentTx: true, + orderUid, + intentType: isCrossChainTx ? 'bridge' : 'swap', + } as unknown as TransactionMeta; + + // Record in bridge history with actual transaction metadata + try { + // Use 'intent:' prefix for intent transactions + const bridgeHistoryKey = `intent:${orderUid}`; + + // Create a bridge transaction metadata that includes the original txId + const bridgeTxMetaForHistory = { + ...syntheticMeta, + id: bridgeHistoryKey, // Use intent: prefix for bridge history key + originalTransactionId: syntheticMeta.id, // Keep original txId for TransactionController updates + } as TransactionMeta; + + this.#addTxToHistory({ + accountAddress, + bridgeTxMeta: bridgeTxMetaForHistory, + statusRequest: { + ...getStatusRequestParams(quoteResponse), + srcTxHash: syntheticMeta.hash ?? '', + }, + quoteResponse, + slippagePercentage: 0, + isStxEnabled: false, + approvalTxId, + }); + + // Start polling using the intent: prefixed key to route to intent manager + this.#startPollingForTxId(bridgeHistoryKey); + } catch (error) { + console.error( + '📝 [submitIntent] Failed to add to bridge history', + error, + ); + // non-fatal but log the error + } + return syntheticMeta; + } catch (error) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + undefined, + { + error_message: (error as Error)?.message, + ...preConfirmationProperties, + }, + ); + + throw error; + } + }; + + #convertBridgeQuoteToIntentQuote( + quoteResponse: QuoteResponse & QuoteMetadata, + intent: Intent, + ): { + id: string; + provider: string; + srcAmount: string; + destAmount: string; + estimatedGas: string; + estimatedTime: number; + priceImpact: number; + fees: unknown[]; + validUntil: number; + metadata: { + order: unknown; + settlementContract: string; + chainId: ChainId; + bridgeQuote: QuoteResponse & QuoteMetadata; + }; + } { + return { + id: `bridge-${Date.now()}`, + provider: intent.protocol, + srcAmount: quoteResponse.quote.srcTokenAmount, + destAmount: quoteResponse.quote.destTokenAmount, + estimatedGas: '21000', + estimatedTime: 300, // 5 minutes + priceImpact: 0, + fees: [], + validUntil: Date.now() + 300000, // 5 minutes from now + metadata: { + order: intent.order, + settlementContract: intent.settlementContract ?? '', + chainId: quoteResponse.quote.srcChainId, + bridgeQuote: quoteResponse, + }, + }; + } + + #mapIntentOrderStatusToTransactionStatus( + intentStatus: IntentOrderStatus, + ): TransactionStatus { + switch (intentStatus) { + case IntentOrderStatus.PENDING: + case IntentOrderStatus.SUBMITTED: + return TransactionStatus.submitted; + case IntentOrderStatus.CONFIRMED: + case IntentOrderStatus.COMPLETED: + return TransactionStatus.confirmed; + case IntentOrderStatus.FAILED: + case IntentOrderStatus.EXPIRED: + return TransactionStatus.failed; + default: + return TransactionStatus.submitted; + } + } + /** * Tracks post-submission events for a cross-chain swap based on the history item * @@ -1292,16 +1880,19 @@ export class BridgeStatusController extends StaticIntervalPollingController( - eventName: T, + eventName: EventName, txMetaId?: string, - eventProperties?: Pick[T], - ) => { + eventProperties?: Pick< + RequiredEventContextFromClient, + EventName + >[EventName], + ): void => { const baseProperties = { action_type: MetricsActionType.SWAPBRIDGE_V1, ...(eventProperties ?? {}), @@ -1319,6 +1910,7 @@ export class BridgeStatusController extends StaticIntervalPollingController id === txMetaId); + const txMeta = transactions?.find( + (tx: TransactionMeta) => tx.id === txMetaId, + ); const approvalTxMeta = transactions?.find( - ({ id }) => id === historyItem.approvalTxId, + (tx: TransactionMeta) => tx.id === historyItem.approvalTxId, ); const requiredEventProperties = { diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index ce49f81171e..e12bb4eab5e 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -31,7 +31,7 @@ import type { import type { CaipAssetType } from '@metamask/utils'; import type { BridgeStatusController } from './bridge-status-controller'; -import type { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; import type { StatusResponseSchema } from './utils/validators'; // All fields need to be types not interfaces, same with their children fields @@ -45,8 +45,7 @@ export enum BridgeClientId { export type FetchFunction = ( input: RequestInfo | URL, init?: RequestInit, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => Promise; +) => Promise; /** * These fields are specific to Solana transactions and can likely be infered from TransactionMeta @@ -103,9 +102,17 @@ export type RefuelStatusResponse = object & StatusResponse; export type BridgeHistoryItem = { txMetaId: string; // Need this to handle STX that might not have a txHash immediately + originalTransactionId?: string; // Keep original transaction ID for intent transactions batchId?: string; quote: Quote; status: StatusResponse; + /** + * For intent-based orders (e.g., CoW) that can be partially filled across + * multiple on-chain settlements, we keep all discovered source tx hashes here. + * The canonical status.srcChain.txHash continues to hold the latest known hash + * for backward compatibility with consumers expecting a single hash. + */ + srcTxHashes?: string[]; startTime?: number; // timestamp in ms estimatedProcessingTimeInSeconds: number; slippagePercentage: number; @@ -140,13 +147,14 @@ export type BridgeHistoryItem = { }; export enum BridgeStatusAction { - START_POLLING_FOR_BRIDGE_TX_STATUS = 'startPollingForBridgeTxStatus', - WIPE_BRIDGE_STATUS = 'wipeBridgeStatus', - GET_STATE = 'getState', - RESET_STATE = 'resetState', - SUBMIT_TX = 'submitTx', - RESTART_POLLING_FOR_FAILED_ATTEMPTS = 'restartPollingForFailedAttempts', - GET_BRIDGE_HISTORY_ITEM_BY_TX_META_ID = 'getBridgeHistoryItemByTxMetaId', + StartPollingForBridgeTxStatus = 'StartPollingForBridgeTxStatus', + WipeBridgeStatus = 'WipeBridgeStatus', + GetState = 'GetState', + ResetState = 'ResetState', + SubmitTx = 'SubmitTx', + SubmitIntent = 'SubmitIntent', + RestartPollingForFailedAttempts = 'RestartPollingForFailedAttempts', + GetBridgeHistoryItemByTxMetaId = 'GetBridgeHistoryItemByTxMetaId', } export type TokenAmountValuesSerialized = { @@ -232,22 +240,25 @@ export type BridgeStatusControllerGetStateAction = ControllerGetStateAction< // Maps to BridgeController function names export type BridgeStatusControllerStartPollingForBridgeTxStatusAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'startPollingForBridgeTxStatus'>; export type BridgeStatusControllerWipeBridgeStatusAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'wipeBridgeStatus'>; export type BridgeStatusControllerResetStateAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'resetState'>; export type BridgeStatusControllerSubmitTxAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'submitTx'>; + +export type BridgeStatusControllerSubmitIntentAction = + BridgeStatusControllerAction<'submitIntent'>; export type BridgeStatusControllerRestartPollingForFailedAttemptsAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'restartPollingForFailedAttempts'>; export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = - BridgeStatusControllerAction; + BridgeStatusControllerAction<'getBridgeHistoryItemByTxMetaId'>; export type BridgeStatusControllerActions = | BridgeStatusControllerStartPollingForBridgeTxStatusAction @@ -255,6 +266,7 @@ export type BridgeStatusControllerActions = | BridgeStatusControllerResetStateAction | BridgeStatusControllerGetStateAction | BridgeStatusControllerSubmitTxAction + | BridgeStatusControllerSubmitIntentAction | BridgeStatusControllerRestartPollingForFailedAttemptsAction | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction; diff --git a/packages/bridge-status-controller/src/utils/intent-api.test.ts b/packages/bridge-status-controller/src/utils/intent-api.test.ts new file mode 100644 index 00000000000..c17977f8f71 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/intent-api.test.ts @@ -0,0 +1,141 @@ +// intent-api.test.ts +import { describe, it, expect, jest } from '@jest/globals'; +import { IntentApiImpl, type IntentSubmissionParams } from './intent-api'; +import type { FetchFunction } from '../types'; +import { IntentOrderStatus } from './validators'; + +describe('IntentApiImpl', () => { + const baseUrl = 'https://example.com/api'; + const clientId = 'client-id'; + + const makeParams = (): IntentSubmissionParams => ({ + srcChainId: '1', + quoteId: 'quote-123', + signature: '0xsig', + order: { some: 'payload' }, + userAddress: '0xabc', + aggregatorId: 'agg-1', + }); + + const makeFetchMock = () => + jest.fn, Parameters>(); + + const validIntentOrderResponse = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + + it('submitIntent calls POST /submitOrder with JSON body and returns response', async () => { + const fetchFn = makeFetchMock().mockResolvedValue(validIntentOrderResponse); + const api = new IntentApiImpl(baseUrl, fetchFn); + + const params = makeParams(); + const result = await api.submitIntent(params, clientId); + + expect(result).toEqual(validIntentOrderResponse); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn).toHaveBeenCalledWith(`${baseUrl}/submitOrder`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Id': clientId, + }, + body: JSON.stringify(params), + }); + }); + + it('submitIntent rethrows Errors with a prefixed message', async () => { + const fetchFn = makeFetchMock().mockRejectedValue(new Error('boom')); + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( + 'Failed to submit intent: boom', + ); + }); + + it('submitIntent throws generic error when rejection is not an Error', async () => { + const fetchFn = makeFetchMock().mockRejectedValue('boom'); + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( + 'Failed to submit intent', + ); + }); + + it('getOrderStatus calls GET /getOrderStatus with encoded query params and returns response', async () => { + const fetchFn = makeFetchMock().mockResolvedValue(validIntentOrderResponse); + const api = new IntentApiImpl(baseUrl, fetchFn); + + const orderId = 'order-1'; + const aggregatorId = 'My Agg/With Spaces'; + const srcChainId = '10'; + + const result = await api.getOrderStatus( + orderId, + aggregatorId, + srcChainId, + clientId, + ); + + expect(result).toEqual(validIntentOrderResponse); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const expectedEndpoint = + `${baseUrl}/getOrderStatus` + + `?orderId=${orderId}` + + `&aggregatorId=${encodeURIComponent(aggregatorId)}` + + `&srcChainId=${srcChainId}`; + + expect(fetchFn).toHaveBeenCalledWith(expectedEndpoint, { + method: 'GET', + headers: { + 'X-Client-Id': clientId, + }, + }); + }); + + it('getOrderStatus rethrows Errors with a prefixed message', async () => { + const fetchFn = makeFetchMock().mockRejectedValue(new Error('nope')); + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect(api.getOrderStatus('o', 'a', '1', clientId)).rejects.toThrow( + 'Failed to get order status: nope', + ); + }); + + it('getOrderStatus throws generic error when rejection is not an Error', async () => { + const fetchFn = makeFetchMock().mockRejectedValue({ message: 'nope' }); + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect(api.getOrderStatus('o', 'a', '1', clientId)).rejects.toThrow( + 'Failed to get order status', + ); + }); + + it('submitIntent throws when response fails validation', async () => { + const fetchFn = makeFetchMock().mockResolvedValue({ + foo: 'bar', // invalid IntentOrder shape + } as any); + + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( + 'Failed to submit intent: Invalid submitOrder response', + ); + }); + + it('getOrderStatus throws when response fails validation', async () => { + const fetchFn = makeFetchMock().mockResolvedValue({ + foo: 'bar', // invalid IntentOrder shape + } as any); + + const api = new IntentApiImpl(baseUrl, fetchFn); + + await expect( + api.getOrderStatus('order-1', 'agg', '1', clientId), + ).rejects.toThrow( + 'Failed to get order status: Invalid submitOrder response', + ); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts new file mode 100644 index 00000000000..905f4e1b026 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -0,0 +1,89 @@ +import { IntentOrder, validateIntentOrderResponse } from './validators'; +import type { FetchFunction } from '../types'; + +export type IntentSubmissionParams = { + srcChainId: string; + quoteId: string; + signature: string; + order: unknown; + userAddress: string; + aggregatorId: string; +}; + +export const getClientIdHeader = (clientId: string) => ({ + 'X-Client-Id': clientId, +}); + +export type IntentApi = { + submitIntent( + params: IntentSubmissionParams, + clientId: string, + ): Promise; + getOrderStatus( + orderId: string, + aggregatorId: string, + srcChainId: string, + clientId: string, + ): Promise; +}; + +export class IntentApiImpl implements IntentApi { + readonly #baseUrl: string; + + readonly #fetchFn: FetchFunction; + + constructor(baseUrl: string, fetchFn: FetchFunction) { + this.#baseUrl = baseUrl; + this.#fetchFn = fetchFn; + } + + async submitIntent( + params: IntentSubmissionParams, + clientId: string, + ): Promise { + const endpoint = `${this.#baseUrl}/submitOrder`; + try { + const response = await this.#fetchFn(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientIdHeader(clientId), + }, + body: JSON.stringify(params), + }); + if (!validateIntentOrderResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to submit intent: ${error.message}`); + } + throw new Error('Failed to submit intent'); + } + } + + async getOrderStatus( + orderId: string, + aggregatorId: string, + srcChainId: string, + clientId: string, + ): Promise { + const endpoint = `${this.#baseUrl}/getOrderStatus?orderId=${orderId}&aggregatorId=${encodeURIComponent(aggregatorId)}&srcChainId=${srcChainId}`; + try { + const response = await this.#fetchFn(endpoint, { + method: 'GET', + headers: getClientIdHeader(clientId), + }); + if (!validateIntentOrderResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to get order status: ${error.message}`); + } + throw new Error('Failed to get order status'); + } + } +} diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts~ b/packages/bridge-status-controller/src/utils/intent-api.ts~ new file mode 100644 index 00000000000..dc91f75f561 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/intent-api.ts~ @@ -0,0 +1,71 @@ +import { + IntentOrder, + validateIntentOrderResponse, +} from '../../bridge-controller/src/utils/validators'; +import type { FetchFunction } from './types'; + +export type IntentSubmissionParams = { + srcChainId: string; + quoteId: string; + signature: string; + order: unknown; + userAddress: string; + aggregatorId: string; +}; + +export type IntentApi = { + submitIntent(params: IntentSubmissionParams): Promise; +}; + +export class IntentApiImpl implements IntentApi { + readonly #baseUrl: string; + + readonly #fetchFn: FetchFunction; + + constructor(baseUrl: string, fetchFn: FetchFunction) { + this.#baseUrl = baseUrl; + this.#fetchFn = fetchFn; + } + + async submitIntent(params: IntentSubmissionParams): Promise { + const endpoint = `${this.#baseUrl}/submitOrder`; + try { + const response = await this.#fetchFn(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + if (!validateIntentOrderResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to submit intent: ${error.message}`); + } + throw new Error('Failed to submit intent'); + } + } + + async getOrderStatus( + orderId: string, + aggregatorId: string, + srcChainId: string, + ): Promise { + const endpoint = `${this.#baseUrl}/getOrderStatus?orderId=${orderId}&aggregatorId=${encodeURIComponent(aggregatorId)}&srcChainId=${srcChainId}`; + try { + const response = await this.#fetchFn(endpoint, { + method: 'GET', + }); + if (!validateIntentOrderResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to submit intent: ${error.message}`); + } + throw new Error('Failed to submit intent'); + } + } +} diff --git a/packages/bridge-status-controller/src/utils/validators.ts b/packages/bridge-status-controller/src/utils/validators.ts index 123456bdf18..e0054f3a0fc 100644 --- a/packages/bridge-status-controller/src/utils/validators.ts +++ b/packages/bridge-status-controller/src/utils/validators.ts @@ -9,6 +9,8 @@ import { union, type, assert, + array, + is, } from '@metamask/superstruct'; const ChainIdSchema = number(); @@ -57,3 +59,37 @@ export const validateBridgeStatusResponse = ( assert(data, StatusResponseSchema); return true; }; + +export enum IntentOrderStatus { + PENDING = 'pending', + SUBMITTED = 'submitted', + CONFIRMED = 'confirmed', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +export type IntentOrder = { + id: string; + status: IntentOrderStatus; + txHash?: string; + metadata: { + txHashes?: string[] | string; + }; +}; + +export const IntentOrderResponseSchema = type({ + id: string(), + status: enums(Object.values(IntentOrderStatus)), + txHash: optional(string()), + metadata: type({ + txHashes: optional(union([array(string()), string()])), + }), +}); + +export const validateIntentOrderResponse = ( + data: unknown, +): data is Infer => { + return is(data, IntentOrderResponseSchema); +}; diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index a3979be382f..6f23a0f3964 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add intent based transaction support ([#6547](https://github.com/MetaMask/core/pull/6547)) ### Changed - Bump `@metamask/remote-feature-flag-controller` from `^3.1.0` to `^4.0.0` ([#7546](https://github.com/MetaMask/core/pull/7546)) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 3df7f798dd2..c6950de2761 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1390,7 +1390,6 @@ export class TransactionController extends BaseController< updateTransaction(addedTransactionMeta); } - // eslint-disable-next-line no-negated-condition if (!skipInitialGasEstimate) { await this.#trace( { name: 'Estimate Gas Properties', parentContext: traceContext }, @@ -1399,6 +1398,21 @@ export class TransactionController extends BaseController< traceContext: context, }), ); + } else if ( + isEIP1559Compatible && + addedTransactionMeta.txParams.gasPrice && + !addedTransactionMeta.txParams.maxFeePerGas + ) { + // Convert legacy gasPrice to EIP-1559 fees for intent transactions on EIP-1559 networks + addedTransactionMeta.txParams.maxFeePerGas = + addedTransactionMeta.txParams.gasPrice; + addedTransactionMeta.txParams.maxPriorityFeePerGas = + addedTransactionMeta.txParams.gasPrice; + addedTransactionMeta.txParams.type = TransactionEnvelopeType.feeMarket; + delete addedTransactionMeta.txParams.gasPrice; // Remove legacy gas price + } else if (!isEIP1559Compatible && addedTransactionMeta.txParams.gasPrice) { + // Ensure legacy type for non-EIP-1559 networks + addedTransactionMeta.txParams.type = TransactionEnvelopeType.legacy; } else { const newTransactionMeta = cloneDeep(addedTransactionMeta); @@ -3113,6 +3127,45 @@ export class TransactionController extends BaseController< } } + // For intent-based transactions (e.g., CoW intents) that are not meant to be + // published on-chain by the TransactionController, skip the approve/publish flow. + // These are tracked externally and should not be signed or sent. + const isIntentTransaction = Boolean( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.#getTransaction(transactionId) as any)?.swapMetaData + ?.isIntentTx === true, + ); + + if (requireApproval === false && isIntentTransaction) { + const submittedTxMeta = this.#updateTransactionInternal( + { + transactionId, + skipValidation: true, + }, + (draftTxMeta) => { + draftTxMeta.status = TransactionStatus.submitted; + draftTxMeta.submittedTime = new Date().getTime(); + }, + ); + + this.messenger.publish(`${controllerName}:transactionSubmitted`, { + transactionMeta: submittedTxMeta, + }); + + this.messenger.publish( + `${controllerName}:transactionFinished`, + submittedTxMeta, + ); + this.#internalEvents.emit( + `${transactionId}:finished`, + submittedTxMeta, + ); + + // Short-circuit normal flow; result callbacks will be handled by the + // finished promise below. + return ApprovalState.Approved; + } + const { isCompleted: isTxCompleted } = this.#isTransactionCompleted(transactionId); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index fb1673eaeea..08f8ee97fa9 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -698,7 +698,9 @@ export class PendingTransactionTracker { (tx) => tx.status === TransactionStatus.submitted && !tx.verifiedOnBlockchain && - !tx.isUserOperation, + !tx.isUserOperation && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !(tx as any).swapMetaData?.isIntentTx, // Exclude intent transactions from pending tracking ); }