diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 6f6e7a08c..ca2c535f8 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Emit `transaction_type` on the transaction lifecycle events. ([#401](https://github.com/MetaMask/internal-snaps/pull/401)) + ### Changed - Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#384](https://github.com/MetaMask/internal-snaps/pull/384)) diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts index 9a218fa81..d40a50625 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts @@ -1,3 +1,4 @@ +import { TransactionType } from '@metamask/keyring-api'; import type { Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/snaps-sdk'; @@ -1105,6 +1106,8 @@ describe('ClientRequestHandler', () => { expect.any(String), Network.Mainnet, 'metamask', + undefined, + TransactionType.TokenApprove, ); expect(result).toStrictEqual({ signature: mockSignature }); }); diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts index 26d4bf8d1..682198558 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -1,4 +1,4 @@ -import { FeeType } from '@metamask/keyring-api'; +import { FeeType, TransactionType } from '@metamask/keyring-api'; import { normalizeError } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; @@ -463,6 +463,8 @@ export class ClientRequestHandler { base64EncodedTransaction, network, METAMASK_ORIGIN, + undefined, + TransactionType.TokenApprove, ); const result = { signature }; diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts index f8f5344ca..1e91effbb 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts @@ -1,7 +1,8 @@ +import { TransactionType } from '@metamask/keyring-api'; import { UuidStruct } from '@metamask/snap-networks-utils'; import { InternalError } from '@metamask/snaps-sdk'; import type { OnCronjobHandler } from '@metamask/snaps-sdk'; -import { assert, literal, object, string } from '@metamask/superstruct'; +import { assert, enums, literal, object, string } from '@metamask/superstruct'; import { analyticsService, keyring } from '../../../../snapContext'; import logger from '../../../utils/logger'; @@ -17,6 +18,7 @@ export const OnTransactionAddedRequestStruct = object({ metadata: object({ scope: NetworkStruct, origin: string(), + transactionType: enums(Object.values(TransactionType)), }), }), }); @@ -42,6 +44,7 @@ export const onTransactionAdded: OnCronjobHandler = async ({ request }) => { origin: metadata.origin, accountType: account.type, chainIdCaip: metadata.scope, + transactionType: metadata.transactionType, }); } catch (error) { logger.error(error); diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts index c56d62f61..88a42fd83 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts @@ -1,7 +1,8 @@ +import { TransactionType } from '@metamask/keyring-api'; import { UuidStruct } from '@metamask/snap-networks-utils'; import { InternalError } from '@metamask/snaps-sdk'; import type { OnCronjobHandler } from '@metamask/snaps-sdk'; -import { assert, literal, object, string } from '@metamask/superstruct'; +import { assert, enums, literal, object, string } from '@metamask/superstruct'; import { analyticsService, keyring } from '../../../../snapContext'; import logger from '../../../utils/logger'; @@ -17,6 +18,7 @@ export const OnTransactionApprovedRequestStruct = object({ metadata: object({ scope: NetworkStruct, origin: string(), + transactionType: enums(Object.values(TransactionType)), }), }), }); @@ -42,6 +44,7 @@ export const onTransactionApproved: OnCronjobHandler = async ({ request }) => { origin: metadata.origin, accountType: account.type, chainIdCaip: metadata.scope, + transactionType: metadata.transactionType, }); } catch (error) { logger.error(error); diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionBackgroundEvents.test.ts b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionBackgroundEvents.test.ts index a81a41989..976267973 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionBackgroundEvents.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionBackgroundEvents.test.ts @@ -1,3 +1,5 @@ +import { TransactionType } from '@metamask/keyring-api'; + import { analyticsService, keyring } from '../../../../snapContext'; import { Network } from '../../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; @@ -53,6 +55,7 @@ describe('transaction background events', () => { metadata: { scope: Network.Mainnet, origin, + transactionType: TransactionType.Send, }, }, }, @@ -63,6 +66,7 @@ describe('transaction background events', () => { origin, accountType: account.type, chainIdCaip: Network.Mainnet, + transactionType: TransactionType.Send, }); }); }); diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts index 7934cd53a..842db9f3b 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts @@ -1,7 +1,8 @@ +import { TransactionType } from '@metamask/keyring-api'; import { UuidStruct } from '@metamask/snap-networks-utils'; import { InternalError } from '@metamask/snaps-sdk'; import type { OnCronjobHandler } from '@metamask/snaps-sdk'; -import { assert, literal, object, string } from '@metamask/superstruct'; +import { assert, enums, literal, object, string } from '@metamask/superstruct'; import { analyticsService, keyring } from '../../../../snapContext'; import logger from '../../../utils/logger'; @@ -17,6 +18,7 @@ export const OnTransactionRejectedRequestStruct = object({ metadata: object({ scope: NetworkStruct, origin: string(), + transactionType: enums(Object.values(TransactionType)), }), }), }); @@ -42,6 +44,7 @@ export const onTransactionRejected: OnCronjobHandler = async ({ request }) => { origin: metadata.origin, accountType: account.type, chainIdCaip: metadata.scope, + transactionType: metadata.transactionType, }); } catch (error) { logger.error(error); diff --git a/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.test.ts b/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.test.ts index f4e98f8d2..43b2952a3 100644 --- a/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.test.ts +++ b/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.test.ts @@ -1,8 +1,9 @@ -import { SolMethod } from '@metamask/keyring-api'; +import { SolMethod, TransactionType } from '@metamask/keyring-api'; import { render as renderConfirmSignIn } from '../../../features/confirmation/views/ConfirmSignIn/render'; import { render as renderConfirmSignMessage } from '../../../features/confirmation/views/ConfirmSignMessage/render'; import { render as renderConfirmTransactionRequest } from '../../../features/confirmation/views/ConfirmTransactionRequest/render'; +import { METAMASK_ORIGIN } from '../../constants/solana'; import { ScheduleBackgroundEventMethod } from '../../handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; import type { SolanaKeyringRequest } from '../../handlers/onKeyringRequest/structs'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; @@ -59,6 +60,7 @@ type ScheduledEventCall = { metadata: { scope: SolanaKeyringRequest['scope']; origin: string; + transactionType: TransactionType; }; }; }; @@ -94,9 +96,15 @@ describe('ConfirmationHandler', () => { * keeps `Added` covered exactly like the terminal events. * * @param method - The lifecycle event method name. + * @param origin - The origin carried in the event metadata. + * @param transactionType - The classification carried in the event metadata. * @returns The expected `snap.request` call. */ - const expectedScheduleCall = (method: string): ScheduledEventCall => ({ + const expectedScheduleCall = ( + method: string, + origin = MOCK_ORIGIN, + transactionType = TransactionType.Unknown, + ): ScheduledEventCall => ({ method: 'snap_scheduleBackgroundEvent', params: { duration: 'PT1S', @@ -106,7 +114,8 @@ describe('ConfirmationHandler', () => { accountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, metadata: { scope: mockTransactionRequest.scope, - origin: MOCK_ORIGIN, + origin, + transactionType, }, }, }, @@ -219,6 +228,32 @@ describe('ConfirmationHandler', () => { }, ); + it('reports a MetaMask-originated transaction as a send', async () => { + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + + await confirmationHandler.handleKeyringRequest( + { ...request, origin: METAMASK_ORIGIN }, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(mockSnapRequest.mock.calls).toStrictEqual([ + [ + expectedScheduleCall( + ScheduleBackgroundEventMethod.OnTransactionAdded, + METAMASK_ORIGIN, + TransactionType.Send, + ), + ], + [ + expectedScheduleCall( + ScheduleBackgroundEventMethod.OnTransactionApproved, + METAMASK_ORIGIN, + TransactionType.Send, + ), + ], + ]); + }); + it('does not schedule Approved when the user rejects', async () => { mockRenderConfirmTransactionRequest.mockResolvedValue(false); diff --git a/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts b/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts index 44565b3ed..b7b43d7bd 100644 --- a/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts +++ b/packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts @@ -10,6 +10,7 @@ import { } from '../../../features/confirmation/views/ConfirmTransactionRequest/render'; import { ScheduleBackgroundEventMethod } from '../../handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; import type { SolanaKeyringRequest } from '../../handlers/onKeyringRequest/structs'; +import { resolveTransactionType } from '../../utils/transactionType'; import { SolanaSignAndSendTransactionRequestStruct, SolanaSignTransactionRequestStruct, @@ -82,6 +83,12 @@ export class ConfirmationHandler { origin, } = request; + /** + * Resolved once and carried by all three lifecycle events, so Added, + * Approved and Rejected always agree on the classification. + */ + const transactionType = resolveTransactionType({ origin }); + // Trigger the side effects that need to happen when the transaction is shown in confirmation UI await snap.request({ method: 'snap_scheduleBackgroundEvent', @@ -94,6 +101,7 @@ export class ConfirmationHandler { metadata: { scope, origin, + transactionType, }, }, }, @@ -122,6 +130,7 @@ export class ConfirmationHandler { metadata: { scope, origin, + transactionType, }, }, }, @@ -143,6 +152,7 @@ export class ConfirmationHandler { metadata: { scope, origin, + transactionType, }, }, }, diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index f490b5083..33e9fd87b 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -1,4 +1,4 @@ -import { SolMethod } from '@metamask/keyring-api'; +import { SolMethod, TransactionType } from '@metamask/keyring-api'; import type { AnalyticsService } from '@metamask/snap-networks-utils'; import { METAMASK_ORIGIN, Network } from '../../constants/solana'; @@ -396,9 +396,47 @@ describe('WalletService', () => { accountType: fromAccount.type, chainIdCaip: scope, origin: 'https://metamask.io', + transactionType: TransactionType.Unknown, }); }); + it('reports a MetaMask-originated transaction as a send', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + METAMASK_ORIGIN, + ); + + expect( + mockAnalyticsService.trackTransactionSubmitted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + origin: METAMASK_ORIGIN, + transactionType: TransactionType.Send, + }), + ); + }); + + it('uses a caller-supplied classification over the origin', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + METAMASK_ORIGIN, + undefined, + TransactionType.TokenApprove, + ); + + expect( + mockAnalyticsService.trackTransactionSubmitted, + ).toHaveBeenCalledWith( + expect.objectContaining({ + transactionType: TransactionType.TokenApprove, + }), + ); + }); + it('saves a pending unconfirmed transaction after broadcasting', async () => { await service.signAndSendTransaction( fromAccount, diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index c6e9aadbd..41baff04f 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -1,5 +1,6 @@ import type { SLIP10Node } from '@metamask/key-tree'; import { SolMethod } from '@metamask/keyring-api'; +import type { TransactionType } from '@metamask/keyring-api'; import { normalizeError } from '@metamask/snap-networks-utils'; import type { AnalyticsService, @@ -38,6 +39,7 @@ import { trackError } from '../../utils/errors'; import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; import logger from '../../utils/logger'; +import { resolveTransactionType } from '../../utils/transactionType'; import { Base58Struct, Base64Struct } from '../../validation/structs'; import type { SolanaConnection } from '../connection'; import type { Signer } from '../signer/Signer'; @@ -275,6 +277,7 @@ export class WalletService { * @param options.preflightCommitment - The preflight commitment. * @param options.maxRetries - The maximum number of retries. * @param options.commitment - The commitment. + * @param transactionType - A classification already known by the caller. When omitted, it is derived from the origin. * @returns A Promise that resolves to the signed transaction. */ async signAndSendTransaction( @@ -283,6 +286,7 @@ export class WalletService { scope: Network, origin: string, options?: SolanaSignAndSendTransactionOptions, + transactionType?: TransactionType, ): Promise { this.#logger.log('Signing and sending transaction', account); @@ -344,6 +348,7 @@ export class WalletService { origin, accountType: account.type, chainIdCaip: scope, + transactionType: resolveTransactionType({ origin, transactionType }), }); // Immediately save and emit a pending transaction, so the client can show diff --git a/packages/solana-wallet-snap/src/core/utils/transactionType.test.ts b/packages/solana-wallet-snap/src/core/utils/transactionType.test.ts new file mode 100644 index 000000000..553620332 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/transactionType.test.ts @@ -0,0 +1,42 @@ +import { TransactionType } from '@metamask/keyring-api'; + +import { METAMASK_ORIGIN } from '../constants/solana'; +import { resolveTransactionType } from './transactionType'; + +describe('resolveTransactionType', () => { + it('uses the classification supplied by the caller when present', () => { + expect( + resolveTransactionType({ + origin: METAMASK_ORIGIN, + transactionType: TransactionType.Receive, + }), + ).toBe(TransactionType.Receive); + }); + + it('prefers the supplied classification over the origin-derived one', () => { + expect( + resolveTransactionType({ + origin: 'https://example.com', + transactionType: TransactionType.Swap, + }), + ).toBe(TransactionType.Swap); + }); + + it('reports a MetaMask-originated transaction as a send', () => { + expect(resolveTransactionType({ origin: METAMASK_ORIGIN })).toBe( + TransactionType.Send, + ); + }); + + it('reports a dApp-originated transaction as unknown', () => { + expect(resolveTransactionType({ origin: 'https://example.com' })).toBe( + TransactionType.Unknown, + ); + }); + + it('does not treat the MetaMask origin URL as the MetaMask origin', () => { + expect(resolveTransactionType({ origin: 'https://metamask.io' })).toBe( + TransactionType.Unknown, + ); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/utils/transactionType.ts b/packages/solana-wallet-snap/src/core/utils/transactionType.ts new file mode 100644 index 000000000..627fd7b18 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/transactionType.ts @@ -0,0 +1,38 @@ +import { TransactionType } from '@metamask/keyring-api'; + +import { METAMASK_ORIGIN } from '../constants/solana'; + +/** + * Resolves the transaction classification to report for a lifecycle event. + * + * A classification is only trustworthy once a transaction has been mapped from + * on-chain data, where the balance changes identify the direction. Before + * broadcast the Snap only has the raw, unsigned transaction, and an arbitrary + * dApp transaction cannot be classified from its instructions without guessing, + * so those events report `unknown`. + * + * The one pre-broadcast case that is known is the unified send flow: it is the + * only MetaMask-originated transaction the Snap itself builds, and it is always + * a send. That holds for `signTransaction` as well as + * `signAndSendTransaction`, so the origin alone decides it. + * + * @param options - The classification inputs. + * @param options.origin - The origin that triggered the lifecycle step. + * @param options.transactionType - A classification already known by the caller, if any. + * @returns The classification to report. + */ +export function resolveTransactionType({ + origin, + transactionType, +}: { + origin: string; + transactionType?: TransactionType; +}): TransactionType { + if (transactionType !== undefined) { + return transactionType; + } + + return origin === METAMASK_ORIGIN + ? TransactionType.Send + : TransactionType.Unknown; +}