From 0382f4a504b417e17f7abdc991f3cfeb74068191 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 3 Jun 2026 18:13:53 +0200 Subject: [PATCH 1/3] feat: add Blockaid token security scan to change-trust confirmations --- packages/snap/locales/es.json | 12 ++ packages/snap/snap.manifest.json | 2 +- packages/snap/src/context.ts | 7 + .../clientRequest/changeTrustOpt.test.ts | 10 ++ .../handlers/clientRequest/changeTrustOpt.ts | 9 +- .../cronjob/refreshConfirmationContext/api.ts | 1 + .../refreshConfirmationContext/index.ts | 1 + .../tokenScanRefresher.test.ts | 158 ++++++++++++++++++ .../tokenScanRefresher.ts | 113 +++++++++++++ .../SecurityAlertsApiClient.test.ts | 62 +++++++ .../SecurityAlertsApiClient.ts | 65 +++++++ .../TransactionScanService.test.ts | 104 +++++++++++- .../TransactionScanService.ts | 37 +++- .../snap/src/services/transaction-scan/api.ts | 68 ++++++++ packages/snap/src/ui/confirmation/api.ts | 16 ++ .../components/TokenScanAlert.test.tsx | 128 ++++++++++++++ .../components/TokenScanAlert.tsx | 53 ++++++ .../src/ui/confirmation/components/index.ts | 1 + .../src/ui/confirmation/controller.test.tsx | 16 ++ .../snap/src/ui/confirmation/controller.tsx | 35 +++- .../snap/src/ui/confirmation/utils.test.ts | 78 ++++++++- packages/snap/src/ui/confirmation/utils.ts | 66 ++++++++ .../ConfirmSignChangeTrustOptIn.tsx | 35 +++- .../ConfirmSignChangeTrustOptOut.tsx | 35 +++- 24 files changed, 1096 insertions(+), 16 deletions(-) create mode 100644 packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts create mode 100644 packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts create mode 100644 packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx create mode 100644 packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx diff --git a/packages/snap/locales/es.json b/packages/snap/locales/es.json index d8fabbbc..dc812693 100644 --- a/packages/snap/locales/es.json +++ b/packages/snap/locales/es.json @@ -154,6 +154,18 @@ "confirmation.validationWarningSubtitle": { "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, + "confirmation.tokenScanMaliciousTitle": { + "message": "This asset may be malicious" + }, + "confirmation.tokenScanMaliciousSubtitle": { + "message": "Security Alerts found risk for {asset}. Only continue if you trust this asset." + }, + "confirmation.tokenScanWarningTitle": { + "message": "This asset may be risky" + }, + "confirmation.tokenScanWarningSubtitle": { + "message": "Security Alerts found potential risk for {asset}. Only continue if you trust this asset." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 864e39af..54859ac8 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "SK2tUsswSpgryEIHWiEXfwq0H/0eMiD3mHvH0Dau1nU=", + "shasum": "9A3IEQJH2Ii2/h1YMErJVY8yBBU3xIPal3O8Swd7BM4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/context.ts b/packages/snap/src/context.ts index d75aca89..392031a3 100644 --- a/packages/snap/src/context.ts +++ b/packages/snap/src/context.ts @@ -21,6 +21,7 @@ import { BackgroundEventMethod } from './handlers/cronjob/api'; import { ConfirmationPriceRefresher, ConfirmationScanRefresher, + ConfirmationTokenScanRefresher, ConfirmationTransactionRefresher, RefreshConfirmationContextHandler, } from './handlers/cronjob/refreshConfirmationContext'; @@ -189,6 +190,11 @@ const confirmationScanRefresher = new ConfirmationScanRefresher({ transactionScanService, }); +const confirmationTokenScanRefresher = new ConfirmationTokenScanRefresher({ + logger, + transactionScanService, +}); + const confirmationTransactionRefresher = new ConfirmationTransactionRefresher({ logger, transactionService, @@ -204,6 +210,7 @@ const refreshConfirmationContextHandler = new RefreshConfirmationContextHandler( refreshers: [ confirmationPriceRefresher, confirmationScanRefresher, + confirmationTokenScanRefresher, confirmationTransactionRefresher, ], }, diff --git a/packages/snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/packages/snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 7f293459..16b6b395 100644 --- a/packages/snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/packages/snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -255,6 +255,7 @@ describe('ChangeTrustOptHandler', () => { }, renderOptions: { loadPrice: true, + scanToken: true, scanTxn: true, validateTxn: true, }, @@ -269,6 +270,10 @@ describe('ChangeTrustOptHandler', () => { method: ClientRequestMethod.ChangeTrustOpt, }), }, + tokenScanRequest: { + assetReference: + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, }), ); const signedTransaction = signTransactionSpy.mock.calls[0]?.[0]; @@ -376,6 +381,7 @@ describe('ChangeTrustOptHandler', () => { interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, renderOptions: { loadPrice: true, + scanToken: true, scanTxn: true, validateTxn: true, }, @@ -390,6 +396,10 @@ describe('ChangeTrustOptHandler', () => { method: ClientRequestMethod.ChangeTrustOpt, }), }, + tokenScanRequest: { + assetReference: + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, }), ); expect(sendTransaction).toHaveBeenCalled(); diff --git a/packages/snap/src/handlers/clientRequest/changeTrustOpt.ts b/packages/snap/src/handlers/clientRequest/changeTrustOpt.ts index 91cf68c1..7ecf2813 100644 --- a/packages/snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/packages/snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -1,5 +1,5 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { ensureError } from '@metamask/utils'; +import { ensureError, parseCaipAssetType } from '@metamask/utils'; import type { ChangeTrustOptJsonRpcRequest, @@ -283,8 +283,9 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< transaction, confirmationInterfaceKey, } = params; - const { scope } = request.params; + const { assetId, scope } = request.params; const xdr = transaction.getRaw().toXDR(); + const { assetReference } = parseCaipAssetType(assetId); return ( (await this.#confirmationUIController.renderConfirmationDialog({ @@ -298,6 +299,7 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< interfaceKey: confirmationInterfaceKey, renderOptions: { loadPrice: true, + scanToken: true, scanTxn: true, validateTxn: true, }, @@ -310,6 +312,9 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< transaction: xdr, request, }, + tokenScanRequest: { + assetReference, + }, })) === true ); } diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/api.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/api.ts index 28f1fe52..be406f72 100644 --- a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/api.ts +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/api.ts @@ -7,6 +7,7 @@ import type { ContextWithPrices } from '../../../ui/confirmation/api'; export enum ConfirmationContextRefresherKey { Prices = 'prices', Scan = 'scan', + TokenScan = 'tokenScan', Transaction = 'transaction', } diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/index.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/index.ts index 8016bbd3..b74fc2c6 100644 --- a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/index.ts +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/index.ts @@ -1,6 +1,7 @@ export { RefreshConfirmationContextHandler } from './handler'; export { ConfirmationPriceRefresher } from './priceRefresher'; export { ConfirmationScanRefresher } from './scanRefresher'; +export { ConfirmationTokenScanRefresher } from './tokenScanRefresher'; export { ConfirmationTransactionRefresher } from './transactionRefresher'; export { ConfirmationContextRefresherKey, diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts new file mode 100644 index 00000000..65cf3ea2 --- /dev/null +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts @@ -0,0 +1,158 @@ +import { createConfirmationDataContext } from './__fixtures__/context.fixtures'; +import { ConfirmationContextRefresherKey } from './api'; +import { ConfirmationTokenScanRefresher } from './tokenScanRefresher'; +import { KnownCaip2ChainId } from '../../../api'; +import type { TransactionScanService } from '../../../services/transaction-scan'; +import { TokenScanResultType } from '../../../services/transaction-scan'; +import { FetchStatus } from '../../../ui/confirmation/api'; +import { logger } from '../../../utils/logger'; + +describe('ConfirmationTokenScanRefresher', () => { + const scope = KnownCaip2ChainId.Mainnet; + const tokenScanRequest = { + assetReference: + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + origin: 'https://example.com', + scope, + }; + const tokenScanResult = { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }; + + function setup() { + const transactionScanService: jest.Mocked< + Pick + > = { + scanToken: jest.fn().mockResolvedValue(tokenScanResult), + }; + const refresher = new ConfirmationTokenScanRefresher({ + logger, + transactionScanService: + transactionScanService as unknown as TransactionScanService, + }); + + return { refresher, transactionScanService }; + } + + function createTokenScanContext( + overrides: Parameters[0] = {}, + ) { + return createConfirmationDataContext({ + preferences: { + useSecurityAlerts: true, + simulateOnChainActions: true, + }, + tokenScanRequest, + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Fetching, + ...overrides, + }); + } + + it('returns fetched token scan data and reschedules on success', async () => { + const { refresher, transactionScanService } = setup(); + + const result = await refresher.refresh(createTokenScanContext()); + + expect(transactionScanService.scanToken).toHaveBeenCalledWith( + tokenScanRequest, + ); + expect(result).toStrictEqual({ + result: { + tokenScan: tokenScanResult, + tokenScanFetchStatus: FetchStatus.Fetched, + }, + reschedule: true, + }); + }); + + it('returns error status and stops rescheduling when scan returns null', async () => { + const { refresher, transactionScanService } = setup(); + transactionScanService.scanToken.mockResolvedValueOnce(null); + + const result = await refresher.refresh(createTokenScanContext()); + + expect(result).toStrictEqual({ + result: { + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Error, + }, + reschedule: false, + }); + }); + + it('does not fetch when Security Alerts are disabled', () => { + const { refresher } = setup(); + + expect( + refresher.shouldFetch( + createTokenScanContext({ + preferences: { + useSecurityAlerts: false, + simulateOnChainActions: true, + }, + }), + ), + ).toBe(false); + }); + + it('does not fetch when tokenScanRequest is missing', () => { + const { refresher } = setup(); + + expect( + refresher.shouldFetch( + createTokenScanContext({ + tokenScanRequest: undefined, + }), + ), + ).toBe(false); + }); + + it('writes fetched recovery when Security Alerts are disabled', () => { + const { refresher } = setup(); + + expect( + refresher.recoveryResult( + createTokenScanContext({ + preferences: { + useSecurityAlerts: false, + simulateOnChainActions: true, + }, + }), + ), + ).toStrictEqual({ + result: { + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Fetched, + }, + reschedule: false, + }); + }); + + it('writes recovery error when token scan prefs are enabled but request is missing', () => { + const { refresher } = setup(); + + expect( + refresher.recoveryResult( + createTokenScanContext({ + tokenScanRequest: undefined, + }), + ), + ).toStrictEqual({ + result: { + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Error, + }, + reschedule: false, + }); + }); + + it('uses the token scan refresher key', () => { + const { refresher } = setup(); + expect(refresher.key).toBe(ConfirmationContextRefresherKey.TokenScan); + }); +}); diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts new file mode 100644 index 00000000..7289f2e3 --- /dev/null +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts @@ -0,0 +1,113 @@ +import type { Json } from '@metamask/utils'; + +import { + ConfirmationContextRefresherKey, + type ConfirmationContextRefreshResult, + type ConfirmationDataContext, + type IConfirmationContextRefresher, +} from './api'; +import type { TransactionScanService } from '../../../services/transaction-scan'; +import type { ContextWithTokenScan } from '../../../ui/confirmation/api'; +import { + ContextWithTokenScanStruct, + FetchStatus, +} from '../../../ui/confirmation/api'; +import type { ILogger } from '../../../utils/logger'; +import { createPrefixedLogger } from '../../../utils/logger'; + +type TokenScanContext = ConfirmationDataContext & ContextWithTokenScan; + +/** + * Refreshes Blockaid token security scan results in change-trust confirmations. + */ +export class ConfirmationTokenScanRefresher implements IConfirmationContextRefresher { + readonly key = ConfirmationContextRefresherKey.TokenScan; + + readonly #transactionScanService: TransactionScanService; + + readonly #logger: ILogger; + + constructor({ + logger, + transactionScanService, + }: { + logger: ILogger; + transactionScanService: TransactionScanService; + }) { + this.#transactionScanService = transactionScanService; + this.#logger = createPrefixedLogger( + logger, + '[🔄 ConfirmationTokenScanRefresher]', + ); + } + + shouldFetch(ctx: ConfirmationDataContext): boolean { + const scanCtx = ctx as TokenScanContext; + if (scanCtx.tokenScanFetchStatus === FetchStatus.Error) { + return false; + } + if (!scanCtx.tokenScanRequest) { + return false; + } + return scanCtx.preferences.useSecurityAlerts; + } + + recoveryResult( + ctx: ConfirmationDataContext, + ): ConfirmationContextRefreshResult { + const scanCtx = ctx as TokenScanContext; + if (scanCtx.tokenScanFetchStatus !== FetchStatus.Fetching) { + return null; + } + + return { + result: { + tokenScan: null, + tokenScanFetchStatus: scanCtx.preferences.useSecurityAlerts + ? FetchStatus.Error + : FetchStatus.Fetched, + }, + reschedule: false, + }; + } + + async refresh( + ctx: ConfirmationDataContext, + ): Promise { + const scanCtx = ctx as TokenScanContext; + const tokenScanRequest = scanCtx.tokenScanRequest as NonNullable< + TokenScanContext['tokenScanRequest'] + >; + + try { + const tokenScan = + await this.#transactionScanService.scanToken(tokenScanRequest); + + return { + result: { + tokenScan, + tokenScanFetchStatus: tokenScan + ? FetchStatus.Fetched + : FetchStatus.Error, + }, + reschedule: tokenScan !== null, + }; + } catch (error) { + this.#logger.error( + 'Error refreshing confirmation token security scan:', + error, + ); + return { + result: { + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Error, + }, + reschedule: false, + }; + } + } + + isValidContext(ctx: Record): boolean { + return ContextWithTokenScanStruct.is(ctx); + } +} diff --git a/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts b/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts index 9efd0f17..a46f2dc9 100644 --- a/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts +++ b/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts @@ -4,6 +4,7 @@ import { TransactionScanException, TransactionScanOption, TransactionScanValidationType, + TokenScanResultType, } from '.'; import { KnownCaip2ChainId } from '../../api'; import { logger } from '../../utils/logger'; @@ -14,6 +15,8 @@ describe('SecurityAlertsApiClient', () => { const baseUrl = 'https://security-alerts.api.cx.metamask.io'; const accountAddress = 'GDPMFLKUGASUTWBN2XGYYKD27QGHCYH4BUFUTER4L23INYQ4JHDWFOIE'; + const assetReference = + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const transaction = 'AAAAAgAAAAA='; function setup(response: unknown = { validation: null, simulation: null }) { @@ -98,6 +101,44 @@ describe('SecurityAlertsApiClient', () => { }); }); + it('posts a Stellar token scan request', async () => { + const { client, fetchMock } = setup({ + result_type: TokenScanResultType.Benign, + chain: 'stellar', + address: assetReference, + metadata: { + name: 'USD Coin', + symbol: 'USDC', + }, + features: [], + }); + + await client.scanToken({ + chain: 'stellar', + address: assetReference, + origin: 'https://example.com/path', + }); + + expect(fetchMock).toHaveBeenCalledWith( + `${baseUrl}/token/scan`, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + }, + }), + ); + const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string); + expect(body).toStrictEqual({ + chain: 'stellar', + address: assetReference, + metadata: { + domain: 'https://example.com', + }, + }); + }); + it('throws TransactionScanException for HTTP errors', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: false, @@ -120,4 +161,25 @@ describe('SecurityAlertsApiClient', () => { }), ).rejects.toThrow(TransactionScanException); }); + + it('throws TransactionScanException for token scan HTTP errors', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + json: jest.fn(), + }); + const client = new SecurityAlertsApiClient( + { baseUrl }, + logger, + fetchMock as unknown as typeof globalThis.fetch, + ); + + await expect( + client.scanToken({ + chain: 'stellar', + address: assetReference, + origin: 'metamask', + }), + ).rejects.toThrow(TransactionScanException); + }); }); diff --git a/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.ts b/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.ts index fe7fc14d..65c7280d 100644 --- a/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.ts +++ b/packages/snap/src/services/transaction-scan/SecurityAlertsApiClient.ts @@ -2,10 +2,13 @@ import { assert } from '@metamask/superstruct'; import { + ScanTokenResponseStruct, StellarTransactionScanResponseStruct, type SecurityAlertsMetadata, + type ScanTokenResponse, type StellarTransactionScanRequest, type StellarTransactionScanResponse, + type TokenScanRequest, type TransactionScanOption, } from './api'; import { TransactionScanException } from './exceptions'; @@ -107,6 +110,56 @@ export class SecurityAlertsApiClient { } } + async scanToken({ + chain, + address, + origin, + }: { + chain: TokenScanRequest['chain']; + address: string; + origin: string; + }): Promise { + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/token/scan', + }); + + const requestBody: TokenScanRequest = { + chain, + address, + metadata: this.#getTokenMetadata(origin), + }; + + const response = await this.#fetch(url, { + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + }, + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new TransactionScanException( + `HTTP error! status: ${response.status}`, + ); + } + + const data = await response.json(); + assert(data, ScanTokenResponseStruct); + + return data; + } catch (error) { + this.#logger.logErrorWithDetails('Error scanning Stellar token', error); + return rethrowIfInstanceElseThrow( + error, + [TransactionScanException], + new TransactionScanException('Error scanning Stellar token'), + ); + } + } + #getMetadata(origin: string): SecurityAlertsMetadata { try { const url = new URL(origin); @@ -120,4 +173,16 @@ export class SecurityAlertsApiClient { }; } } + + #getTokenMetadata(origin: string): NonNullable { + try { + return { + domain: new URL(origin).origin, + }; + } catch { + return { + domain: origin, + }; + } + } } diff --git a/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts b/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts index 0a9130b7..c23379d1 100644 --- a/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts +++ b/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts @@ -1,4 +1,8 @@ -import { TransactionScanOption, TransactionScanValidationType } from './api'; +import { + TokenScanResultType, + TransactionScanOption, + TransactionScanValidationType, +} from './api'; import type { SecurityAlertsApiClient } from './SecurityAlertsApiClient'; import { TransactionScanService } from './TransactionScanService'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -10,6 +14,8 @@ jest.mock('../../utils/logger'); describe('TransactionScanService', () => { const accountAddress = 'GDPMFLKUGASUTWBN2XGYYKD27QGHCYH4BUFUTER4L23INYQ4JHDWFOIE'; + const assetReference = + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const scanParams = { accountAddress, origin: 'https://example.com', @@ -17,12 +23,18 @@ describe('TransactionScanService', () => { transaction: 'AAAAAgAAAAA=', options: [TransactionScanOption.Validation], }; + const tokenScanParams = { + assetReference, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + }; function setup() { const securityAlertsApiClient: jest.Mocked< - Pick + Pick > = { scanTransaction: jest.fn(), + scanToken: jest.fn(), }; const service = new TransactionScanService({ securityAlertsApiClient: @@ -225,4 +237,92 @@ describe('TransactionScanService', () => { const result = await service.scanTransaction(scanParams); expect(result).toBeNull(); }); + + it('maps malicious token scan responses', async () => { + const { service, securityAlertsApiClient } = setup(); + securityAlertsApiClient.scanToken.mockResolvedValue({ + result_type: TokenScanResultType.Malicious, + malicious_score: 1, + chain: 'stellar', + address: assetReference, + metadata: { + name: 'USD Coin', + symbol: 'USDC', + }, + features: [], + }); + + const result = await service.scanToken(tokenScanParams); + + expect(securityAlertsApiClient.scanToken).toHaveBeenCalledWith({ + chain: 'stellar', + address: assetReference, + origin: 'https://example.com', + }); + expect(result).toStrictEqual({ + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }); + }); + + it('maps warning token scan responses', async () => { + const { service, securityAlertsApiClient } = setup(); + securityAlertsApiClient.scanToken.mockResolvedValue({ + result_type: TokenScanResultType.Warning, + chain: 'stellar', + address: assetReference, + metadata: { + symbol: 'USDC', + }, + features: [], + }); + + const result = await service.scanToken(tokenScanParams); + + expect(result).toStrictEqual({ + resultType: TokenScanResultType.Warning, + isMalicious: false, + isWarning: true, + name: null, + symbol: 'USDC', + }); + }); + + it.each([ + TokenScanResultType.Benign, + TokenScanResultType.Verified, + TokenScanResultType.Trusted, + ])('maps %s token scan responses as safe', async (resultType) => { + const { service, securityAlertsApiClient } = setup(); + securityAlertsApiClient.scanToken.mockResolvedValue({ + result_type: resultType, + chain: 'stellar', + address: assetReference, + metadata: {}, + features: [], + }); + + const result = await service.scanToken(tokenScanParams); + + expect(result).toStrictEqual({ + resultType, + isMalicious: false, + isWarning: false, + name: null, + symbol: null, + }); + }); + + it('returns null when the token client throws', async () => { + const { service, securityAlertsApiClient } = setup(); + securityAlertsApiClient.scanToken.mockRejectedValue( + new Error('network error'), + ); + + const result = await service.scanToken(tokenScanParams); + expect(result).toBeNull(); + }); }); diff --git a/packages/snap/src/services/transaction-scan/TransactionScanService.ts b/packages/snap/src/services/transaction-scan/TransactionScanService.ts index 634d6209..8b35e328 100644 --- a/packages/snap/src/services/transaction-scan/TransactionScanService.ts +++ b/packages/snap/src/services/transaction-scan/TransactionScanService.ts @@ -1,7 +1,9 @@ -import { TransactionScanOption } from './api'; +import { TokenScanResultType, TransactionScanOption } from './api'; import type { + ScanTokenResponse, StellarAssetDiff, StellarTransactionScanResponse, + TokenScanResult, TransactionScanAssetChange, TransactionScanError, TransactionScanResult, @@ -61,6 +63,28 @@ export class TransactionScanService { } } + async scanToken({ + assetReference, + origin, + }: { + assetReference: string; + scope: KnownCaip2ChainId; + origin: string; + }): Promise { + try { + const result = await this.#securityAlertsApiClient.scanToken({ + chain: 'stellar', + address: assetReference, + origin, + }); + + return this.#mapTokenScan(result); + } catch (error) { + this.#logger.logErrorWithDetails('Error scanning Stellar token', error); + return null; + } + } + #mapScan( result: StellarTransactionScanResponse, options: TransactionScanOption[], @@ -100,6 +124,17 @@ export class TransactionScanService { }; } + #mapTokenScan(result: ScanTokenResponse): TokenScanResult { + const resultType = result.result_type as TokenScanResultType; + return { + resultType, + isMalicious: resultType === TokenScanResultType.Malicious, + isWarning: resultType === TokenScanResultType.Warning, + name: result.metadata?.name ?? null, + symbol: result.metadata?.symbol ?? null, + }; + } + #mapAssetChanges( assetDiffs: StellarAssetDiff[], ): TransactionScanAssetChange[] { diff --git a/packages/snap/src/services/transaction-scan/api.ts b/packages/snap/src/services/transaction-scan/api.ts index 75065f90..d0cfcdbe 100644 --- a/packages/snap/src/services/transaction-scan/api.ts +++ b/packages/snap/src/services/transaction-scan/api.ts @@ -2,6 +2,7 @@ import type { Infer } from '@metamask/superstruct'; import { array, + boolean, enums, literal, nullable, @@ -27,8 +28,20 @@ export enum TransactionScanValidationType { Malicious = 'Malicious', } +export enum TokenScanResultType { + Malicious = 'Malicious', + Warning = 'Warning', + Benign = 'Benign', + Trusted = 'Trusted', + Verified = 'Verified', + // eslint-disable-next-line @typescript-eslint/no-shadow + Error = 'Error', +} + export type StellarSecurityAlertsChain = 'pubnet' | 'testnet' | 'futurenet'; +export type TokenSecurityAlertsChain = 'stellar'; + export type SecurityAlertsMetadata = | { type: 'wallet'; @@ -46,6 +59,33 @@ export type StellarTransactionScanRequest = { options?: TransactionScanOption[]; }; +export type TokenScanRequest = { + chain: TokenSecurityAlertsChain; + address: string; + metadata?: { + domain: string; + }; +}; + +const TokenScanMetadataStruct = type({ + type: optional(string()), + name: optional(string()), + symbol: optional(string()), + decimals: optional(number()), +}); + +export const ScanTokenResponseStruct = type({ + result_type: enums(Object.values(TokenScanResultType)), + malicious_score: optional(number()), + attack_types: optional(array(string())), + chain: string(), + address: string(), + metadata: optional(TokenScanMetadataStruct), + features: optional(array(unknown())), +}); + +export type ScanTokenResponse = Infer; + const StellarAssetTransferDetailsStruct = type({ raw_value: number(), value: number(), @@ -193,6 +233,22 @@ export type TransactionScanResult = { error: TransactionScanError | null; }; +export type TokenScanResult = { + resultType: TokenScanResultType; + isMalicious: boolean; + isWarning: boolean; + name: string | null; + symbol: string | null; +}; + +export const TokenScanResultStruct = type({ + resultType: enums(Object.values(TokenScanResultType)), + isMalicious: boolean(), + isWarning: boolean(), + name: nullable(string()), + symbol: nullable(string()), +}); + export const SecurityScanRequestStruct = type({ accountAddress: string(), origin: string(), @@ -206,3 +262,15 @@ export type SecurityScanRequest = { scope: KnownCaip2ChainId; transaction: string; }; + +export const TokenSecurityScanRequestStruct = type({ + assetReference: string(), + origin: string(), + scope: enums(Object.values(KnownCaip2ChainId)), +}); + +export type TokenSecurityScanRequest = { + assetReference: string; + origin: string; + scope: KnownCaip2ChainId; +}; diff --git a/packages/snap/src/ui/confirmation/api.ts b/packages/snap/src/ui/confirmation/api.ts index 1915b67d..d91719fd 100644 --- a/packages/snap/src/ui/confirmation/api.ts +++ b/packages/snap/src/ui/confirmation/api.ts @@ -30,10 +30,14 @@ import { } from '../../handlers/clientRequest/api'; import { SecurityScanRequestStruct, + TokenScanResultStruct, + TokenSecurityScanRequestStruct, TransactionScanResultStruct, } from '../../services/transaction-scan'; import type { SecurityScanRequest, + TokenScanResult, + TokenSecurityScanRequest, TransactionScanResult, } from '../../services/transaction-scan'; @@ -83,6 +87,15 @@ export type ContextWithSecurityScan = Infer< typeof ContextWithSecurityScanStruct >; +export const ContextWithTokenScanStruct = type({ + preferences: SecurityScanPreferencesStruct, + tokenScan: optional(nullable(TokenScanResultStruct)), + tokenScanFetchStatus: enums(Object.values(FetchStatus)), + tokenScanRequest: optional(TokenSecurityScanRequestStruct), +}); + +export type ContextWithTokenScan = Infer; + /** * Context required to re-validate the pending transaction (time bounds, fees, * balance) against the latest on-chain state while the confirmation dialog is open. @@ -124,6 +137,9 @@ export type ConfirmationBaseProps = Partial & { scan?: TransactionScanResult | null; scanFetchStatus?: FetchStatus; securityScanRequest?: SecurityScanRequest; + tokenScan?: TokenScanResult | null; + tokenScanFetchStatus?: FetchStatus; + tokenScanRequest?: TokenSecurityScanRequest; transactionsFetchStatus?: FetchStatus; preferences: GetPreferencesResult; locale: string; diff --git a/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx b/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx new file mode 100644 index 00000000..acc25286 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx @@ -0,0 +1,128 @@ +import type { + ComponentOrElement, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; + +import { TokenScanResultType } from '../../../services/transaction-scan'; +import { FetchStatus } from '../api'; +import { TokenScanAlert } from './TokenScanAlert'; + +const preferences: GetPreferencesResult = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + useExternalPricingData: true, + showTestnets: true, +}; + +function getType(component: ComponentOrElement | null): string | undefined { + return typeof component === 'object' && component !== null + ? component.type + : undefined; +} + +function getProps( + component: ComponentOrElement | null, +): Record | undefined { + const candidate = component as { props?: Record }; + return typeof component === 'object' && component !== null + ? candidate.props + : undefined; +} + +describe('TokenScanAlert', () => { + it('renders malicious token scans as danger banners', () => { + const component = TokenScanAlert({ + preferences, + tokenScanFetchStatus: FetchStatus.Fetched, + tokenScan: { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'danger', + title: 'This asset may be malicious', + }); + }); + + it('renders warning token scans as warning banners', () => { + const component = TokenScanAlert({ + preferences, + tokenScanFetchStatus: FetchStatus.Fetched, + tokenScan: { + resultType: TokenScanResultType.Warning, + isMalicious: false, + isWarning: true, + name: null, + symbol: 'USDC', + }, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'warning', + title: 'This asset may be risky', + }); + }); + + it.each([ + TokenScanResultType.Benign, + TokenScanResultType.Verified, + TokenScanResultType.Trusted, + ])('renders nothing for %s token scans', (resultType) => { + const component = TokenScanAlert({ + preferences, + tokenScanFetchStatus: FetchStatus.Fetched, + tokenScan: { + resultType, + isMalicious: false, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }, + }); + + expect(component).toBeNull(); + }); + + it('renders nothing when Security Alerts are disabled', () => { + const component = TokenScanAlert({ + preferences: { + ...preferences, + useSecurityAlerts: false, + }, + tokenScanFetchStatus: FetchStatus.Fetched, + tokenScan: { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }, + }); + + expect(component).toBeNull(); + }); + + it('renders nothing while fetching', () => { + const component = TokenScanAlert({ + preferences, + tokenScanFetchStatus: FetchStatus.Fetching, + tokenScan: null, + }); + + expect(component).toBeNull(); + }); +}); diff --git a/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx new file mode 100644 index 00000000..af6904c6 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx @@ -0,0 +1,53 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Banner, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import type { TokenScanResult } from '../../../services/transaction-scan'; +import type { Locale } from '../../../utils'; +import { i18n } from '../../../utils'; +import type { ConfirmationBaseProps } from '../api'; +import { FetchStatus } from '../api'; + +type TokenScanAlertProps = { + preferences: ConfirmationBaseProps['preferences']; + tokenScan: TokenScanResult | null; + tokenScanFetchStatus: FetchStatus; +}; + +export const TokenScanAlert = ({ + preferences, + tokenScan, + tokenScanFetchStatus, +}: TokenScanAlertProps): ComponentOrElement | null => { + if ( + !preferences.useSecurityAlerts || + tokenScanFetchStatus !== FetchStatus.Fetched || + tokenScan === null || + (!tokenScan.isMalicious && !tokenScan.isWarning) + ) { + return null; + } + + const translate = i18n(preferences.locale as Locale); + const asset = + tokenScan.symbol ?? tokenScan.name ?? translate('confirmation.asset'); + + return ( + + + {translate( + tokenScan.isMalicious + ? 'confirmation.tokenScanMaliciousSubtitle' + : 'confirmation.tokenScanWarningSubtitle', + { asset }, + )} + + + ); +}; diff --git a/packages/snap/src/ui/confirmation/components/index.ts b/packages/snap/src/ui/confirmation/components/index.ts index 2f03a431..a3a33096 100644 --- a/packages/snap/src/ui/confirmation/components/index.ts +++ b/packages/snap/src/ui/confirmation/components/index.ts @@ -2,4 +2,5 @@ export * from './Fee'; export * from './AssetIcon'; export * from './Asset'; export * from './TransactionAlert'; +export * from './TokenScanAlert'; export * from './TransactionValidationAlert'; diff --git a/packages/snap/src/ui/confirmation/controller.test.tsx b/packages/snap/src/ui/confirmation/controller.test.tsx index 4a2f9769..2d2772e2 100644 --- a/packages/snap/src/ui/confirmation/controller.test.tsx +++ b/packages/snap/src/ui/confirmation/controller.test.tsx @@ -19,4 +19,20 @@ describe('ConfirmationUXController', () => { 'Cannot scan a transaction confirmation without a security scan request.', ); }); + + it('throws when token scanning is enabled without a token scan request', async () => { + const controller = new ConfirmationUXController({ logger: noOpLogger }); + + await expect( + controller.renderConfirmationDialog({ + scope: KnownCaip2ChainId.Mainnet, + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptIn, + fee: '100', + renderContext: {}, + renderOptions: { scanToken: true }, + }), + ).rejects.toThrow( + 'Cannot scan a token confirmation without a token scan request.', + ); + }); }); diff --git a/packages/snap/src/ui/confirmation/controller.tsx b/packages/snap/src/ui/confirmation/controller.tsx index 8c71aeca..939311ea 100644 --- a/packages/snap/src/ui/confirmation/controller.tsx +++ b/packages/snap/src/ui/confirmation/controller.tsx @@ -18,7 +18,10 @@ import type { ChangeTrustOptJsonRpcRequest, ConfirmSendJsonRpcRequest, } from '../../handlers/clientRequest/api'; -import type { SecurityScanRequest } from '../../services/transaction-scan'; +import type { + SecurityScanRequest, + TokenSecurityScanRequest, +} from '../../services/transaction-scan'; import type { ILogger, Locale } from '../../utils'; import { createInterface, @@ -57,6 +60,7 @@ type ConfirmationViewProps = Record; type ConfirmationRenderOptions = { loadPrice?: boolean; + scanToken?: boolean; scanTxn?: boolean; validateTxn?: boolean; }; @@ -78,6 +82,7 @@ type RenderConfirmationDialogCommon = { origin?: string; renderOptions?: ConfirmationRenderOptions; securityScanRequest?: Omit; + tokenScanRequest?: Omit; transactionValidationRequest?: TransactionValidationRequest; tokenPrices?: ContextWithPrices['tokenPrices']; }; @@ -111,6 +116,7 @@ export class ConfirmationUXController { readonly #defaultRenderOptions: ConfirmationRenderOptions = { loadPrice: false, + scanToken: false, scanTxn: false, validateTxn: false, }; @@ -157,6 +163,12 @@ export class ConfirmationUXController { ); } + if (renderOptions.scanToken && params.tokenScanRequest === undefined) { + throw new Error( + 'Cannot scan a token confirmation without a token scan request.', + ); + } + if ( renderOptions.validateTxn && params.transactionValidationRequest === undefined @@ -195,6 +207,11 @@ export class ConfirmationUXController { hasEnabledTransactionScan(preferences) && params.securityScanRequest !== undefined; + const enableTokenScan = + renderOptions.scanToken && + preferences.useSecurityAlerts && + params.tokenScanRequest !== undefined; + const enableTransactionValidation = renderOptions.validateTxn && params.transactionValidationRequest !== undefined; @@ -215,6 +232,10 @@ export class ConfirmationUXController { scanFetchStatus: enableSecurityScan ? FetchStatus.Fetching : FetchStatus.Fetched, + tokenScan: null, + tokenScanFetchStatus: enableTokenScan + ? FetchStatus.Fetching + : FetchStatus.Fetched, ...(enableSecurityScan ? { securityScanRequest: { @@ -224,6 +245,15 @@ export class ConfirmationUXController { }, } : {}), + ...(enableTokenScan + ? { + tokenScanRequest: { + ...params.tokenScanRequest, + origin, + scope, + }, + } + : {}), // Optimistic: tx was validated at build time, so keep confirm enabled; the // refresher flips to Error if it later drifts (submission rejects invalid txs too). // TODO(follow-up): re-validate synchronously right before signing. @@ -271,6 +301,9 @@ export class ConfirmationUXController { if (enableSecurityScan) { refresherKeys.push(ConfirmationContextRefresherKey.Scan); } + if (enableTokenScan) { + refresherKeys.push(ConfirmationContextRefresherKey.TokenScan); + } if (enableTransactionValidation) { refresherKeys.push(ConfirmationContextRefresherKey.Transaction); } diff --git a/packages/snap/src/ui/confirmation/utils.test.ts b/packages/snap/src/ui/confirmation/utils.test.ts index df7d12f0..a5120aec 100644 --- a/packages/snap/src/ui/confirmation/utils.test.ts +++ b/packages/snap/src/ui/confirmation/utils.test.ts @@ -3,9 +3,13 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { FetchStatus } from './api'; import { isConfirmDisabledByScan, + isConfirmDisabledByTokenScan, isConfirmDisabledByTransactionValidation, } from './utils'; -import { TransactionScanValidationType } from '../../services/transaction-scan'; +import { + TokenScanResultType, + TransactionScanValidationType, +} from '../../services/transaction-scan'; const preferences: GetPreferencesResult = { locale: 'en', @@ -94,6 +98,78 @@ describe('confirmation utils', () => { }); }); + describe('isConfirmDisabledByTokenScan', () => { + it('disables confirm while token scan is fetching', () => { + expect( + isConfirmDisabledByTokenScan({ + preferences, + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Fetching, + }), + ).toBe(true); + }); + + it.each([ + { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + }, + { + resultType: TokenScanResultType.Warning, + isMalicious: false, + isWarning: true, + }, + ])('disables confirm for $resultType token scans', (tokenScan) => { + expect( + isConfirmDisabledByTokenScan({ + preferences, + tokenScan: { + ...tokenScan, + name: 'USD Coin', + symbol: 'USDC', + }, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(true); + }); + + it('does not disable confirm for benign token scans', () => { + expect( + isConfirmDisabledByTokenScan({ + preferences, + tokenScan: { + resultType: TokenScanResultType.Benign, + isMalicious: false, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(false); + }); + + it('does not disable confirm when Security Alerts are disabled', () => { + expect( + isConfirmDisabledByTokenScan({ + preferences: { + ...preferences, + useSecurityAlerts: false, + }, + tokenScan: { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', + }, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(false); + }); + }); + describe('isConfirmDisabledByTransactionValidation', () => { it('disables confirm when re-validation reports an error', () => { expect(isConfirmDisabledByTransactionValidation(FetchStatus.Error)).toBe( diff --git a/packages/snap/src/ui/confirmation/utils.ts b/packages/snap/src/ui/confirmation/utils.ts index 520a8abb..cbfd8dc3 100644 --- a/packages/snap/src/ui/confirmation/utils.ts +++ b/packages/snap/src/ui/confirmation/utils.ts @@ -9,6 +9,7 @@ import { AppConfig } from '../../config'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { parseOperationAssetReference } from '../../services/transaction/utils'; import { + type TokenScanResult, TransactionScanValidationType, type TransactionScanResult, } from '../../services/transaction-scan'; @@ -190,6 +191,71 @@ export function isConfirmDisabledByScan(params: { ); } +/** + * Determines whether a change-trust confirmation must be blocked by token scan state. + * + * @param params - Token scan and preference state. + * @param params.preferences - User preferences controlling security alerts. + * @param params.tokenScan - Latest token scan result. + * @param params.tokenScanFetchStatus - Latest token scan fetch status. + * @returns True when the confirm action should be disabled. + */ +export function isConfirmDisabledByTokenScan(params: { + preferences: GetPreferencesResult; + tokenScan?: TokenScanResult | null; + tokenScanFetchStatus: FetchStatus; +}): boolean { + const { preferences, tokenScan, tokenScanFetchStatus } = params; + return ( + preferences.useSecurityAlerts && + (tokenScanFetchStatus === FetchStatus.Fetching || + tokenScan?.isMalicious === true || + tokenScan?.isWarning === true) + ); +} + +/** + * Determines whether the transaction scan alert would render. + * + * @param params - Scan and preference state. + * @param params.preferences - User preferences controlling scan behavior. + * @param params.scan - Latest transaction scan result. + * @param params.scanFetchStatus - Latest transaction scan fetch status. + * @returns True when the transaction scan banner should take priority. + */ +export function hasVisibleTransactionAlert(params: { + preferences: GetPreferencesResult; + scan?: TransactionScanResult | null; + scanFetchStatus: FetchStatus; +}): boolean { + const { preferences, scan, scanFetchStatus } = params; + + if ( + scanFetchStatus === FetchStatus.Fetching || + scanFetchStatus === FetchStatus.Error + ) { + return true; + } + + if (scan?.error) { + if (scan.error.type === 'simulation') { + return preferences.simulateOnChainActions; + } + + if (scan.error.type === 'validation') { + return preferences.useSecurityAlerts; + } + + return hasEnabledTransactionScan(preferences); + } + + return ( + preferences.useSecurityAlerts && + (scan?.validation?.type === TransactionScanValidationType.Malicious || + scan?.validation?.type === TransactionScanValidationType.Warning) + ); +} + /** * Determines whether transaction scan UI should be shown for the current preferences. * diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 75d18d74..4c69329a 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -30,16 +30,19 @@ import { Asset, AssetIcon, FeeRow, + TokenScanAlert, TransactionAlert, TransactionValidationAlert, } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, + getNetworkName, hasEnabledTransactionScan, + hasVisibleTransactionAlert, isConfirmDisabledByScan, + isConfirmDisabledByTokenScan, isConfirmDisabledByTransactionValidation, - getNetworkName, } from '../../utils'; export type ConfirmSignChangeTrustOptInProps = ConfirmationBaseProps & @@ -62,16 +65,34 @@ export const ConfirmSignChangeTrustOptIn = ({ tokenPricesFetchStatus = FetchStatus.Initial, scan, scanFetchStatus = FetchStatus.Initial, + tokenScan, + tokenScanFetchStatus = FetchStatus.Initial, transactionsFetchStatus = FetchStatus.Initial, }: ConfirmSignChangeTrustOptInProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; + const hasTransactionValidationError = + transactionsFetchStatus === FetchStatus.Error; + const showTransactionScanAlert = + !hasTransactionValidationError && + hasEnabledTransactionScan(preferences) && + hasVisibleTransactionAlert({ + preferences, + scan, + scanFetchStatus, + }); const shouldDisableConfirmButton = isConfirmDisabledByScan({ preferences, scan, scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + }) || + isConfirmDisabledByTokenScan({ + preferences, + tokenScan, + tokenScanFetchStatus, + }) || + isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -80,8 +101,7 @@ export const ConfirmSignChangeTrustOptIn = ({ preferences={preferences} transactionsFetchStatus={transactionsFetchStatus} /> - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( + {showTransactionScanAlert ? ( ) : null} + {!hasTransactionValidationError && !showTransactionScanAlert ? ( + + ) : null} {null} diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index bf101ccc..79cf9c59 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -30,16 +30,19 @@ import { Asset, AssetIcon, FeeRow, + TokenScanAlert, TransactionAlert, TransactionValidationAlert, } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, + getNetworkName, hasEnabledTransactionScan, + hasVisibleTransactionAlert, isConfirmDisabledByScan, + isConfirmDisabledByTokenScan, isConfirmDisabledByTransactionValidation, - getNetworkName, } from '../../utils'; export type ConfirmSignChangeTrustOptOutProps = ConfirmationBaseProps & @@ -62,16 +65,34 @@ export const ConfirmSignChangeTrustOptOut = ({ tokenPricesFetchStatus = FetchStatus.Initial, scan, scanFetchStatus = FetchStatus.Initial, + tokenScan, + tokenScanFetchStatus = FetchStatus.Initial, transactionsFetchStatus = FetchStatus.Initial, }: ConfirmSignChangeTrustOptOutProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; + const hasTransactionValidationError = + transactionsFetchStatus === FetchStatus.Error; + const showTransactionScanAlert = + !hasTransactionValidationError && + hasEnabledTransactionScan(preferences) && + hasVisibleTransactionAlert({ + preferences, + scan, + scanFetchStatus, + }); const shouldDisableConfirmButton = isConfirmDisabledByScan({ preferences, scan, scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + }) || + isConfirmDisabledByTokenScan({ + preferences, + tokenScan, + tokenScanFetchStatus, + }) || + isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -80,8 +101,7 @@ export const ConfirmSignChangeTrustOptOut = ({ preferences={preferences} transactionsFetchStatus={transactionsFetchStatus} /> - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( + {showTransactionScanAlert ? ( ) : null} + {!hasTransactionValidationError && !showTransactionScanAlert ? ( + + ) : null} {null} From 9f1bc00236f94b13bd952e79fd2ab5031c5538d4 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 4 Jun 2026 10:43:56 +0200 Subject: [PATCH 2/3] refactor: centralize confirmation banner priority into one tested helper --- packages/snap/locales/en.json | 12 ++ packages/snap/messages.json | 12 ++ packages/snap/snap.manifest.json | 2 +- .../tokenScanRefresher.test.ts | 7 +- .../tokenScanRefresher.ts | 14 +- .../TransactionScanService.test.ts | 1 - .../TransactionScanService.ts | 1 - .../components/TokenScanAlert.tsx | 14 +- .../snap/src/ui/confirmation/utils.test.ts | 182 ++++++++++++++++++ packages/snap/src/ui/confirmation/utils.ts | 86 +++++++++ .../ConfirmSendTransaction.tsx | 22 ++- .../ConfirmSignChangeTrustOptIn.tsx | 36 ++-- .../ConfirmSignChangeTrustOptOut.tsx | 36 ++-- 13 files changed, 365 insertions(+), 60 deletions(-) diff --git a/packages/snap/locales/en.json b/packages/snap/locales/en.json index d8fabbbc..dc812693 100644 --- a/packages/snap/locales/en.json +++ b/packages/snap/locales/en.json @@ -154,6 +154,18 @@ "confirmation.validationWarningSubtitle": { "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, + "confirmation.tokenScanMaliciousTitle": { + "message": "This asset may be malicious" + }, + "confirmation.tokenScanMaliciousSubtitle": { + "message": "Security Alerts found risk for {asset}. Only continue if you trust this asset." + }, + "confirmation.tokenScanWarningTitle": { + "message": "This asset may be risky" + }, + "confirmation.tokenScanWarningSubtitle": { + "message": "Security Alerts found potential risk for {asset}. Only continue if you trust this asset." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/packages/snap/messages.json b/packages/snap/messages.json index 75a7a1c9..af34293c 100644 --- a/packages/snap/messages.json +++ b/packages/snap/messages.json @@ -152,6 +152,18 @@ "confirmation.validationWarningSubtitle": { "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, + "confirmation.tokenScanMaliciousTitle": { + "message": "This asset may be malicious" + }, + "confirmation.tokenScanMaliciousSubtitle": { + "message": "Security Alerts found risk for {asset}. Only continue if you trust this asset." + }, + "confirmation.tokenScanWarningTitle": { + "message": "This asset may be risky" + }, + "confirmation.tokenScanWarningSubtitle": { + "message": "Security Alerts found potential risk for {asset}. Only continue if you trust this asset." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 54859ac8..323ca0a7 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "9A3IEQJH2Ii2/h1YMErJVY8yBBU3xIPal3O8Swd7BM4=", + "shasum": "8i6WlgYciUTEOKG5lWfM44irjZjKSQEUpIypaS2Lkoc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts index 65cf3ea2..488325de 100644 --- a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.test.ts @@ -58,9 +58,10 @@ describe('ConfirmationTokenScanRefresher', () => { const result = await refresher.refresh(createTokenScanContext()); - expect(transactionScanService.scanToken).toHaveBeenCalledWith( - tokenScanRequest, - ); + expect(transactionScanService.scanToken).toHaveBeenCalledWith({ + assetReference: tokenScanRequest.assetReference, + origin: tokenScanRequest.origin, + }); expect(result).toStrictEqual({ result: { tokenScan: tokenScanResult, diff --git a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts index 7289f2e3..bdaef474 100644 --- a/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts +++ b/packages/snap/src/handlers/cronjob/refreshConfirmationContext/tokenScanRefresher.ts @@ -75,13 +75,17 @@ export class ConfirmationTokenScanRefresher implements IConfirmationContextRefre ctx: ConfirmationDataContext, ): Promise { const scanCtx = ctx as TokenScanContext; - const tokenScanRequest = scanCtx.tokenScanRequest as NonNullable< - TokenScanContext['tokenScanRequest'] - >; + const { tokenScanRequest } = scanCtx; + + if (!tokenScanRequest) { + return this.recoveryResult(ctx); + } try { - const tokenScan = - await this.#transactionScanService.scanToken(tokenScanRequest); + const tokenScan = await this.#transactionScanService.scanToken({ + assetReference: tokenScanRequest.assetReference, + origin: tokenScanRequest.origin, + }); return { result: { diff --git a/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts b/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts index c23379d1..5530fcb3 100644 --- a/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts +++ b/packages/snap/src/services/transaction-scan/TransactionScanService.test.ts @@ -26,7 +26,6 @@ describe('TransactionScanService', () => { const tokenScanParams = { assetReference, origin: 'https://example.com', - scope: KnownCaip2ChainId.Mainnet, }; function setup() { diff --git a/packages/snap/src/services/transaction-scan/TransactionScanService.ts b/packages/snap/src/services/transaction-scan/TransactionScanService.ts index 8b35e328..42ca4ef6 100644 --- a/packages/snap/src/services/transaction-scan/TransactionScanService.ts +++ b/packages/snap/src/services/transaction-scan/TransactionScanService.ts @@ -68,7 +68,6 @@ export class TransactionScanService { origin, }: { assetReference: string; - scope: KnownCaip2ChainId; origin: string; }): Promise { try { diff --git a/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx index af6904c6..14f46909 100644 --- a/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx +++ b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx @@ -4,8 +4,8 @@ import { Banner, Text as SnapText } from '@metamask/snaps-sdk/jsx'; import type { TokenScanResult } from '../../../services/transaction-scan'; import type { Locale } from '../../../utils'; import { i18n } from '../../../utils'; -import type { ConfirmationBaseProps } from '../api'; -import { FetchStatus } from '../api'; +import type { ConfirmationBaseProps, FetchStatus } from '../api'; +import { hasVisibleTokenScanAlert } from '../utils'; type TokenScanAlertProps = { preferences: ConfirmationBaseProps['preferences']; @@ -19,10 +19,12 @@ export const TokenScanAlert = ({ tokenScanFetchStatus, }: TokenScanAlertProps): ComponentOrElement | null => { if ( - !preferences.useSecurityAlerts || - tokenScanFetchStatus !== FetchStatus.Fetched || - tokenScan === null || - (!tokenScan.isMalicious && !tokenScan.isWarning) + !hasVisibleTokenScanAlert({ + preferences, + tokenScan, + tokenScanFetchStatus, + }) || + tokenScan === null ) { return null; } diff --git a/packages/snap/src/ui/confirmation/utils.test.ts b/packages/snap/src/ui/confirmation/utils.test.ts index a5120aec..0a1b14c2 100644 --- a/packages/snap/src/ui/confirmation/utils.test.ts +++ b/packages/snap/src/ui/confirmation/utils.test.ts @@ -2,9 +2,12 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { FetchStatus } from './api'; import { + ConfirmationBanner, + hasVisibleTokenScanAlert, isConfirmDisabledByScan, isConfirmDisabledByTokenScan, isConfirmDisabledByTransactionValidation, + resolveConfirmationBanner, } from './utils'; import { TokenScanResultType, @@ -25,6 +28,52 @@ const preferences: GetPreferencesResult = { showTestnets: true, }; +const maliciousTokenScan = { + resultType: TokenScanResultType.Malicious, + isMalicious: true, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', +}; + +const warningTokenScan = { + resultType: TokenScanResultType.Warning, + isMalicious: false, + isWarning: true, + name: 'USD Coin', + symbol: 'USDC', +}; + +const benignTokenScan = { + resultType: TokenScanResultType.Benign, + isMalicious: false, + isWarning: false, + name: 'USD Coin', + symbol: 'USDC', +}; + +const maliciousTransactionScan = { + status: 'SUCCESS' as const, + estimatedChanges: { assets: [] }, + validation: { + type: TransactionScanValidationType.Malicious, + reason: 'known_attacker', + description: null, + }, + error: null, +}; + +const benignTransactionScan = { + status: 'SUCCESS' as const, + estimatedChanges: { assets: [] }, + validation: { + type: TransactionScanValidationType.Benign, + reason: null, + description: null, + }, + error: null, +}; + describe('confirmation utils', () => { describe('isConfirmDisabledByScan', () => { it('disables confirm while scan is fetching', () => { @@ -170,6 +219,139 @@ describe('confirmation utils', () => { }); }); + describe('hasVisibleTokenScanAlert', () => { + it.each([maliciousTokenScan, warningTokenScan])( + 'returns true for $resultType token scans', + (tokenScan) => { + expect( + hasVisibleTokenScanAlert({ + preferences, + tokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(true); + }, + ); + + it('returns false for benign token scans', () => { + expect( + hasVisibleTokenScanAlert({ + preferences, + tokenScan: benignTokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(false); + }); + + it('returns false while token scan is fetching', () => { + expect( + hasVisibleTokenScanAlert({ + preferences, + tokenScan: maliciousTokenScan, + tokenScanFetchStatus: FetchStatus.Fetching, + }), + ).toBe(false); + }); + + it('returns false when Security Alerts are disabled', () => { + expect( + hasVisibleTokenScanAlert({ + preferences: { + ...preferences, + useSecurityAlerts: false, + }, + tokenScan: maliciousTokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(false); + }); + }); + + describe('resolveConfirmationBanner', () => { + it('returns validation error when transaction validation fails despite visible scan alerts', () => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Error, + scan: maliciousTransactionScan, + scanFetchStatus: FetchStatus.Fetched, + tokenScan: maliciousTokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.ValidationError); + }); + + it('returns transaction scan when transaction and token scan alerts are visible', () => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Fetched, + scan: maliciousTransactionScan, + scanFetchStatus: FetchStatus.Fetched, + tokenScan: maliciousTokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.TransactionScan); + }); + + it.each([maliciousTokenScan, warningTokenScan])( + 'returns token scan for $resultType token-only alerts', + (tokenScan) => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Fetched, + scan: benignTransactionScan, + scanFetchStatus: FetchStatus.Fetched, + tokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.TokenScan); + }, + ); + + it('returns none when token args are omitted and nothing else is visible', () => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Fetched, + scan: benignTransactionScan, + scanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.None); + }); + + it.each([ + { + label: 'benign scan results', + preferences, + scan: benignTransactionScan, + tokenScan: benignTokenScan, + }, + { + label: 'disabled preferences', + preferences: { + ...preferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }, + scan: maliciousTransactionScan, + tokenScan: maliciousTokenScan, + }, + ])('returns none for $label', (params) => { + expect( + resolveConfirmationBanner({ + preferences: params.preferences, + transactionsFetchStatus: FetchStatus.Fetched, + scan: params.scan, + scanFetchStatus: FetchStatus.Fetched, + tokenScan: params.tokenScan, + tokenScanFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.None); + }); + }); + describe('isConfirmDisabledByTransactionValidation', () => { it('disables confirm when re-validation reports an error', () => { expect(isConfirmDisabledByTransactionValidation(FetchStatus.Error)).toBe( diff --git a/packages/snap/src/ui/confirmation/utils.ts b/packages/snap/src/ui/confirmation/utils.ts index cbfd8dc3..ed2c85a8 100644 --- a/packages/snap/src/ui/confirmation/utils.ts +++ b/packages/snap/src/ui/confirmation/utils.ts @@ -206,6 +206,7 @@ export function isConfirmDisabledByTokenScan(params: { tokenScanFetchStatus: FetchStatus; }): boolean { const { preferences, tokenScan, tokenScanFetchStatus } = params; + // Token-scan/API errors fail open by design, matching the Tron snap; unlike transaction validation, a scan Error must not block legitimate trustline ops. return ( preferences.useSecurityAlerts && (tokenScanFetchStatus === FetchStatus.Fetching || @@ -214,6 +215,28 @@ export function isConfirmDisabledByTokenScan(params: { ); } +/** + * Determines whether the token scan alert would render. + * + * @param params - Token scan and preference state. + * @param params.preferences - User preferences controlling security alerts. + * @param params.tokenScan - Latest token scan result. + * @param params.tokenScanFetchStatus - Latest token scan fetch status. + * @returns True when the token scan banner should be visible. + */ +export function hasVisibleTokenScanAlert(params: { + preferences: GetPreferencesResult; + tokenScan?: TokenScanResult | null; + tokenScanFetchStatus: FetchStatus; +}): boolean { + const { preferences, tokenScan, tokenScanFetchStatus } = params; + return ( + preferences.useSecurityAlerts && + tokenScanFetchStatus === FetchStatus.Fetched && + (tokenScan?.isMalicious === true || tokenScan?.isWarning === true) + ); +} + /** * Determines whether the transaction scan alert would render. * @@ -256,6 +279,69 @@ export function hasVisibleTransactionAlert(params: { ); } +export enum ConfirmationBanner { + None = 'none', + ValidationError = 'validationError', + TransactionScan = 'transactionScan', + TokenScan = 'tokenScan', +} + +/** + * Resolves the single confirmation banner to render. Banners are mutually + * exclusive and use this priority: transaction validation error, transaction + * scan alert, then token scan alert. + * + * @param params - Confirmation banner source state. + * @param params.preferences - User preferences controlling scan behavior. + * @param params.transactionsFetchStatus - Latest transaction validation fetch status. + * @param params.scan - Latest transaction scan result. + * @param params.scanFetchStatus - Latest transaction scan fetch status. + * @param params.tokenScan - Latest token scan result. + * @param params.tokenScanFetchStatus - Latest token scan fetch status. + * @returns The highest-priority banner to render. + */ +export function resolveConfirmationBanner(params: { + preferences: GetPreferencesResult; + transactionsFetchStatus: FetchStatus; + scan?: TransactionScanResult | null; + scanFetchStatus: FetchStatus; + tokenScan?: TokenScanResult | null; + tokenScanFetchStatus?: FetchStatus; +}): ConfirmationBanner { + const { + preferences, + transactionsFetchStatus, + scan, + scanFetchStatus, + tokenScan, + tokenScanFetchStatus, + } = params; + + if (transactionsFetchStatus === FetchStatus.Error) { + return ConfirmationBanner.ValidationError; + } + + if ( + hasEnabledTransactionScan(preferences) && + hasVisibleTransactionAlert({ preferences, scan, scanFetchStatus }) + ) { + return ConfirmationBanner.TransactionScan; + } + + if ( + tokenScanFetchStatus !== undefined && + hasVisibleTokenScanAlert({ + preferences, + tokenScan, + tokenScanFetchStatus, + }) + ) { + return ConfirmationBanner.TokenScan; + } + + return ConfirmationBanner.None; +} + /** * Determines whether transaction scan UI should be shown for the current preferences. * diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index a6317db5..87fbf83f 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -34,14 +34,15 @@ import { TransactionValidationAlert, } from '../../components'; import { + ConfirmationBanner, getAccountExplorerUrl, getAccountName, getClassicAssetExplorerUrl, getNetworkName, getSepAssetExplorerUrl, - hasEnabledTransactionScan, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, + resolveConfirmationBanner, } from '../../utils'; export type ConfirmSendTransactionProps = ConfirmationBaseProps & @@ -74,6 +75,12 @@ export const ConfirmSendTransaction = ({ const t = i18n(locale); const { address } = account; const { assetId, symbol } = assetMetadata; + const banner = resolveConfirmationBanner({ + preferences, + transactionsFetchStatus, + scan, + scanFetchStatus, + }); const shouldDisableConfirmButton = isConfirmDisabledByScan({ preferences, @@ -94,12 +101,13 @@ export const ConfirmSendTransaction = ({ return ( - - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( + {banner === ConfirmationBanner.ValidationError ? ( + + ) : null} + {banner === ConfirmationBanner.TransactionScan ? ( { const t = i18n(locale); const { address } = account; - const hasTransactionValidationError = - transactionsFetchStatus === FetchStatus.Error; - const showTransactionScanAlert = - !hasTransactionValidationError && - hasEnabledTransactionScan(preferences) && - hasVisibleTransactionAlert({ - preferences, - scan, - scanFetchStatus, - }); + const banner = resolveConfirmationBanner({ + preferences, + transactionsFetchStatus, + scan, + scanFetchStatus, + tokenScan, + tokenScanFetchStatus, + }); const shouldDisableConfirmButton = isConfirmDisabledByScan({ preferences, @@ -97,11 +95,13 @@ export const ConfirmSignChangeTrustOptIn = ({ return ( - - {showTransactionScanAlert ? ( + {banner === ConfirmationBanner.ValidationError ? ( + + ) : null} + {banner === ConfirmationBanner.TransactionScan ? ( ) : null} - {!hasTransactionValidationError && !showTransactionScanAlert ? ( + {banner === ConfirmationBanner.TokenScan ? ( { const t = i18n(locale); const { address } = account; - const hasTransactionValidationError = - transactionsFetchStatus === FetchStatus.Error; - const showTransactionScanAlert = - !hasTransactionValidationError && - hasEnabledTransactionScan(preferences) && - hasVisibleTransactionAlert({ - preferences, - scan, - scanFetchStatus, - }); + const banner = resolveConfirmationBanner({ + preferences, + transactionsFetchStatus, + scan, + scanFetchStatus, + tokenScan, + tokenScanFetchStatus, + }); const shouldDisableConfirmButton = isConfirmDisabledByScan({ preferences, @@ -97,11 +95,13 @@ export const ConfirmSignChangeTrustOptOut = ({ return ( - - {showTransactionScanAlert ? ( + {banner === ConfirmationBanner.ValidationError ? ( + + ) : null} + {banner === ConfirmationBanner.TransactionScan ? ( ) : null} - {!hasTransactionValidationError && !showTransactionScanAlert ? ( + {banner === ConfirmationBanner.TokenScan ? ( Date: Thu, 4 Jun 2026 18:10:30 +0200 Subject: [PATCH 3/3] fix: fix copilot ai comments --- packages/snap/snap.manifest.json | 2 +- .../components/TokenScanAlert.test.tsx | 8 +++++-- .../components/TokenScanAlert.tsx | 23 +++++++++++++++++-- .../snap/src/ui/confirmation/utils.test.ts | 19 ++++++++++++--- packages/snap/src/ui/confirmation/utils.ts | 9 +++++--- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 323ca0a7..69e88b98 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "8i6WlgYciUTEOKG5lWfM44irjZjKSQEUpIypaS2Lkoc=", + "shasum": "j03va6Qf2cKvp/SDabpqW5ZSUdJ4/OnFKCwgSmg0baY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx b/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx index acc25286..5a7b14a1 100644 --- a/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx +++ b/packages/snap/src/ui/confirmation/components/TokenScanAlert.test.tsx @@ -116,13 +116,17 @@ describe('TokenScanAlert', () => { expect(component).toBeNull(); }); - it('renders nothing while fetching', () => { + it('renders an info banner while fetching', () => { const component = TokenScanAlert({ preferences, tokenScanFetchStatus: FetchStatus.Fetching, tokenScan: null, }); - expect(component).toBeNull(); + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'info', + title: 'Checking for security issues', + }); }); }); diff --git a/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx index 14f46909..1c20666d 100644 --- a/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx +++ b/packages/snap/src/ui/confirmation/components/TokenScanAlert.tsx @@ -4,7 +4,8 @@ import { Banner, Text as SnapText } from '@metamask/snaps-sdk/jsx'; import type { TokenScanResult } from '../../../services/transaction-scan'; import type { Locale } from '../../../utils'; import { i18n } from '../../../utils'; -import type { ConfirmationBaseProps, FetchStatus } from '../api'; +import type { ConfirmationBaseProps } from '../api'; +import { FetchStatus } from '../api'; import { hasVisibleTokenScanAlert } from '../utils'; type TokenScanAlertProps = { @@ -24,12 +25,30 @@ export const TokenScanAlert = ({ tokenScan, tokenScanFetchStatus, }) || - tokenScan === null + (tokenScanFetchStatus === FetchStatus.Fetched && tokenScan === null) ) { return null; } const translate = i18n(preferences.locale as Locale); + + if (tokenScanFetchStatus === FetchStatus.Fetching) { + return ( + + + {translate('confirmation.securityScanInProgressMessage')} + + + ); + } + + if (tokenScan === null) { + return null; + } + const asset = tokenScan.symbol ?? tokenScan.name ?? translate('confirmation.asset'); diff --git a/packages/snap/src/ui/confirmation/utils.test.ts b/packages/snap/src/ui/confirmation/utils.test.ts index 0a1b14c2..2d008f61 100644 --- a/packages/snap/src/ui/confirmation/utils.test.ts +++ b/packages/snap/src/ui/confirmation/utils.test.ts @@ -243,14 +243,14 @@ describe('confirmation utils', () => { ).toBe(false); }); - it('returns false while token scan is fetching', () => { + it('returns true while token scan is fetching', () => { expect( hasVisibleTokenScanAlert({ preferences, - tokenScan: maliciousTokenScan, + tokenScan: null, tokenScanFetchStatus: FetchStatus.Fetching, }), - ).toBe(false); + ).toBe(true); }); it('returns false when Security Alerts are disabled', () => { @@ -310,6 +310,19 @@ describe('confirmation utils', () => { }, ); + it('returns token scan while token scan is fetching', () => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Fetched, + scan: benignTransactionScan, + scanFetchStatus: FetchStatus.Fetched, + tokenScan: null, + tokenScanFetchStatus: FetchStatus.Fetching, + }), + ).toBe(ConfirmationBanner.TokenScan); + }); + it('returns none when token args are omitted and nothing else is visible', () => { expect( resolveConfirmationBanner({ diff --git a/packages/snap/src/ui/confirmation/utils.ts b/packages/snap/src/ui/confirmation/utils.ts index ed2c85a8..92949e46 100644 --- a/packages/snap/src/ui/confirmation/utils.ts +++ b/packages/snap/src/ui/confirmation/utils.ts @@ -206,7 +206,9 @@ export function isConfirmDisabledByTokenScan(params: { tokenScanFetchStatus: FetchStatus; }): boolean { const { preferences, tokenScan, tokenScanFetchStatus } = params; - // Token-scan/API errors fail open by design, matching the Tron snap; unlike transaction validation, a scan Error must not block legitimate trustline ops. + // Token-scan/API errors fail open by design, matching the Tron snap. + // Unlike transaction validation, a scan Error must not block legitimate + // trustline ops. return ( preferences.useSecurityAlerts && (tokenScanFetchStatus === FetchStatus.Fetching || @@ -232,8 +234,9 @@ export function hasVisibleTokenScanAlert(params: { const { preferences, tokenScan, tokenScanFetchStatus } = params; return ( preferences.useSecurityAlerts && - tokenScanFetchStatus === FetchStatus.Fetched && - (tokenScan?.isMalicious === true || tokenScan?.isWarning === true) + (tokenScanFetchStatus === FetchStatus.Fetching || + (tokenScanFetchStatus === FetchStatus.Fetched && + (tokenScan?.isMalicious === true || tokenScan?.isWarning === true))) ); }