diff --git a/packages/snap/CHANGELOG.md b/packages/snap/CHANGELOG.md index 890fe04d..91c63d89 100644 --- a/packages/snap/CHANGELOG.md +++ b/packages/snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add security scanning for tokens sends ([#205](https://github.com/MetaMask/snap-tron-wallet/pull/205)) + ### Fixed - Decode hex-encoded TRC10 token names and symbols from Full Node API responses ([#187](https://github.com/MetaMask/snap-tron-wallet/pull/187)) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 91693a4f..41b0e512 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "E/qvXLXSs5RvRXpgTauLi8L/ABSxmgX54CCy1bBzp+g=", + "shasum": "9EWYf1pdiOmZgMY380Lw3C6PkrYnDIzOVqdPivN4o+Q=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/handlers/cronjob.test.tsx b/packages/snap/src/handlers/cronjob.test.tsx new file mode 100644 index 00000000..36cb6075 --- /dev/null +++ b/packages/snap/src/handlers/cronjob.test.tsx @@ -0,0 +1,435 @@ +import { BackgroundEventMethod, CronHandler } from './cronjob'; +import type { PriceApiClient } from '../clients/price-api/PriceApiClient'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { TronHttpClient } from '../clients/tron-http/TronHttpClient'; +import { Network } from '../constants'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { State, UnencryptedStateValue } from '../services/state/State'; +import type { TransactionScanService } from '../services/transaction-scan/TransactionScanService'; +import type { TransactionScanResult } from '../services/transaction-scan/types'; +import type { ConfirmTransactionRequestContext } from '../ui/confirmation/views/ConfirmTransactionRequest/types'; +import type { ILogger } from '../utils/logger'; + +/** + * Subset of SnapClient methods exercised by `refreshConfirmationSend`. + */ +type MockSnapClient = jest.Mocked< + Pick< + SnapClient, + | 'getClientStatus' + | 'createInterface' + | 'showDialog' + | 'updateInterface' + | 'getInterfaceContext' + | 'scheduleBackgroundEvent' + | 'getPreferences' + > +>; + +/** + * Subset of State methods exercised by `refreshConfirmationSend`. + */ +type MockState = jest.Mocked< + Pick, 'getKey' | 'setKey'> +>; + +/** + * Subset of TransactionScanService methods exercised by + * `refreshConfirmationSend`. + */ +type MockTransactionScanService = jest.Mocked< + Pick< + TransactionScanService, + 'scanTransaction' | 'getSecurityAlertDescription' + > +>; + +/** + * Builds a mock scan result for use in tests. + * + * @param overrides - Optional overrides for the scan result. + * @returns A mock TransactionScanResult. + */ +function buildMockScanResult( + overrides: Partial = {}, +): TransactionScanResult { + return { + status: 'SUCCESS', + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1000000', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { type: 'Benign', reason: null }, + error: null, + ...overrides, + }; +} + +/** + * Builds a mock interface context for the send confirmation dialog. + * + * @param overrides - Optional overrides for context fields. + * @returns A mock ConfirmTransactionRequestContext. + */ +function buildMockInterfaceContext( + overrides: Partial = {}, +): ConfirmTransactionRequestContext { + return { + origin: 'MetaMask', + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', + }, + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, + networkImage: '', + tokenPrices: {}, + tokenPricesFetchStatus: 'fetched', + scan: null, + scanFetchStatus: 'initial', + scanParameters: { + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + data: null, + value: 1000000, + }, + accountType: 'tron:eoa', + ...overrides, + }; +} + +/** + * Builds a mock logger satisfying the ILogger interface. + * + * @returns A mock ILogger. + */ +function buildMockLogger(): ILogger { + return { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; +} + +/** + * Builds a mock SnapClient with only the methods exercised by the + * `refreshConfirmationSend` flow. + * + * @param interfaceContext - The value `getInterfaceContext` resolves to. + * @returns A mock SnapClient. + */ +function buildMockSnapClient( + interfaceContext: ConfirmTransactionRequestContext | null, +): MockSnapClient { + return { + getClientStatus: jest + .fn() + .mockResolvedValue({ active: true, locked: false }), + createInterface: jest.fn().mockResolvedValue('interface-id'), + showDialog: jest.fn().mockResolvedValue(true), + updateInterface: jest.fn().mockResolvedValue(undefined), + getInterfaceContext: jest.fn().mockResolvedValue(interfaceContext), + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + getPreferences: jest.fn().mockResolvedValue({}), + }; +} + +/** + * Builds a mock State with only the methods exercised by the + * `refreshConfirmationSend` flow. + * + * @param mapInterfaceNameToId - The value `getKey` resolves to. + * @returns A mock State. + */ +function buildMockState( + mapInterfaceNameToId: Record, +): MockState { + return { + setKey: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue(mapInterfaceNameToId), + }; +} + +/** + * Builds a mock TransactionScanService with only the methods exercised by + * the `refreshConfirmationSend` flow. + * + * @param scanResult - The value `scanTransaction` resolves to. + * @returns A mock TransactionScanService. + */ +function buildMockTransactionScanService( + scanResult: TransactionScanResult, +): MockTransactionScanService { + return { + scanTransaction: jest.fn().mockResolvedValue(scanResult), + getSecurityAlertDescription: jest.fn().mockReturnValue('description'), + }; +} + +/** + * Assembles a CronHandler from the given partial mocks. Type assertions are + * concentrated here so that every other part of the test file stays + * assertion-free. + * + * @param deps - The mock dependencies. + * @param deps.mockSnapClient - The mock SnapClient. + * @param deps.mockState - The mock State. + * @param deps.mockTransactionScanService - The mock TransactionScanService. + * @returns A CronHandler instance wired to the mocks. + */ +function buildCronHandler({ + mockSnapClient, + mockState, + mockTransactionScanService, +}: { + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; +}): CronHandler { + return new CronHandler({ + logger: buildMockLogger(), + accountsService: {} as AccountsService, + snapClient: mockSnapClient as unknown as SnapClient, + state: mockState as unknown as State, + priceApiClient: {} as PriceApiClient, + tronHttpClient: {} as TronHttpClient, + transactionScanService: + mockTransactionScanService as unknown as TransactionScanService, + }); +} + +/** + * The callback that `withCronHandler` calls. + */ +type WithCronHandlerCallback = (payload: { + cronHandler: CronHandler; + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; +}) => Promise | void; + +/** + * Options for the `withCronHandler` factory function. + */ +type WithCronHandlerOptions = { + interfaceContext?: ConfirmTransactionRequestContext | null; + scanResult?: TransactionScanResult; + mapInterfaceNameToId?: Record; +}; + +/** + * Constructs a CronHandler with sensible defaults and calls the given test + * function with the handler and all mocks. Overrides can be provided to + * configure the mocks for specific test scenarios. + * + * @param args - Either a function, or an options bag + a function. + */ +async function withCronHandler( + ...args: + | [WithCronHandlerCallback] + | [WithCronHandlerOptions, WithCronHandlerCallback] +): Promise { + const [options, testFunction] = args.length === 2 ? args : [{}, args[0]]; + + const { + interfaceContext = buildMockInterfaceContext(), + scanResult = buildMockScanResult(), + mapInterfaceNameToId = { confirmTransaction: 'interface-id-456' }, + } = options; + + const mockSnapClient = buildMockSnapClient(interfaceContext); + const mockState = buildMockState(mapInterfaceNameToId); + const mockTransactionScanService = + buildMockTransactionScanService(scanResult); + + const cronHandler = buildCronHandler({ + mockSnapClient, + mockState, + mockTransactionScanService, + }); + + await testFunction({ + cronHandler, + mockSnapClient, + mockState, + mockTransactionScanService, + }); +} + +describe('CronHandler', () => { + describe('refreshConfirmationSend', () => { + it('refreshes security scan and updates interface', async () => { + await withCronHandler( + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).toHaveBeenCalledWith( + expect.objectContaining({ + accountAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: ['simulation', 'validation'], + }), + ); + + // Verify interface was updated (fetching state + final state) + expect(mockSnapClient.updateInterface).toHaveBeenCalledTimes(2); + + // Verify next refresh was scheduled + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('exits early when no active interface exists', async () => { + await withCronHandler( + { mapInterfaceNameToId: {} }, + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + expect(mockSnapClient.updateInterface).not.toHaveBeenCalled(); + }, + ); + }); + + it('cleans up when interface context no longer exists', async () => { + await withCronHandler( + { interfaceContext: null }, + async ({ cronHandler, mockState, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'mapInterfaceNameToId.confirmTransaction', + null, + ); + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('skips refresh when required context fields are missing', async () => { + await withCronHandler( + { + interfaceContext: buildMockInterfaceContext({ fromAddress: null }), + }, + async ({ cronHandler, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('handles scan failure gracefully and sets error state', async () => { + await withCronHandler( + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + mockTransactionScanService.scanTransaction.mockRejectedValue( + new Error('Scan API error'), + ); + + await cronHandler.refreshConfirmationSend(); + + // Should still update interface with error status + expect(mockSnapClient.updateInterface).toHaveBeenCalledTimes(2); + + // The final update should have scanFetchStatus: 'error' + const lastUpdateCall = mockSnapClient.updateInterface.mock.calls[1]; + const contextArg = lastUpdateCall?.[2] as any; + expect(contextArg?.scanFetchStatus).toBe('error'); + }, + ); + }); + + it('exits gracefully when interface closes during refresh', async () => { + const context = buildMockInterfaceContext(); + await withCronHandler( + { interfaceContext: context }, + async ({ cronHandler, mockSnapClient }) => { + // First call returns context (initial check), second returns null (closed during scan) + mockSnapClient.getInterfaceContext + .mockResolvedValueOnce(context) + .mockResolvedValueOnce(null); + + await cronHandler.refreshConfirmationSend(); + + // Should not schedule next refresh + expect(mockSnapClient.scheduleBackgroundEvent).not.toHaveBeenCalled(); + }, + ); + }); + + it('includes only simulation when useSecurityAlerts is false', async () => { + await withCronHandler( + { + interfaceContext: buildMockInterfaceContext({ + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, + }), + }, + async ({ cronHandler, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + }, + ); + }); + }); +}); diff --git a/packages/snap/src/handlers/cronjob.tsx b/packages/snap/src/handlers/cronjob.tsx index bf4c6856..c62323f3 100644 --- a/packages/snap/src/handlers/cronjob.tsx +++ b/packages/snap/src/handlers/cronjob.tsx @@ -4,6 +4,7 @@ import type { PriceApiClient } from '../clients/price-api/PriceApiClient'; import type { SnapClient } from '../clients/snap/SnapClient'; import type { TronHttpClient } from '../clients/tron-http/TronHttpClient'; import type { Network } from '../constants'; +import type { TronKeyringAccount } from '../entities'; import type { AccountsService } from '../services/accounts/AccountsService'; import type { State, UnencryptedStateValue } from '../services/state/State'; import type { TransactionScanService } from '../services/transaction-scan/TransactionScanService'; @@ -30,6 +31,7 @@ export enum BackgroundEventMethod { SynchronizeAccount = 'onSynchronizeAccount', SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', RefreshConfirmationPrices = 'refreshConfirmationPrices', + RefreshConfirmationSend = 'refreshConfirmationSend', RefreshSignTransaction = 'refreshSignTransaction', TrackTransaction = 'onTrackTransaction', } @@ -104,6 +106,9 @@ export class CronHandler { case BackgroundEventMethod.RefreshConfirmationPrices: await this.refreshConfirmationPrices(); break; + case BackgroundEventMethod.RefreshConfirmationSend: + await this.refreshConfirmationSend(); + break; case BackgroundEventMethod.RefreshSignTransaction: await this.refreshSignTransaction(); break; @@ -308,6 +313,166 @@ export class CronHandler { } } + /** + * Background job to refresh the security scan for simple send confirmation dialogs. + * Follows Solana's snap pattern: get interface ID from map, refresh scan data, update UI. + */ + async refreshConfirmationSend(): Promise { + this.#logger.info( + 'Background scan refresh triggered for send confirmation...', + ); + + const mapInterfaceNameToId = + (await this.#state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_TRANSACTION_INTERFACE_NAME]; + + // Don't do anything if the confirmation interface is not open + if (!confirmationInterfaceId) { + this.#logger.info('No active send confirmation interface found'); + return; + } + + // Get the current interface context + const interfaceContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + this.#logger.info('Interface context no longer exists, cleaning up'); + await this.#state.setKey( + `mapInterfaceNameToId.${CONFIRM_TRANSACTION_INTERFACE_NAME}`, + null, + ); + return; + } + + // Skip if required fields are missing + if (!interfaceContext.fromAddress || !interfaceContext.scope) { + this.#logger.info('Context is missing required fields for scan refresh'); + return; + } + + const { preferences, scope, fromAddress, origin, scanParameters } = + interfaceContext; + + try { + // Update UI to show fetching state for scan + const fetchingContext: ConfirmTransactionRequestContext = { + ...interfaceContext, + scanFetchStatus: 'fetching', + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + fetchingContext, + ); + + // Always request simulation for estimated changes; + // conditionally add validation based on user preference + const options: string[] = ['simulation']; + if (preferences.useSecurityAlerts) { + options.push('validation'); + } + + // Create a minimal account object for analytics tracking + const scanAccount = { + type: interfaceContext.accountType, + address: fromAddress, + } as TronKeyringAccount; + + let { scan } = interfaceContext; + let { scanFetchStatus } = interfaceContext; + + try { + scan = await this.#transactionScanService.scanTransaction({ + accountAddress: fromAddress, + parameters: { + from: scanParameters?.from ?? undefined, + to: scanParameters?.to ?? undefined, + data: scanParameters?.data ?? undefined, + value: scanParameters?.value ?? undefined, + }, + origin, + scope, + options, + account: scanAccount, + }); + scanFetchStatus = scan ? 'fetched' : 'error'; + this.#logger.info('Successfully refreshed send confirmation scan'); + } catch (error) { + this.#logger.error('Error refreshing send confirmation scan:', error); + scanFetchStatus = 'error'; + } + + // Get the latest context (might have changed during async operations) + const latestContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!latestContext) { + this.#logger.info('Interface closed during refresh'); + return; + } + + // Update with scan results + const updatedContext: ConfirmTransactionRequestContext = { + ...latestContext, + scan, + scanFetchStatus, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + updatedContext, + ); + + this.#logger.info('Successfully refreshed send confirmation'); + + // Schedule the next refresh (20 seconds matching Solana pattern) + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + } catch (error) { + this.#logger.error('Error refreshing send confirmation:', error); + + // Try to update the UI to show error state + try { + const currentContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!currentContext) { + return; + } + + const errorContext: ConfirmTransactionRequestContext = { + ...currentContext, + scanFetchStatus: 'error', + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + errorContext, + ); + } catch { + // Ignore errors when trying to update error state + } + + // Don't schedule another refresh on error - the dialog might be gone + } + } + /** * Background job to refresh security scan and prices for signTransaction confirmation dialogs. * Handles both security scanning and price fetching in a single method. diff --git a/packages/snap/src/services/confirmation/ConfirmationHandler.ts b/packages/snap/src/services/confirmation/ConfirmationHandler.ts index 765a2759..66cff10c 100644 --- a/packages/snap/src/services/confirmation/ConfirmationHandler.ts +++ b/packages/snap/src/services/confirmation/ConfirmationHandler.ts @@ -150,6 +150,7 @@ export class ConfirmationHandler { fees, asset, origin: formatOrigin(origin), + accountType, }, ); diff --git a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx new file mode 100644 index 00000000..c8fc7144 --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx @@ -0,0 +1,208 @@ +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from './types'; +import { Network } from '../../../../constants'; +import type { TransactionScanResult } from '../../../../services/transaction-scan/types'; +import type { Preferences } from '../../../../types/snap'; + +// Mock i18n +jest.mock('../../../../utils/i18n', () => ({ + i18n: (_locale: string) => (key: string) => key, +})); + +// Mock getExplorerUrl +jest.mock('../../../../utils/getExplorerUrl', () => ({ + getExplorerUrl: (_scope: string, _type: string, _address: string) => + 'https://explorer.example.com', +})); + +describe('ConfirmTransactionRequest', () => { + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const mockScanResult: TransactionScanResult = { + status: 'SUCCESS', + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { + type: 'Benign', + reason: null, + }, + error: null, + }; + + const baseContext: ConfirmTransactionRequestContext = { + origin: 'MetaMask', + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', + }, + preferences: mockPreferences, + networkImage: '', + tokenPrices: {}, + tokenPricesFetchStatus: 'fetched', + scan: mockScanResult, + scanFetchStatus: 'fetched', + scanParameters: { + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + data: null, + value: 1000000, + }, + accountType: 'tron:eoa', + }; + + it('renders without crashing with security scan data', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + }); + + it('renders when useSecurityAlerts is true and scan data exists', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + // The component renders successfully with security alerts enabled and scan data present + expect(JSON.stringify(result)).toContain( + 'confirm-sign-and-send-transaction-confirm', + ); + }); + + it('renders without TransactionAlert when useSecurityAlerts is false', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + preferences: { + ...mockPreferences, + useSecurityAlerts: false, + }, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); + + it('renders EstimatedChanges when simulateOnChainActions is true', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + expect(JSON.stringify(result)).toContain( + 'confirmation.estimatedChanges.title', + ); + }); + + it('hides EstimatedChanges when simulateOnChainActions is false', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + preferences: { + ...mockPreferences, + simulateOnChainActions: false, + }, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + expect(JSON.stringify(result)).not.toContain( + 'confirmation.estimatedChanges.title', + ); + }); + + it('disables confirm button when scanFetchStatus is fetching', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scanFetchStatus: 'fetching', + scan: null, + }; + + const result = ConfirmTransactionRequest({ context }); + const serialized = JSON.stringify(result); + + // The confirm button should have disabled=true + expect(serialized).toContain('"disabled":true'); + }); + + it('disables confirm button when scan status is ERROR', () => { + const errorScanResult: TransactionScanResult = { + ...mockScanResult, + status: 'ERROR', + }; + + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: errorScanResult, + scanFetchStatus: 'fetched', + }; + + const result = ConfirmTransactionRequest({ context }); + const serialized = JSON.stringify(result); + + // The confirm button should have disabled=true + expect(serialized).toContain('"disabled":true'); + }); + + it('enables confirm button when scan is successful', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + const serialized = JSON.stringify(result); + + // The confirm button should NOT have disabled=true + // When disabled is false/undefined, it shouldn't be in the serialized output + // or it should be false + expect(serialized).not.toContain('"disabled":true'); + }); + + it('renders with scan error state', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: null, + scanFetchStatus: 'error', + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); + + it('renders with Malicious validation', () => { + const maliciousScanResult: TransactionScanResult = { + ...mockScanResult, + validation: { + type: 'Malicious', + reason: 'known_attacker', + }, + }; + + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: maliciousScanResult, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx index e27d15c7..d302d89e 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -20,8 +20,9 @@ import { Networks } from '../../../../constants'; import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; import { getExplorerUrl } from '../../../../utils/getExplorerUrl'; import { i18n } from '../../../../utils/i18n'; -import { Asset } from '../../components/Asset/Asset'; +import { EstimatedChanges } from '../../components/EstimatedChanges/EstimatedChanges'; import { Fees } from '../../components/Fees'; +import { TransactionAlert } from '../../components/TransactionAlert/TransactionAlert'; export const ConfirmTransactionRequest = ({ context: { @@ -29,25 +30,35 @@ export const ConfirmTransactionRequest = ({ scope, fromAddress, toAddress, - asset, - amount, fees, preferences, networkImage, tokenPrices, tokenPricesFetchStatus, + scan, + scanFetchStatus, }, }: { context: ConfirmTransactionRequestContext; }): ComponentOrElement => { const translate = i18n(preferences.locale); - const assetPrice = tokenPrices[asset.assetType]?.price ?? null; - const priceLoading = tokenPricesFetchStatus === 'fetching'; + const shouldDisableConfirmButton = + scanFetchStatus === 'fetching' || scan?.status === 'ERROR'; return ( + {/* Security Alert */} + {preferences.useSecurityAlerts ? ( + + ) : null} + {/* Header */} {null} @@ -56,36 +67,15 @@ export const ConfirmTransactionRequest = ({ {null} - {/* Estimated Changes */} -
- {/* Header + Tooltip */} - - - {translate('confirmation.estimatedChanges.title')} - - - - - - - - - {translate('confirmation.estimatedChanges.send')} - - - - -
+ + {/* Estimated Changes (from security scan simulation) */} + {preferences.simulateOnChainActions ? ( + + ) : null} {/* Additional Details */}
@@ -165,7 +155,10 @@ export const ConfirmTransactionRequest = ({ - diff --git a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx new file mode 100644 index 00000000..c30e0de8 --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx @@ -0,0 +1,431 @@ +import { render } from './render'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import type { AssetEntity } from '../../../../entities/assets'; +import { BackgroundEventMethod } from '../../../../handlers/cronjob'; +import type { + State, + UnencryptedStateValue, +} from '../../../../services/state/State'; +import type { TransactionScanService } from '../../../../services/transaction-scan/TransactionScanService'; +import type { TransactionScanResult } from '../../../../services/transaction-scan/types'; +import type { Preferences } from '../../../../types/snap'; + +// Mock the context module +jest.mock('../../../../context', () => ({ + __esModule: true, // eslint-disable-line @typescript-eslint/naming-convention + default: { + transactionScanService: null, + }, +})); + +// --------------------------------------------------------------------------- +// Mock types +// --------------------------------------------------------------------------- + +/** + * Subset of SnapClient methods exercised by `render`. + */ +type MockSnapClient = jest.Mocked< + Pick< + SnapClient, + | 'createInterface' + | 'showDialog' + | 'updateInterface' + | 'getPreferences' + | 'scheduleBackgroundEvent' + > +>; + +/** + * Subset of TransactionScanService methods exercised by `render`. + */ +type MockTransactionScanService = jest.Mocked< + Pick< + TransactionScanService, + 'scanTransaction' | 'getSecurityAlertDescription' + > +>; + +/** + * Subset of State methods exercised by `render`. + */ +type MockState = jest.Mocked< + Pick, 'setKey' | 'getKey'> +>; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const defaultPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, +}; + +const mockAsset: AssetEntity = { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', +}; + +const defaultScanResult: TransactionScanResult = { + status: 'SUCCESS', + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1000000', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { + type: 'Benign', + reason: null, + }, + error: null, +}; + +const defaultIncomingContext = { + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [] as never[], + asset: mockAsset, + origin: 'MetaMask', + accountType: 'tron:eoa', +}; + +// --------------------------------------------------------------------------- +// Builder functions +// --------------------------------------------------------------------------- + +/** + * Builds a mock SnapClient with only the methods exercised by the `render` + * flow. + * + * @returns A mock SnapClient. + */ +function buildMockSnapClient(): MockSnapClient { + return { + createInterface: jest.fn().mockResolvedValue('interface-id-123'), + showDialog: jest.fn().mockResolvedValue(true), + updateInterface: jest.fn().mockResolvedValue(undefined), + getPreferences: jest.fn().mockResolvedValue(defaultPreferences), + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + }; +} + +/** + * Builds a mock TransactionScanService with only the methods exercised by + * the `render` flow. + * + * @returns A mock TransactionScanService. + */ +function buildMockTransactionScanService(): MockTransactionScanService { + return { + scanTransaction: jest.fn().mockResolvedValue(defaultScanResult), + getSecurityAlertDescription: jest.fn().mockReturnValue('description'), + }; +} + +/** + * Builds a mock State with only the methods exercised by the `render` flow. + * + * @returns A mock State. + */ +function buildMockState(): MockState { + return { + setKey: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue({}), + }; +} + +// --------------------------------------------------------------------------- +// Factory function +// --------------------------------------------------------------------------- + +/** + * The callback that `withRender` calls. + */ +type WithRenderCallback = (payload: { + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; + callRender: ( + contextOverrides?: Partial[2]>, + ) => ReturnType; +}) => Promise | void; + +/** + * Options for the `withRender` factory function. + */ +type WithRenderOptions = { + hasTransactionScanService?: boolean; +}; + +/** + * Constructs render mocks with sensible defaults and calls the given test + * function with helpers and all mocks. The `callRender` helper concentrates + * the type assertions so that every test body stays assertion-free. + * + * @param args - Either a callback, or an options bag + callback. + */ +async function withRender( + ...args: [WithRenderCallback] | [WithRenderOptions, WithRenderCallback] +): Promise { + const [options, testFunction] = args.length === 2 ? args : [{}, args[0]]; + const { hasTransactionScanService = true } = options; + + const mockSnapClient = buildMockSnapClient(); + const mockState = buildMockState(); + const mockTransactionScanService = buildMockTransactionScanService(); + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals + const snapContext = require('../../../../context').default; + snapContext.transactionScanService = hasTransactionScanService + ? mockTransactionScanService + : null; + + const callRender = async ( + contextOverrides?: Partial[2]>, + ) => + render( + mockSnapClient as unknown as SnapClient, + mockState as unknown as State, + { ...defaultIncomingContext, ...contextOverrides }, + ); + + await testFunction({ + mockSnapClient, + mockState, + mockTransactionScanService, + callRender, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ConfirmTransactionRequest render', () => { + it('triggers security scan when preferences enable it', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.showDialog).toHaveBeenCalledWith( + 'interface-id-123', + ); + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + accountAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + parameters: { + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + data: undefined, + value: 1000000, // 1 TRX = 1000000 sun + }, + origin: 'MetaMask', + scope: Network.Mainnet, + options: ['simulation', 'validation'], + }), + ); + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('always triggers scan even when security preferences are disabled', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('handles security scan failure gracefully', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockTransactionScanService.scanTransaction.mockRejectedValue( + new Error('Scan failed'), + ); + + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + + const updateCall = mockSnapClient.updateInterface.mock.calls[0]; + const contextArg = updateCall?.[2] as any; + expect(contextArg?.scanFetchStatus).toBe('error'); + expect(contextArg?.scan).toBeNull(); + }, + ); + }); + + it('handles missing transaction scan service gracefully', async () => { + await withRender( + { hasTransactionScanService: false }, + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('uses fallback preferences when preferences fail to load', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockRejectedValue( + new Error('Failed to load'), + ); + + const result = await callRender(); + + expect(result).toBe(true); + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalled(); + }, + ); + }); + + it('requests only simulation when useSecurityAlerts is false', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: false, + simulateOnChainActions: true, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + }, + ); + }); + + it('includes both simulation and validation when useSecurityAlerts is true', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: true, + simulateOnChainActions: false, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation', 'validation'], + }), + ); + }, + ); + }); + + it('builds correct scan parameters for TRC20 tokens', async () => { + await withRender(async ({ callRender, mockTransactionScanService }) => { + const trc20Asset: AssetEntity = { + ...mockAsset, + assetType: `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + symbol: 'USDT', + decimals: 6, + }; + + await callRender({ asset: trc20Asset }); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + parameters: expect.objectContaining({ + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // contract address + data: undefined, + value: undefined, // null → undefined + }), + }), + ); + }); + }); + + it('schedules price refresh when pricing data is enabled', async () => { + await withRender(async ({ callRender, mockSnapClient }) => { + await callRender(); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT1S', + }); + }); + }); + + it('returns the dialog promise result', async () => { + await withRender(async ({ callRender, mockSnapClient }) => { + mockSnapClient.showDialog.mockResolvedValue(true); + + const result = await callRender(); + + expect(result).toBe(true); + }); + }); + + it('stores interface ID in state for background refresh', async () => { + await withRender(async ({ callRender, mockState }) => { + await callRender(); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'mapInterfaceNameToId.confirmTransaction', + 'interface-id-123', + ); + }); + }); +}); diff --git a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx index bf353fc6..87c83c5f 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -1,4 +1,6 @@ import type { DialogResult } from '@metamask/snaps-sdk'; +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; import { @@ -7,6 +9,8 @@ import { } from './types'; import type { SnapClient } from '../../../../clients/snap/SnapClient'; import { Network } from '../../../../constants'; +import snapContext from '../../../../context'; +import type { TronKeyringAccount } from '../../../../entities'; import type { AssetEntity } from '../../../../entities/assets'; import { BackgroundEventMethod } from '../../../../handlers/cronjob'; import type { ComputeFeeResult } from '../../../../services/send/types'; @@ -37,13 +41,17 @@ export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { networkImage: TRX_IMAGE_SVG, tokenPrices: {}, tokenPricesFetchStatus: 'initial', + scan: null, + scanFetchStatus: 'initial', + scanParameters: null, + accountType: '', preferences: { locale: 'en', currency: 'usd', hideBalances: false, - useSecurityAlerts: false, + useSecurityAlerts: true, useExternalPricingData: true, - simulateOnChainActions: false, + simulateOnChainActions: true, useTokenDetection: true, batchCheckBalances: true, displayNftMedia: false, @@ -51,6 +59,55 @@ export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { }, }; +/** + * Build scan parameters from the transaction details. + * For native TRX sends, uses the recipient address and amount in sun. + * For TRC20 sends, uses the contract address as the target. + * + * @param fromAddress - The sender address. + * @param toAddress - The recipient address. + * @param amount - The amount to send (display units). + * @param asset - The asset being sent. + * @returns The scan parameters for the security alerts API. + */ +function buildScanParameters( + fromAddress: string, + toAddress: string, + amount: string, + asset: AssetEntity, +): { + from: string | null; + to: string | null; + data: string | null; + value: number | null; +} { + const { assetNamespace, assetReference } = parseCaipAssetType( + asset.assetType, + ); + const isTrc20 = assetNamespace === 'trc20'; + + if (isTrc20) { + return { + from: fromAddress, + to: assetReference, // contract address + data: null, + value: null, + }; + } + + // Native TRX: convert display amount to raw sun + const rawValue = new BigNumber(amount) + .multipliedBy(new BigNumber(10).pow(asset.decimals)) + .toNumber(); + + return { + from: fromAddress, + to: toAddress, + data: null, + value: rawValue, + }; +} + /** * Render the ConfirmTransactionRequest UI and show a dialog resolving to the user's choice. * @@ -64,6 +121,7 @@ export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { * @param incomingContext.fees - The detailed fee breakdown array. * @param incomingContext.asset - The asset involved in the transaction. * @param incomingContext.origin - The origin string to display. + * @param incomingContext.accountType - The account type for analytics. * @returns A dialog result with the user's decision. */ export async function render( @@ -77,13 +135,17 @@ export async function render( fees: ComputeFeeResult; asset: AssetEntity; origin: string; + accountType: string; }, ): Promise { + const { transactionScanService } = snapContext; + // 1. Initial context with loading state const context: ConfirmTransactionRequestContext = { ...DEFAULT_CONFIRMATION_CONTEXT, ...incomingContext, tokenPricesFetchStatus: 'fetching', // Start as fetching + scanFetchStatus: 'fetching', // Start as fetching }; try { @@ -92,6 +154,8 @@ export async function render( // keep defaults } + const { useSecurityAlerts } = context.preferences; + /** * Resolve icon URLs for fee assets from known asset metadata. */ @@ -99,6 +163,14 @@ export async function render( fee.asset.iconUrl = getIconUrlForKnownAsset(fee.asset.type); }); + // Build scan parameters from transaction details + context.scanParameters = buildScanParameters( + incomingContext.fromAddress, + incomingContext.toAddress, + incomingContext.amount, + incomingContext.asset, + ); + // 2. Initial render with loading skeleton (always show loading if pricing enabled) const id = await snapClient.createInterface( , @@ -107,12 +179,57 @@ export async function render( const dialogPromise = snapClient.showDialog(id); // Store interface ID by name for background refresh (Solana pattern) - await state.setKey( + const storeIdPromise = state.setKey( `mapInterfaceNameToId.${CONFIRM_TRANSACTION_INTERFACE_NAME}`, id, ); - // 3. Schedule background job to handle all price fetching + // 3. Perform security scan (always needed for estimated changes simulation) + if (transactionScanService) { + // Always request simulation for estimated changes; + // conditionally add validation based on user preference + const options: string[] = ['simulation']; + + if (useSecurityAlerts) { + options.push('validation'); + } + + // Create a minimal account object for analytics tracking + const scanAccount = { + type: incomingContext.accountType, + address: incomingContext.fromAddress, + } as TronKeyringAccount; + + try { + const scan = await transactionScanService.scanTransaction({ + accountAddress: incomingContext.fromAddress, + parameters: { + from: context.scanParameters?.from ?? undefined, + to: context.scanParameters?.to ?? undefined, + data: context.scanParameters?.data ?? undefined, + value: context.scanParameters?.value ?? undefined, + }, + origin: incomingContext.origin, + scope: incomingContext.scope, + options, + account: scanAccount, + }); + + context.scan = scan; + context.scanFetchStatus = scan ? 'fetched' : 'error'; + } catch { + context.scan = null; + context.scanFetchStatus = 'error'; + } + } else { + // No scan service available, mark as fetched immediately + context.scanFetchStatus = 'fetched'; + } + + // Ensure interface ID is stored before updating + await storeIdPromise; + + // 4. Schedule background job to handle price fetching and scan refresh if (context.preferences.useExternalPricingData) { // Trigger immediate price fetch (1 second), then continue every 20 seconds await snapClient.scheduleBackgroundEvent({ @@ -122,14 +239,24 @@ export async function render( } else { // If pricing is disabled, set to fetched immediately context.tokenPricesFetchStatus = 'fetched'; - await snapClient.updateInterface( - id, - , - context, - ); } - // 4. Return the dialog promise immediately (don't await it!) + // Schedule security scan background refresh (every 20 seconds) + if (transactionScanService) { + await snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + } + + // 5. Update interface with scan results after initial render + await snapClient.updateInterface( + id, + , + context, + ); + + // 6. Return the dialog promise immediately (don't await it!) // Cleanup happens in the background refresh handler when it detects the interface is gone return dialogPromise; } diff --git a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts index f6e888e0..b0437124 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts +++ b/packages/snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts @@ -2,6 +2,7 @@ import type { SpotPrices } from '../../../../clients/price-api/types'; import type { Network } from '../../../../constants'; import type { AssetEntity } from '../../../../entities/assets'; import type { ComputeFeeResult } from '../../../../services/send/types'; +import type { TransactionScanResult } from '../../../../services/transaction-scan/types'; import type { FetchStatus, Preferences } from '../../../../types/snap'; export const CONFIRM_TRANSACTION_INTERFACE_NAME = 'confirmTransaction'; @@ -18,4 +19,13 @@ export type ConfirmTransactionRequestContext = { networkImage: string; tokenPrices: SpotPrices; tokenPricesFetchStatus: FetchStatus; + scan: TransactionScanResult | null; + scanFetchStatus: FetchStatus; + scanParameters: { + from: string | null; + to: string | null; + data: string | null; + value: number | null; + } | null; + accountType: string; };