From aec86f4289bcc8133ec2c614c6b843afccb0c148 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Wed, 30 Sep 2026 19:44:39 +0200 Subject: [PATCH 1/2] feat: add SecurityAlertsApiClient into shared lib --- packages/snap-networks-utils/CHANGELOG.md | 2 + packages/snap-networks-utils/src/index.ts | 14 ++ .../SecurityAlertsApiClient.test.ts | 162 ++++++++++++++++++ .../SecurityAlertsApiClient.ts | 124 ++++++++++++++ .../services/security-alerts/types.test.ts | 43 +++++ .../src/services/security-alerts/types.ts | 79 +++++++++ 6 files changed, 424 insertions(+) create mode 100644 packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.test.ts create mode 100644 packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.ts create mode 100644 packages/snap-networks-utils/src/services/security-alerts/types.test.ts create mode 100644 packages/snap-networks-utils/src/services/security-alerts/types.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index bb5a1211..3ab60fa6 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add the shared Security Alerts API HTTP client for transaction scans (`SecurityAlertsApiClient` posting a typed body to a single `scanUrl`, `SecurityAlertsHttpError`, `SECURITY_ALERTS_REQUEST_HEADERS`), the common scan request wire type (`SecurityAlertsScanRequestBase`), and the scan vocabulary shared by network snaps (`SecurityAlertsScanOption`, `SecurityAlertsScanStatus`, `SecurityAlertResponse`, `normalizeScanOrigin`) + - Add a shared `EstimatedChanges` Snaps JSX component for transaction confirmations, rendering send/receive asset rows with loading, not-available, and no-changes states ([#369](https://github.com/MetaMask/internal-snaps/pull/369)) - Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#374](https://github.com/MetaMask/internal-snaps/pull/374)) - Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index a61ee999..2c90dfba 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -23,6 +23,20 @@ export type { TransactionFinalizedEventProperties, WebSocketConnectionClosedEventProperties, } from './services/analytics/AnalyticsService'; +export { + SecurityAlertsApiClient, + SecurityAlertsHttpError, + SECURITY_ALERTS_REQUEST_HEADERS, +} from './services/security-alerts/SecurityAlertsApiClient'; +export type { SecurityAlertsApiClientOptions } from './services/security-alerts/SecurityAlertsApiClient'; +export { + METAMASK_ORIGIN_URL, + normalizeScanOrigin, + SecurityAlertResponse, + SecurityAlertsScanOption, + SecurityAlertsScanStatus, +} from './services/security-alerts/types'; +export type { SecurityAlertsScanRequestBase } from './services/security-alerts/types'; export { safeMerge } from './utils/safeMerge/safeMerge'; export { buildUrl } from './utils/buildUrl/buildUrl'; export type { BuildUrlParams } from './utils/buildUrl/buildUrl'; diff --git a/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.test.ts b/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.test.ts new file mode 100644 index 00000000..e0cb2dc3 --- /dev/null +++ b/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.test.ts @@ -0,0 +1,162 @@ +import { string, type } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import { + SecurityAlertsApiClient, + SecurityAlertsHttpError, + SECURITY_ALERTS_REQUEST_HEADERS, +} from './SecurityAlertsApiClient'; + +const SCAN_URL = 'https://security-alerts.example/tron/transaction/scan'; + +const FakeResponseStruct = type({ verdict: string() }); +type FakeResponse = Infer; + +describe('SecurityAlertsApiClient', () => { + const fetchMock = jest.fn() as jest.MockedFunction; + + const responseWith = (overrides: Partial = {}): Response => + ({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ verdict: 'Benign' }), + text: jest.fn().mockResolvedValue('response body'), + ...overrides, + }) as unknown as Response; + + const createClient = (): SecurityAlertsApiClient => + new SecurityAlertsApiClient({ scanUrl: SCAN_URL, fetch: fetchMock }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('rejects an invalid scan URL', () => { + expect(() => new SecurityAlertsApiClient({ scanUrl: 'not-a-url' })).toThrow( + 'Invalid URL format', + ); + }); + + it('posts the body to the scan URL with the shared headers', async () => { + fetchMock.mockResolvedValue(responseWith()); + const client = createClient(); + + await client.scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(SCAN_URL, { + headers: { + ...SECURITY_ALERTS_REQUEST_HEADERS, + }, + method: 'POST', + body: JSON.stringify({ + transaction: 'fake-transaction', + options: ['validation'], + }), + }); + }); + + it('defaults to the global fetch when none is provided', async () => { + const globalFetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(responseWith()); + const client = new SecurityAlertsApiClient({ scanUrl: SCAN_URL }); + + await client.scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ); + + expect(globalFetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns the response validated by the response struct', async () => { + fetchMock.mockResolvedValue(responseWith()); + const client = createClient(); + + // Annotating proves `ResponseT` is inferred from the struct. + const result: FakeResponse = await client.scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ); + + expect(result).toStrictEqual({ verdict: 'Benign' }); + }); + + it('throws when the response does not match the response struct', async () => { + fetchMock.mockResolvedValue(responseWith({ json: jest.fn() })); + const client = createClient(); + + await expect( + client.scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ), + ).rejects.toThrow('Expected an object, but received: undefined'); + }); + + it('throws a SecurityAlertsHttpError carrying the status and response body on a non-2xx response', async () => { + fetchMock.mockResolvedValue( + responseWith({ + ok: false, + status: 503, + text: jest.fn().mockResolvedValue('service unavailable'), + }), + ); + const client = createClient(); + + const error = await client + .scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(SecurityAlertsHttpError); + expect(error).toMatchObject({ + name: 'SecurityAlertsHttpError', + message: 'Security Alerts API error: 503 - service unavailable', + statusCode: 503, + responseBody: 'service unavailable', + }); + }); + + it('carries a null response body when the error body cannot be read', async () => { + fetchMock.mockResolvedValue( + responseWith({ + ok: false, + status: 500, + text: jest.fn().mockRejectedValue(new Error('stream consumed')), + }), + ); + const client = createClient(); + + const error = await client + .scanTransaction( + { transaction: 'fake-transaction', options: ['validation'] }, + FakeResponseStruct, + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(SecurityAlertsHttpError); + expect(error).toMatchObject({ + name: 'SecurityAlertsHttpError', + message: 'Security Alerts API error: 500', + statusCode: 500, + responseBody: null, + }); + }); +}); + +describe('SecurityAlertsHttpError', () => { + it('omits the body from the message when there is none', () => { + const error = new SecurityAlertsHttpError(429, null); + + expect(error.message).toBe('Security Alerts API error: 429'); + expect(error.statusCode).toBe(429); + expect(error.responseBody).toBeNull(); + }); +}); diff --git a/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.ts b/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.ts new file mode 100644 index 00000000..28448ade --- /dev/null +++ b/packages/snap-networks-utils/src/services/security-alerts/SecurityAlertsApiClient.ts @@ -0,0 +1,124 @@ +import { assert } from '@metamask/superstruct'; +import type { Struct } from '@metamask/superstruct'; + +import { UrlStruct } from '../../utils/urlStruct/urlStruct'; + +/** + * Headers sent with every Security Alerts API request. + */ +export const SECURITY_ALERTS_REQUEST_HEADERS: Record = { + 'Content-Type': 'application/json', + accept: 'application/json', +}; + +/** + * Error thrown when the Security Alerts API responds with a non-2xx status. + */ +export class SecurityAlertsHttpError extends Error { + readonly statusCode: number; + + readonly responseBody: string | null; + + /** + * Creates a new SecurityAlertsHttpError. + * + * @param statusCode - The HTTP status code of the response. + * @param responseBody - The raw response body, when it could be read. + */ + constructor(statusCode: number, responseBody: string | null) { + super( + `Security Alerts API error: ${statusCode}${ + responseBody ? ` - ${responseBody}` : '' + }`, + ); + + this.name = 'SecurityAlertsHttpError'; + this.statusCode = statusCode; + this.responseBody = responseBody; + } +} + +/** + * Options for a `SecurityAlertsApiClient` constructor. + */ +export type SecurityAlertsApiClientOptions = { + /** + * The full scan endpoint URL, e.g. `https://security-alerts.api.cx.metamask.io/tron/transaction/scan`. + */ + scanUrl: string; + /** The fetch implementation to use. Defaults to `globalThis.fetch`. */ + fetch?: typeof globalThis.fetch; +}; + +/** + * HTTP client for a Security Alerts API scan endpoint. + * + * Owns the request envelope shared by every chain — POSTing the JSON body, + * reporting non-2xx responses as {@link SecurityAlertsHttpError}, and parsing + * the JSON response — and nothing else. + * + * The request body and response payload are chain-specific Blockaid + * contracts: the caller supplies a typed body (e.g. checked with `satisfies` + * against a per-snap body type) and a Superstruct struct for the response, + * so both are type-checked and validated at the call site. + */ +export class SecurityAlertsApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #scanUrl: string; + + /** + * Creates a new SecurityAlertsApiClient. + * + * @param options - The client options. + * @param options.scanUrl - The full scan endpoint URL. Validated once, so + * an invalid URL fails fast at construction. + * @param options.fetch - Optional fetch implementation; defaults to + * `globalThis.fetch`. + */ + constructor({ + scanUrl, + fetch: fetchFn = globalThis.fetch, + }: SecurityAlertsApiClientOptions) { + assert(scanUrl, UrlStruct); + + this.#fetch = fetchFn; + this.#scanUrl = scanUrl; + } + + /** + * Scans a transaction by posting the request body to the scan endpoint. + * + * @param body - The chain-specific scan request body. + * @param responseStruct - Superstruct struct validating the + * chain-specific response payload. + * @returns The validated scan response. + * @throws SecurityAlertsHttpError if the API responds with a non-2xx status. + */ + async scanTransaction, ResponseT>( + body: BodyT, + responseStruct: Struct, + ): Promise { + const response = await this.#fetch(this.#scanUrl, { + headers: { ...SECURITY_ALERTS_REQUEST_HEADERS }, + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + let responseBody: string | null = null; + try { + responseBody = await response.text(); + } catch { + responseBody = null; + } + + throw new SecurityAlertsHttpError(response.status, responseBody); + } + + const data = await response.json(); + assert(data, responseStruct); + + return data; + } +} diff --git a/packages/snap-networks-utils/src/services/security-alerts/types.test.ts b/packages/snap-networks-utils/src/services/security-alerts/types.test.ts new file mode 100644 index 00000000..5b833fdf --- /dev/null +++ b/packages/snap-networks-utils/src/services/security-alerts/types.test.ts @@ -0,0 +1,43 @@ +import { + METAMASK_ORIGIN_URL, + normalizeScanOrigin, + SecurityAlertResponse, + SecurityAlertsScanOption, + SecurityAlertsScanStatus, +} from './types'; + +describe('normalizeScanOrigin', () => { + it('maps the MetaMask in-app origin to its URL', () => { + expect(normalizeScanOrigin('metamask')).toBe(METAMASK_ORIGIN_URL); + }); + + it('returns other origins unchanged', () => { + expect(normalizeScanOrigin('https://example.com')).toBe( + 'https://example.com', + ); + }); +}); + +describe('shared scan vocabulary', () => { + it('exposes the Security Alerts API scan options', () => { + expect(SecurityAlertsScanOption).toStrictEqual({ + Simulation: 'simulation', + Validation: 'validation', + }); + }); + + it('exposes the scan statuses', () => { + expect(SecurityAlertsScanStatus).toStrictEqual({ + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', + }); + }); + + it('exposes the security alert verdicts', () => { + expect(SecurityAlertResponse).toStrictEqual({ + Benign: 'Benign', + Warning: 'Warning', + Malicious: 'Malicious', + }); + }); +}); diff --git a/packages/snap-networks-utils/src/services/security-alerts/types.ts b/packages/snap-networks-utils/src/services/security-alerts/types.ts new file mode 100644 index 00000000..e3f73515 --- /dev/null +++ b/packages/snap-networks-utils/src/services/security-alerts/types.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { DEFAULT_METAMASK_ORIGIN } from '../../utils/originPermissions/createOriginPermissions'; + +/** + * Scan options recognized by the Security Alerts API scan endpoints. + * + * `simulation` requests an on-chain simulation of the transaction (asset + * diffs); `validation` requests a security verdict. + */ +export const SecurityAlertsScanOption = { + Simulation: 'simulation', + Validation: 'validation', +} as const; + +export type SecurityAlertsScanOption = + (typeof SecurityAlertsScanOption)[keyof typeof SecurityAlertsScanOption]; + +/** Status of a completed security scan. */ +export const SecurityAlertsScanStatus = { + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', +} as const; + +export type SecurityAlertsScanStatus = + (typeof SecurityAlertsScanStatus)[keyof typeof SecurityAlertsScanStatus]; + +/** + * Verdicts emitted by the Security Alerts API validation. + * + * Shared by all network snaps so scan results and analytics agree on the + * verdict vocabulary. + */ +export const SecurityAlertResponse = { + Benign: 'Benign', + Warning: 'Warning', + Malicious: 'Malicious', +} as const; + +export type SecurityAlertResponse = + (typeof SecurityAlertResponse)[keyof typeof SecurityAlertResponse]; + +/** + * JSON body fields shared by every Security Alerts API scan request. + * + * Chain-specific payloads (the transaction itself, its encoding, and the + * metadata shape) are added by each chain's client on top of this base. + */ +export type SecurityAlertsScanRequestBase = { + /** + * The scanned account's address, in the chain's native format. The snake + * case matches the Security Alerts API wire format. + */ + account_address: string; + /** The Security Alerts API chain identifier (e.g. `mainnet`, `pubnet`). */ + chain: string; + /** The requested scan options. */ + options?: string[]; +}; + +/** + * The URL substituted for the MetaMask in-app origin in scan request + * metadata, since the in-app browser reports no dapp URL. + */ +export const METAMASK_ORIGIN_URL = 'https://metamask.io'; + +/** + * Normalizes a dapp origin for scan request metadata. + * + * The MetaMask in-app browser reports the pseudo-origin `metamask`, which is + * not a usable URL; it is mapped to the MetaMask site. Other origins are + * returned unchanged. + * + * @param origin - The origin of the request. + * @returns The normalized origin. + */ +export function normalizeScanOrigin(origin: string): string { + return origin === DEFAULT_METAMASK_ORIGIN ? METAMASK_ORIGIN_URL : origin; +} From bbe2c39173e78881126b15e9e0e8f529bf1c735b Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Wed, 30 Sep 2026 19:47:01 +0200 Subject: [PATCH 2/2] chore: tron use SecurityAlertsApiClient from shared lib --- packages/tron-wallet-snap/jest.config.js | 8 +- .../SecurityAlertsApiClient.ts | 142 ------------------ .../clients/security-alerts-api/utils.test.ts | 66 +++++++- .../src/clients/security-alerts-api/utils.ts | 33 +++- packages/tron-wallet-snap/src/context.ts | 13 +- .../TransactionScanService.test.ts | 89 +++++++++-- .../TransactionScanService.ts | 84 ++++++++--- .../src/services/transaction-scan/types.ts | 16 -- 8 files changed, 241 insertions(+), 210 deletions(-) delete mode 100644 packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts diff --git a/packages/tron-wallet-snap/jest.config.js b/packages/tron-wallet-snap/jest.config.js index 881de040..59e2611d 100644 --- a/packages/tron-wallet-snap/jest.config.js +++ b/packages/tron-wallet-snap/jest.config.js @@ -19,10 +19,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 72.69, - functions: 79.95, - lines: 85.85, - statements: 85.86, + branches: 73.13, + functions: 80.08, + lines: 86.17, + statements: 86.18, }, }, }; diff --git a/packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts b/packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts deleted file mode 100644 index 3de81216..00000000 --- a/packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - -import type { Logger } from '@metamask/snap-networks-utils'; -import { assert } from '@metamask/superstruct'; -import { Types as TronwebTypes } from 'tronweb'; - -import type { ConfigProvider } from '../../services/config'; -import { isTransactionWellFormed } from '../../validation/transaction'; -import { SecurityAlertResponseStruct } from './structs'; -import type { SecurityAlertSimulationValidationResponse } from './structs'; -import { extractScanParametersFromTransactionData } from './utils'; - -/** - * Client for interacting with the Security Alerts API for security scanning. - * - * @example - * ```typescript - * const client = new SecurityAlertsApiClient(configProvider, logger); - * ``` - */ -export class SecurityAlertsApiClient { - /** - * Contract types for which the Security Alerts API can produce reliable - * simulation results. - */ - static readonly SUPPORTED_CONTRACT_TYPES: TronwebTypes.ContractType[] = [ - TronwebTypes.ContractType.TransferContract, - TronwebTypes.ContractType.CreateSmartContract, - TronwebTypes.ContractType.TriggerSmartContract, - ]; - - /** - * Checks whether the first contract type in the transaction is supported - * by the Security Alerts API simulation. - * - * @param rawData - The raw transaction data. - * @returns True if the contract type is supported for simulation. - */ - static isContractTypeSupported( - rawData: TronwebTypes.Transaction['raw_data'], - ): boolean { - const [contractInteraction] = rawData.contract; - if (!contractInteraction) { - return false; - } - return SecurityAlertsApiClient.SUPPORTED_CONTRACT_TYPES.includes( - contractInteraction.type, - ); - } - - readonly #fetch: typeof globalThis.fetch; - - readonly #logger: Logger; - - readonly #baseUrl: string; - - /** - * Creates a new SecurityAlertsApiClient instance. - * - * @param configProvider - The configuration provider. - * @param logger - Logger instance for logging. - */ - constructor(configProvider: ConfigProvider, logger: Logger) { - this.#fetch = fetch; - this.#logger = logger.withPrefix('[🔒 SecurityAlertsApiClient]'); - this.#baseUrl = configProvider.config.securityAlertsApi.baseUrl; - } - - /** - * Scans a Tron transaction using the Security Alerts API. - * - * @param params - The parameters for the scan. - * @param params.accountAddress - The account address in base58 format. - * @param params.transactionRawData - The raw data of the transaction. - * @param params.origin - The origin URL of the request. - * @param params.options - Optional scan options (simulation, validation). - * @returns The security alert response from Security Alerts API. - */ - async scanTransaction({ - accountAddress, - transactionRawData, - origin, - options = ['simulation', 'validation'], - }: { - accountAddress: string; - transactionRawData: TronwebTypes.Transaction['raw_data']; - origin: string; - options?: string[]; - }): Promise { - this.#logger.info('Scanning Tron transaction with Security Alerts API'); - - if ( - !isTransactionWellFormed(transactionRawData) || - !SecurityAlertsApiClient.isContractTypeSupported(transactionRawData) - ) { - throw new Error('Transaction is not supported for scanning.'); - } - - const headers: Record = { - 'Content-Type': 'application/json', - accept: 'application/json', - }; - - const scanParameters = - extractScanParametersFromTransactionData(transactionRawData); - - if (!scanParameters) { - throw new Error('Could not extract scan parameters from transaction.'); - } - - const response = await this.#fetch( - `${this.#baseUrl}/tron/transaction/scan`, - { - headers, - method: 'POST', - body: JSON.stringify({ - account_address: accountAddress, - metadata: { - domain: origin, - }, - data: scanParameters, - options, - }), - }, - ); - - if (!response.ok) { - const errorBody = await response.text(); - this.#logger.error( - `Security Alerts API error: ${response.status} - ${errorBody}`, - ); - throw new Error( - `Security Alerts API error: ${response.status} - ${errorBody}`, - ); - } - - const data = await response.json(); - assert(data, SecurityAlertResponseStruct); - - return data; - } -} diff --git a/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts b/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts index 07a880fe..7e5c9d03 100644 --- a/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts +++ b/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts @@ -4,9 +4,73 @@ import type { TransferAssetContractParameter, TransferContractParameter, } from '../trongrid/types'; -import { extractScanParametersFromTransactionData } from './utils'; +import { + extractScanParametersFromTransactionData, + isContractTypeSupported, +} from './utils'; describe('SecurityAlertsApiClient utils', () => { + describe('isContractTypeSupported', () => { + it('supports transfers, contract deployment, and contract calls', () => { + const rawData: TronwebTypes.Transaction['raw_data'] = { + contract: [ + { + type: TronwebTypes.ContractType.TransferContract, + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c', + to_address: '4191bba2f3f6e1c4d5c8e8f5b6a7c8d9e0f1a2b3c4', + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '', + ref_block_hash: '', + expiration: 0, + timestamp: 0, + }; + + expect(isContractTypeSupported(rawData)).toBe(true); + }); + + it('does not support other contract types', () => { + const rawData: TronwebTypes.Transaction['raw_data'] = { + contract: [ + { + type: 'FreezeBalanceContract' as TronwebTypes.ContractType, + parameter: { + type_url: 'type.googleapis.com/protocol.FreezeBalanceContract', + value: { + owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c', + frozen_balance: 1000, + }, + }, + }, + ], + ref_block_bytes: '', + ref_block_hash: '', + expiration: 0, + timestamp: 0, + }; + + expect(isContractTypeSupported(rawData)).toBe(false); + }); + + it('does not support transactions without contracts', () => { + const rawData: TronwebTypes.Transaction['raw_data'] = { + contract: [], + ref_block_bytes: '', + ref_block_hash: '', + expiration: 0, + timestamp: 0, + }; + + expect(isContractTypeSupported(rawData)).toBe(false); + }); + }); + describe('extractScanParametersFromTransactionData', () => { it('extracts scan parameters from a TransferAssetContractParameter', () => { const contractInteraction: TransferAssetContractParameter = { diff --git a/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts b/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts index ee74ae3d..d27b6b04 100644 --- a/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts +++ b/packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts @@ -3,10 +3,41 @@ import { TronWeb, Types as TronwebTypes } from 'tronweb'; import type { SecurityScanPayload } from './types'; +/** + * Contract types for which the Security Alerts API can produce reliable + * simulation results. + */ +const SUPPORTED_CONTRACT_TYPES: TronwebTypes.ContractType[] = [ + TronwebTypes.ContractType.TransferContract, + TronwebTypes.ContractType.CreateSmartContract, + TronwebTypes.ContractType.TriggerSmartContract, +]; + +/** + * Checks whether the first contract type in the transaction is supported + * by the Security Alerts API simulation. + * + * Only the first contract in `raw_data.contract` is considered because + * the Tron protocol currently only executes one contract per + * transaction (see {@link extractScanParametersFromTransactionData}). + * + * @param rawData - The raw transaction data. + * @returns True if the contract type is supported for simulation. + */ +export function isContractTypeSupported( + rawData: TronwebTypes.Transaction['raw_data'], +): boolean { + const [contractInteraction] = rawData.contract; + if (!contractInteraction) { + return false; + } + return SUPPORTED_CONTRACT_TYPES.includes(contractInteraction.type); +} + /** * Extracts scan parameters from the raw transaction data. This function * can be used as adapter between a Tron transaction and the payload - * supported by SecurityAlertsApiClient. + * supported by the Security Alerts API scan request. * * Only the first contract in `raw_data.contract` is used because * the Tron protocol currently only executes one contract per diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 7c465fae..f839fa9e 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -3,6 +3,7 @@ import { AssetsProvider, InMemoryCache, RemoteFeatureFlagsProvider, + SecurityAlertsApiClient, State, StateCache, } from '@metamask/snap-networks-utils'; @@ -14,7 +15,6 @@ import type { import { getMessenger } from '@metamask/snaps-sdk'; import { PriceApiClient } from './clients/price-api/PriceApiClient'; -import { SecurityAlertsApiClient } from './clients/security-alerts-api/SecurityAlertsApiClient'; import { getSnapProvider } from './clients/snap/getSnapProvider'; import { SnapClient } from './clients/snap/SnapClient'; import { TokenApiClient } from './clients/token-api/TokenApiClient'; @@ -112,11 +112,10 @@ const assetsProvider = new AssetsProvider({ messenger: coreMessenger as AssetsProviderMessenger, }); -// Security Alerts API client -const securityAlertsApiClient = new SecurityAlertsApiClient( - configProvider, - logger, -); +// Security Alerts API HTTP client +const securityAlertsHttpClient = new SecurityAlertsApiClient({ + scanUrl: `${configProvider.config.securityAlertsApi.baseUrl}/tron/transaction/scan`, +}); const snapAssetsAdapter = new SnapAssetsAdapter({ logger, @@ -202,7 +201,7 @@ const walletService = new WalletService({ }); const transactionScanService = new TransactionScanService( - securityAlertsApiClient, + securityAlertsHttpClient, snapClient, logger, analyticsService, diff --git a/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts b/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts index 7264128c..8e1890c7 100644 --- a/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts +++ b/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts @@ -1,17 +1,22 @@ import type { AnalyticsService, ExtendedKeyringAccount, + SecurityAlertsApiClient, } from '@metamask/snap-networks-utils'; -import { Types as TronwebTypes } from 'tronweb'; +import { + SecurityAlertResponse, + SecurityAlertsScanStatus, +} from '@metamask/snap-networks-utils'; +import { TronWeb, Types as TronwebTypes } from 'tronweb'; -import { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; +import { SecurityAlertResponseStruct } from '../../clients/security-alerts-api/structs'; import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/structs'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { METAMASK_ORIGIN, Network } from '../../constants'; import { mockLogger } from '../../utils/mockLogger'; import { TransactionScanService } from './TransactionScanService'; import type { TransactionScanResult } from './types'; -import { ScanStatus, SecurityAlertResponse, SimulationStatus } from './types'; +import { SimulationStatus } from './types'; const mockAnalyticsService = { trackSecurityScanCompleted: jest.fn().mockResolvedValue(undefined), @@ -593,16 +598,13 @@ describe('TransactionScanService', () => { }); expect(result).toMatchObject({ - status: ScanStatus.ERROR, + status: SecurityAlertsScanStatus.ERROR, simulationStatus: SimulationStatus.Failed, error: { type: 'MALFORMED_TRANSACTION' }, }); }); it('skips unsupported contract types', async () => { - const isContractTypeSupported = jest - .spyOn(SecurityAlertsApiClient, 'isContractTypeSupported') - .mockReturnValue(false); const mockSecurityAlertsApiClient = createMockSecurityAlertsApiClient({ simulation: { status: 'Success' }, validation: { status: 'Success', result_type: 'Benign' }, @@ -616,19 +618,68 @@ describe('TransactionScanService', () => { const result = await service.scanTransaction({ accountAddress: mockAccount.address, - transactionRawData: createWellFormedTransactionRawData(), + transactionRawData: { + ...createWellFormedTransactionRawData(), + contract: [ + { + type: 'FreezeBalanceContract' as TronwebTypes.ContractType, + parameter: { + type_url: 'type.googleapis.com/protocol.FreezeBalanceContract', + value: { + owner_address: `41${'a'.repeat(40)}`, + frozen_balance: 1000, + }, + }, + }, + ], + }, origin: 'https://example.com', scope: Network.Mainnet, }); expect(result).toMatchObject({ - status: ScanStatus.SUCCESS, + status: SecurityAlertsScanStatus.SUCCESS, simulationStatus: SimulationStatus.Skipped, }); expect( mockSecurityAlertsApiClient.scanTransaction, ).not.toHaveBeenCalled(); - isContractTypeSupported.mockRestore(); + }); + + it('posts the scan body extracted from the transaction data', async () => { + const mockSecurityAlertsApiClient = createMockSecurityAlertsApiClient({ + simulation: { status: 'Success' }, + validation: { status: 'Success', result_type: 'Benign' }, + }); + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + createMockSnapClient() as unknown as SnapClient, + mockLogger, + mockAnalyticsService, + ); + + await service.scanTransaction({ + accountAddress: mockAccount.address, + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://example.com', + scope: Network.Mainnet, + options: ['validation'], + }); + + expect(mockSecurityAlertsApiClient.scanTransaction).toHaveBeenCalledWith( + { + account_address: mockAccount.address, + metadata: { domain: 'https://example.com' }, + data: { + from: TronWeb.address.fromHex(`41${'a'.repeat(40)}`), + to: TronWeb.address.fromHex(`41${'b'.repeat(40)}`), + data: null, + value: 990000, + }, + options: ['validation'], + }, + SecurityAlertResponseStruct, + ); }); it('ignores asset diffs without changes', async () => { @@ -742,7 +793,7 @@ describe('TransactionScanService', () => { origin: 'https://example.com', accountType: mockAccount.type, chainIdCaip: Network.Mainnet, - scanStatus: ScanStatus.SUCCESS, + scanStatus: SecurityAlertsScanStatus.SUCCESS, hasSecurityAlerts: false, }); }); @@ -765,7 +816,7 @@ describe('TransactionScanService', () => { origin: 'https://example.com', accountType: mockAccount.type, chainIdCaip: Network.Mainnet, - scanStatus: ScanStatus.SUCCESS, + scanStatus: SecurityAlertsScanStatus.SUCCESS, hasSecurityAlerts: true, }); expect( @@ -793,7 +844,7 @@ describe('TransactionScanService', () => { origin: 'https://example.com', accountType: mockAccount.type, chainIdCaip: Network.Mainnet, - scanStatus: ScanStatus.ERROR, + scanStatus: SecurityAlertsScanStatus.ERROR, hasSecurityAlerts: false, }); }); @@ -815,7 +866,7 @@ describe('TransactionScanService', () => { origin: 'https://example.com', accountType: mockAccount.type, chainIdCaip: Network.Mainnet, - scanStatus: ScanStatus.ERROR, + scanStatus: SecurityAlertsScanStatus.ERROR, hasSecurityAlerts: false, }); }); @@ -854,7 +905,10 @@ describe('TransactionScanService', () => { }); expect(mockSecurityAlertsApiClient.scanTransaction).toHaveBeenCalledWith( - expect.objectContaining({ origin: 'https://metamask.io' }), + expect.objectContaining({ + metadata: { domain: 'https://metamask.io' }, + }), + SecurityAlertResponseStruct, ); }); @@ -869,7 +923,10 @@ describe('TransactionScanService', () => { }); expect(mockSecurityAlertsApiClient.scanTransaction).toHaveBeenCalledWith( - expect.objectContaining({ origin: 'https://example.com' }), + expect.objectContaining({ + metadata: { domain: 'https://example.com' }, + }), + SecurityAlertResponseStruct, ); }); }); diff --git a/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts b/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts index ecb2211e..248dec27 100644 --- a/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts +++ b/packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts @@ -1,20 +1,32 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import type { AnalyticsService, ExtendedKeyringAccount, Logger, + SecurityAlertsApiClient, +} from '@metamask/snap-networks-utils'; +import { + normalizeScanOrigin, + SecurityAlertResponse, + SecurityAlertsScanStatus, } from '@metamask/snap-networks-utils'; import { BigNumber } from 'bignumber.js'; import type { Types as TronwebTypes } from 'tronweb'; -import { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; import type { AssetChange, AssetDiff, - SecurityAlertSimulationValidationResponse, } from '../../clients/security-alerts-api/structs'; +import { SecurityAlertResponseStruct } from '../../clients/security-alerts-api/structs'; +import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/structs'; +import type { SecurityScanPayload } from '../../clients/security-alerts-api/types'; +import { + extractScanParametersFromTransactionData, + isContractTypeSupported, +} from '../../clients/security-alerts-api/utils'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { Network } from '../../constants'; -import { METAMASK_ORIGIN } from '../../constants'; import { isTransactionWellFormed } from '../../validation/transaction'; import type { TransactionScanAssetChange, @@ -22,12 +34,24 @@ import type { TransactionScanResult, TransactionScanValidation, } from './types'; -import { ScanStatus, SecurityAlertResponse, SimulationStatus } from './types'; - -const METAMASK_ORIGIN_URL = 'https://metamask.io'; +import { SimulationStatus } from './types'; + +/** + * JSON body sent to the Tron scan endpoint. + */ +type TronScanRequestBody = { + /** The scanned account's address in base58 format. */ + account_address: string; + /** The origin of the request, reported by Blockaid as a domain. */ + metadata: { domain: string }; + /** The scan parameters extracted from the raw transaction data. */ + data: SecurityScanPayload; + /** The requested scan options. */ + options: string[]; +}; export class TransactionScanService { - readonly #securityAlertsApiClient: SecurityAlertsApiClient; + readonly #securityAlertsHttpClient: SecurityAlertsApiClient; readonly #snapClient: SnapClient; @@ -36,12 +60,12 @@ export class TransactionScanService { readonly #analyticsService: AnalyticsService; constructor( - securityAlertsApiClient: SecurityAlertsApiClient, + securityAlertsHttpClient: SecurityAlertsApiClient, snapClient: SnapClient, logger: Logger, analyticsService: AnalyticsService, ) { - this.#securityAlertsApiClient = securityAlertsApiClient; + this.#securityAlertsHttpClient = securityAlertsHttpClient; this.#snapClient = snapClient; this.#logger = logger; this.#analyticsService = analyticsService; @@ -92,7 +116,7 @@ export class TransactionScanService { }; } - if (!SecurityAlertsApiClient.isContractTypeSupported(transactionRawData)) { + if (!isContractTypeSupported(transactionRawData)) { this.#logger.info( 'Transaction contract type is not supported for simulation, skipping scan', ); @@ -107,12 +131,26 @@ export class TransactionScanService { } try { - const result = await this.#securityAlertsApiClient.scanTransaction({ - accountAddress, - transactionRawData, - origin: origin === METAMASK_ORIGIN ? METAMASK_ORIGIN_URL : origin, - options, - }); + this.#logger.info('Scanning Tron transaction with Security Alerts API'); + + const scanParameters = + extractScanParametersFromTransactionData(transactionRawData); + + if (!scanParameters) { + throw new Error('Could not extract scan parameters from transaction.'); + } + + const result = await this.#securityAlertsHttpClient.scanTransaction( + { + account_address: accountAddress, + metadata: { + domain: normalizeScanOrigin(origin), + }, + data: scanParameters, + options, + } satisfies TronScanRequestBody, + SecurityAlertResponseStruct, + ); const scan = this.#mapScan(result); @@ -127,7 +165,7 @@ export class TransactionScanService { origin, accountType: account.type, chainIdCaip: scope, - scanStatus: ScanStatus.ERROR, + scanStatus: SecurityAlertsScanStatus.ERROR, hasSecurityAlerts: false, }); } @@ -137,12 +175,12 @@ export class TransactionScanService { // Track security scan completion if (account) { - const isValidScanStatus = Object.values(ScanStatus).includes( - scan.status as ScanStatus, - ); + const isValidScanStatus = Object.values( + SecurityAlertsScanStatus, + ).includes(scan.status as SecurityAlertsScanStatus); const scanStatus = isValidScanStatus - ? (scan.status as ScanStatus) - : ScanStatus.ERROR; + ? (scan.status as SecurityAlertsScanStatus) + : SecurityAlertsScanStatus.ERROR; const hasSecurityAlert = Boolean( scan.validation?.type && @@ -191,7 +229,7 @@ export class TransactionScanService { origin, accountType: account.type, chainIdCaip: scope, - scanStatus: ScanStatus.ERROR, + scanStatus: SecurityAlertsScanStatus.ERROR, hasSecurityAlerts: false, }); } diff --git a/packages/tron-wallet-snap/src/services/transaction-scan/types.ts b/packages/tron-wallet-snap/src/services/transaction-scan/types.ts index 83ac2d51..5c7080a4 100644 --- a/packages/tron-wallet-snap/src/services/transaction-scan/types.ts +++ b/packages/tron-wallet-snap/src/services/transaction-scan/types.ts @@ -41,19 +41,3 @@ export type TransactionScanResult = { error: TransactionScanError | null; simulationStatus: SimulationStatus; }; - -export const SecurityAlertResponse = { - Benign: 'Benign', - Warning: 'Warning', - Malicious: 'Malicious', -} as const; - -export type SecurityAlertResponse = - (typeof SecurityAlertResponse)[keyof typeof SecurityAlertResponse]; - -export const ScanStatus = { - SUCCESS: 'SUCCESS', - ERROR: 'ERROR', -} as const; - -export type ScanStatus = (typeof ScanStatus)[keyof typeof ScanStatus];