From 647543ed2cb5d134f3f00b496cb0ec4b6a520073 Mon Sep 17 00:00:00 2001 From: Kevin Le Jeune Date: Fri, 12 Dec 2025 10:55:16 +0100 Subject: [PATCH] feat: tracking headers --- src/SmartTransactionsController.test.ts | 212 +++++++++++++++++++++++- src/SmartTransactionsController.ts | 16 +- src/index.ts | 3 + src/types.ts | 35 ++++ 4 files changed, 264 insertions(+), 2 deletions(-) diff --git a/src/SmartTransactionsController.test.ts b/src/SmartTransactionsController.test.ts index 18e7068..52e1eab 100644 --- a/src/SmartTransactionsController.test.ts +++ b/src/SmartTransactionsController.test.ts @@ -42,7 +42,12 @@ import { type SmartTransactionsControllerMessenger, } from './SmartTransactionsController'; import type { SmartTransaction, UnsignedTransaction, Hex } from './types'; -import { SmartTransactionStatuses, ClientId } from './types'; +import { + SmartTransactionStatuses, + ClientId, + TransactionFeature, + TransactionKind, +} from './types'; import * as utils from './utils'; type AllActions = MessengerActions; @@ -979,6 +984,211 @@ describe('SmartTransactionsController', () => { ); }); }); + + describe('trackingHeaders', () => { + it('sends X-Transaction-Feature and X-Transaction-Kind headers when trackingHeaders is provided', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + trackingHeaders: { + feature: TransactionFeature.Send, + kind: TransactionKind.STX, + }, + }); + + expect(handleFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/submitTransactions'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Client-Id': ClientId.Mobile, + 'X-Transaction-Feature': 'Send', + 'X-Transaction-Kind': 'STX', + }), + }), + ); + + handleFetchSpy.mockRestore(); + }); + }); + + it('sends only X-Transaction-Feature header when only feature is provided', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + trackingHeaders: { + feature: TransactionFeature.Swap, + }, + }); + + expect(handleFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/submitTransactions'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Client-Id': ClientId.Mobile, + 'X-Transaction-Feature': 'Swap', + }), + }), + ); + + // Ensure X-Transaction-Kind is NOT in the headers + const [, options] = handleFetchSpy.mock.calls[0]; + expect(options?.headers).not.toHaveProperty('X-Transaction-Kind'); + + handleFetchSpy.mockRestore(); + }); + }); + + it('sends only X-Transaction-Kind header when only kind is provided', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + trackingHeaders: { + kind: TransactionKind.GaslessSendBundle, + }, + }); + + expect(handleFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/submitTransactions'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Client-Id': ClientId.Mobile, + 'X-Transaction-Kind': 'GaslessSendBundle', + }), + }), + ); + + // Ensure X-Transaction-Feature is NOT in the headers + const [, options] = handleFetchSpy.mock.calls[0]; + expect(options?.headers).not.toHaveProperty('X-Transaction-Feature'); + + handleFetchSpy.mockRestore(); + }); + }); + + it('does not include tracking headers when trackingHeaders is not provided', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + // No trackingHeaders provided + }); + + const [, options] = handleFetchSpy.mock.calls[0]; + expect(options?.headers).not.toHaveProperty('X-Transaction-Feature'); + expect(options?.headers).not.toHaveProperty('X-Transaction-Kind'); + + handleFetchSpy.mockRestore(); + }); + }); + + it('supports all TransactionFeature values', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + // Test each feature value + const features = Object.values(TransactionFeature); + for (const feature of features) { + handleFetchSpy.mockClear(); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + trackingHeaders: { feature }, + }); + + expect(handleFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/submitTransactions'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Transaction-Feature': feature, + }), + }), + ); + } + + handleFetchSpy.mockRestore(); + }); + }); + + it('supports all TransactionKind values', async () => { + await withController(async ({ controller }) => { + const signedTransaction = createSignedTransaction(); + const submitTransactionsApiResponse = + createSubmitTransactionsApiResponse(); + + const handleFetchSpy = jest + .spyOn(utils, 'handleFetch') + .mockResolvedValue(submitTransactionsApiResponse); + + // Test each kind value + const kinds = Object.values(TransactionKind); + for (const kind of kinds) { + handleFetchSpy.mockClear(); + + await controller.submitSignedTransactions({ + signedTransactions: [signedTransaction], + txParams: createTxParams(), + trackingHeaders: { kind }, + }); + + expect(handleFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/submitTransactions'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Transaction-Kind': kind, + }), + }), + ); + } + + handleFetchSpy.mockRestore(); + }); + }); + }); }); describe('fetchSmartTransactionsStatus', () => { diff --git a/src/SmartTransactionsController.ts b/src/SmartTransactionsController.ts index 738e380..96ee37d 100644 --- a/src/SmartTransactionsController.ts +++ b/src/SmartTransactionsController.ts @@ -47,6 +47,7 @@ import type { MetaMetricsProps, FeatureFlags, ClientId, + TransactionTrackingHeaders, } from './types'; import { APIType, SmartTransactionStatuses } from './types'; import { @@ -240,12 +241,22 @@ export class SmartTransactionsController extends StaticIntervalPollingController #trace: TraceCallback; /* istanbul ignore next */ - async #fetch(request: string, options?: RequestInit) { + async #fetch( + request: string, + options?: RequestInit, + trackingHeaders?: TransactionTrackingHeaders, + ) { const fetchOptions = { ...options, headers: { 'Content-Type': 'application/json', ...(this.#clientId && { 'X-Client-Id': this.#clientId }), + ...(trackingHeaders?.feature && { + 'X-Transaction-Feature': trackingHeaders.feature, + }), + ...(trackingHeaders?.kind && { + 'X-Transaction-Kind': trackingHeaders.kind, + }), }, }; @@ -846,12 +857,14 @@ export class SmartTransactionsController extends StaticIntervalPollingController signedTransactions, signedCanceledTransactions = [], networkClientId, + trackingHeaders, }: { signedTransactions: SignedTransaction[]; signedCanceledTransactions?: SignedCanceledTransaction[]; transactionMeta?: TransactionMeta; txParams?: TransactionParams; networkClientId?: NetworkClientId; + trackingHeaders?: TransactionTrackingHeaders; }) { const selectedNetworkClientId = networkClientId ?? @@ -874,6 +887,7 @@ export class SmartTransactionsController extends StaticIntervalPollingController rawCancelTxs: signedCanceledTransactions, }), }, + trackingHeaders, ), ); const time = Date.now(); diff --git a/src/index.ts b/src/index.ts index 800c6c0..56f6923 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,13 @@ export { type IndividualTxFees, type FeatureFlags, type SmartTransaction, + type TransactionTrackingHeaders, SmartTransactionMinedTx, SmartTransactionCancellationReason, SmartTransactionStatuses, ClientId, + TransactionFeature, + TransactionKind, } from './types'; export { MetaMetricsEventName, MetaMetricsEventCategory } from './constants'; export { diff --git a/src/types.ts b/src/types.ts index c3f1180..349db5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,3 +140,38 @@ export type FeatureFlags = { extensionReturnTxHashAsap?: boolean; }; }; + +/** + * Transaction feature - identifies which MetaMask feature initiated the transaction + */ +export enum TransactionFeature { + Swap = 'Swap', + Bridge = 'Bridge', + Send = 'Send', + Sell = 'Sell', + Perps = 'Perps', + Predictions = 'Predictions', + Card = 'Card', + Pay = 'Pay', + DAppTransaction = 'dAppTransaction', + Earn = 'Earn', + AccountUpgrade = 'AccountUpgrade', +} + +/** + * Transaction kind - identifies the type of transaction mechanism used + */ +export enum TransactionKind { + Regular = 'Regular', + STX = 'STX', + GaslessSendBundle = 'GaslessSendBundle', + GaslessEIP7702 = 'GaslessEIP7702', +} + +/** + * Optional headers for tracking transaction feature and kind + */ +export type TransactionTrackingHeaders = { + feature?: TransactionFeature; + kind?: TransactionKind; +};