diff --git a/packages/snap/images/icon.svg b/packages/snap/images/icon.svg index 02afb7b7..2165da62 100644 --- a/packages/snap/images/icon.svg +++ b/packages/snap/images/icon.svg @@ -1 +1 @@ -Asset 1 \ No newline at end of file +Stellar diff --git a/packages/snap/locales/en.json b/packages/snap/locales/en.json index d8fabbbc..efb2bf57 100644 --- a/packages/snap/locales/en.json +++ b/packages/snap/locales/en.json @@ -43,6 +43,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -155,10 +173,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/packages/snap/locales/es.json b/packages/snap/locales/es.json index d8fabbbc..efb2bf57 100644 --- a/packages/snap/locales/es.json +++ b/packages/snap/locales/es.json @@ -43,6 +43,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -155,10 +173,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/packages/snap/messages.json b/packages/snap/messages.json index 75a7a1c9..e39efb5d 100644 --- a/packages/snap/messages.json +++ b/packages/snap/messages.json @@ -41,6 +41,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -153,10 +171,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 3633ead6..f689f8ba 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": "YP/x/UxnvPyYy6yLPeQM2j1PZR8D8I4xUrgqoUnWbSE=", + "shasum": "0bXKXi1JKlz9Tl+t7sHnw2HInhPpNtkrTIrUdRD/n4I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/handlers/user-input/userInput.ts b/packages/snap/src/handlers/user-input/userInput.ts index 50a525b7..d55768ba 100644 --- a/packages/snap/src/handlers/user-input/userInput.ts +++ b/packages/snap/src/handlers/user-input/userInput.ts @@ -7,6 +7,7 @@ import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../u import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; +import { createEventHandlers as createMaliciousAcknowledgementEvents } from '../../ui/confirmation/views/MaliciousAcknowledgement/events'; import { withCatchAndThrowSnapError, createPrefixedLogger, @@ -51,6 +52,7 @@ export class UserInputHandler { ...createSignChangeTrustOptInEvents(), ...createSignChangeTrustOptOutEvents(), ...createConfirmSendTransactionEvents(), + ...createMaliciousAcknowledgementEvents(), }; /** diff --git a/packages/snap/src/ui/confirmation/api.ts b/packages/snap/src/ui/confirmation/api.ts index 1915b67d..cd6c8032 100644 --- a/packages/snap/src/ui/confirmation/api.ts +++ b/packages/snap/src/ui/confirmation/api.ts @@ -131,4 +131,11 @@ export type ConfirmationBaseProps = Partial & { networkImage: string | null; origin: string; feeData?: FeeData; + // Identifies the active view so shared event handlers (e.g. the malicious + // acknowledgement screen) can re-render the correct confirmation. + interfaceKey?: ConfirmationInterfaceKey; + // True while the malicious acknowledgement screen is shown over the confirmation. + acknowledgementScreen?: boolean; + // Whether the user has checked the "I acknowledge the risk" box on that screen. + acknowledged?: boolean; }; diff --git a/packages/snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx b/packages/snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx new file mode 100644 index 00000000..47995beb --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx @@ -0,0 +1,102 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + +import { ConfirmationFooter } from './ConfirmationFooter'; +import { i18n } from '../../../utils'; +import { MaliciousAcknowledgementFormNames } from '../views/MaliciousAcknowledgement/constants'; + +const translate = i18n('en'); + +type Element = { + type?: string; + props?: Record; +}; + +/** + * Finds the first button element in the tree with the given name. + * + * @param node - The element to search. + * @param name - The button name to match. + * @returns The matching button props, or undefined. + */ +function findButton( + node: ComponentOrElement | null, + name: string, +): Record | undefined { + if (typeof node !== 'object' || node === null) { + return undefined; + } + const element = node as Element; + if (element.type === 'Button' && element.props?.name === name) { + return element.props; + } + const children = element.props?.children; + const list = Array.isArray(children) ? children : [children]; + for (const child of list) { + const found = findButton(child as ComponentOrElement | null, name); + if (found) { + return found; + } + } + return undefined; +} + +describe('ConfirmationFooter', () => { + const baseProps = { + locale: 'en', + cancelButtonName: 'cancel', + confirmButtonName: 'confirm', + }; + + it('renders the confirm button when acknowledgement is not required', () => { + const footer = ConfirmationFooter({ ...baseProps }); + + const confirm = findButton(footer, 'confirm'); + expect(confirm).toBeDefined(); + expect(confirm?.children).toBe(translate('confirmation.confirmButton')); + expect( + findButton(footer, MaliciousAcknowledgementFormNames.Review), + ).toBeUndefined(); + }); + + it('disables the confirm button when confirmDisabled is true', () => { + const footer = ConfirmationFooter({ ...baseProps, confirmDisabled: true }); + + expect(findButton(footer, 'confirm')?.disabled).toBe(true); + }); + + it('renders the review-alerts button when acknowledgement is required', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + }); + + const review = findButton(footer, MaliciousAcknowledgementFormNames.Review); + expect(review).toBeDefined(); + expect(review?.children).toBe(translate('confirmation.reviewAlertsButton')); + expect(findButton(footer, 'confirm')).toBeUndefined(); + }); + + it('falls back to the disabled confirm button when blocked, even if acknowledgement is required', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + confirmDisabled: true, + }); + + expect( + findButton(footer, MaliciousAcknowledgementFormNames.Review), + ).toBeUndefined(); + expect(findButton(footer, 'confirm')?.disabled).toBe(true); + }); + + it('always renders the cancel button', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + }); + + expect(findButton(footer, 'cancel')?.children).toBe( + translate('confirmation.cancelButton'), + ); + }); +}); diff --git a/packages/snap/src/ui/confirmation/components/ConfirmationFooter.tsx b/packages/snap/src/ui/confirmation/components/ConfirmationFooter.tsx new file mode 100644 index 00000000..7fd24199 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/ConfirmationFooter.tsx @@ -0,0 +1,61 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Button, Footer } from '@metamask/snaps-sdk/jsx'; + +import type { Locale } from '../../../utils'; +import { i18n } from '../../../utils'; +import { MaliciousAcknowledgementFormNames } from '../views/MaliciousAcknowledgement/constants'; + +type ConfirmationFooterProps = { + locale: string; + cancelButtonName: string; + confirmButtonName: string; + confirmDisabled?: boolean; + // When true, the primary button becomes "Review alerts" and routes the user + // through the malicious acknowledgement screen instead of confirming directly. + requiresAcknowledgement?: boolean; +}; + +/** + * Shared confirmation footer (cancel + primary button). + * + * Centralizes the malicious-acknowledgement behavior: a malicious scan result + * swaps the primary button from "Confirm" to "Review alerts" rather than + * disabling it, so the user keeps a "proceed anyway" path behind friction. + * + * A blocking state (`confirmDisabled`, e.g. failed background re-validation) + * takes priority over the acknowledgement swap: we fall back to the disabled + * "Confirm" button so the user can never enter the acknowledgement flow for a + * transaction that is no longer valid. + * + * @param props - The footer props. + * @param props.locale - The active locale. + * @param props.cancelButtonName - Event name for the cancel button. + * @param props.confirmButtonName - Event name for the confirm button. + * @param props.confirmDisabled - Whether the confirm button is disabled. + * @param props.requiresAcknowledgement - Whether to show "Review alerts" instead of "Confirm". + * @returns The footer. + */ +export const ConfirmationFooter = ({ + locale, + cancelButtonName, + confirmButtonName, + confirmDisabled = false, + requiresAcknowledgement = false, +}: ConfirmationFooterProps): ComponentOrElement => { + const t = i18n(locale as Locale); + + return ( +
+ + {requiresAcknowledgement && !confirmDisabled ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/snap/src/ui/confirmation/components/index.ts b/packages/snap/src/ui/confirmation/components/index.ts index 204010ef..09ced0da 100644 --- a/packages/snap/src/ui/confirmation/components/index.ts +++ b/packages/snap/src/ui/confirmation/components/index.ts @@ -4,3 +4,4 @@ export * from './Asset'; export * from './TransactionAlert'; export * from './TransactionValidationAlert'; export * from './ConfirmationAlerts'; +export * from './ConfirmationFooter'; diff --git a/packages/snap/src/ui/confirmation/controller.tsx b/packages/snap/src/ui/confirmation/controller.tsx index 8c71aeca..c608b34d 100644 --- a/packages/snap/src/ui/confirmation/controller.tsx +++ b/packages/snap/src/ui/confirmation/controller.tsx @@ -1,10 +1,9 @@ -import type { ComponentOrElement, DialogResult } from '@metamask/snaps-sdk'; -import type { Json } from '@metamask/utils'; +import type { DialogResult } from '@metamask/snaps-sdk'; import { - ConfirmationInterfaceKey, - type ContextWithPrices, FetchStatus, + type ConfirmationInterfaceKey, + type ContextWithPrices, } from './api'; import { formatFeeData, @@ -29,32 +28,15 @@ import { updateInterfaceIfExists, } from '../../utils'; import { STELLAR_IMAGE } from '../images/icon'; -import type { ConfirmSendTransactionProps } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; -import { ConfirmSendTransaction } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; -import { - ConfirmSignAuthEntry, - type ConfirmSignAuthEntryProps, -} from './views/ConfirmSignAuthEntry/ConfirmSignAuthEntry'; -import type { ConfirmSignChangeTrustOptInProps } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; -import { ConfirmSignChangeTrustOptIn } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; -import type { ConfirmSignChangeTrustOptOutProps } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; -import { ConfirmSignChangeTrustOptOut } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; -import { - ConfirmSignMessage, - type ConfirmSignMessageProps, -} from './views/ConfirmSignMessage/ConfirmSignMessage'; import { - ConfirmSignTransaction, - type ConfirmSignTransactionProps, -} from './views/ConfirmSignTransaction/ConfirmSignTransaction'; + renderConfirmationView, + type ConfirmationViewProps, +} from './views/render'; import { ConfirmationContextRefresherKey, RefreshConfirmationContextHandler, } from '../../handlers/cronjob/refreshConfirmationContext'; -/** Serializable props bag stored on the interface and merged into each view. */ -type ConfirmationViewProps = Record; - type ConfirmationRenderOptions = { loadPrice?: boolean; scanTxn?: boolean; @@ -200,6 +182,9 @@ export class ConfirmationUXController { params.transactionValidationRequest !== undefined; const defaultContext = { + // Persisted so shared event handlers (malicious acknowledgement screen) + // can re-render the correct confirmation view. + interfaceKey, // if pricing is disabled, mark as fetched immediately tokenPricesFetchStatus: enablePricing ? FetchStatus.Fetching @@ -246,7 +231,7 @@ export class ConfirmationUXController { // 2. Initial render with loading skeleton (always show loading if pricing enabled) const id = await createInterface( - this.#renderConfirmationView(interfaceKey, context), + renderConfirmationView(interfaceKey, context), {}, ); const dialogPromise = showDialog(id); @@ -254,7 +239,7 @@ export class ConfirmationUXController { // 3. Update interface context after initial render (silently ignores if dismissed) const updated = await updateInterfaceIfExists( id, - this.#renderConfirmationView(interfaceKey, context), + renderConfirmationView(interfaceKey, context), context, ); @@ -315,52 +300,8 @@ export class ConfirmationUXController { const { interfaceId, updatedContext, interfaceKey } = params; await updateInterfaceIfExists( interfaceId, - this.#renderConfirmationView(interfaceKey, updatedContext), + renderConfirmationView(interfaceKey, updatedContext), updatedContext, ); } - - #renderConfirmationView( - interfaceKey: ConfirmationInterfaceKey, - context: ConfirmationViewProps, - ): ComponentOrElement { - switch (interfaceKey) { - case ConfirmationInterfaceKey.ChangeTrustlineOptIn: - return ( - - ); - case ConfirmationInterfaceKey.ChangeTrustlineOptOut: - return ( - - ); - case ConfirmationInterfaceKey.SignTransaction: - return ( - - ); - case ConfirmationInterfaceKey.SignMessage: - return ; - case ConfirmationInterfaceKey.SignAuthEntry: - return ( - - ); - case ConfirmationInterfaceKey.ConfirmSendTransaction: - return ( - - ); - default: { - const exhaustive: never = interfaceKey; - throw new Error(`Unsupported interface key: ${String(exhaustive)}`); - } - } - } } diff --git a/packages/snap/src/ui/confirmation/utils.test.ts b/packages/snap/src/ui/confirmation/utils.test.ts index 0170766d..05f02c38 100644 --- a/packages/snap/src/ui/confirmation/utils.test.ts +++ b/packages/snap/src/ui/confirmation/utils.test.ts @@ -1,107 +1,130 @@ -import { defaultPreferences as preferences } from './__fixtures__/confirmation.fixtures'; +import { + defaultPreferences as preferences, + maliciousScan, +} from './__fixtures__/confirmation.fixtures'; import { FetchStatus } from './api'; import { ConfirmationBanner, - isConfirmDisabledByScan, - isConfirmDisabledByTransactionValidation, + isLocalTransactionValidationFailed, + isRemoteTransactionScanLoading, + requiresMaliciousAcknowledgement, resolveConfirmationBanner, + shouldDisableConfirmation, } from './utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; +const warningScan = { + ...maliciousScan, + validation: { + type: TransactionScanValidationType.Warning, + reason: 'suspicious_request', + description: null, + }, +}; + describe('confirmation utils', () => { - describe('isConfirmDisabledByScan', () => { + describe('isRemoteTransactionScanLoading', () => { it('disables confirm while scan is fetching', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: null, + isRemoteTransactionScanLoading({ scanFetchStatus: FetchStatus.Fetching, }), ).toBe(true); }); - it('disables confirm for malicious validation alerts', () => { + it('does not disable confirm once the scan has fetched', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: { - status: 'SUCCESS', - estimatedChanges: { assets: [] }, - validation: { - type: TransactionScanValidationType.Malicious, - reason: 'known_attacker', - description: null, - }, - error: null, - }, + isRemoteTransactionScanLoading({ scanFetchStatus: FetchStatus.Fetched, }), + ).toBe(false); + }); + + it('does not disable confirm when the scan fetch status is not fetching', () => { + expect( + isRemoteTransactionScanLoading({ scanFetchStatus: FetchStatus.Error }), + ).toBe(false); + }); + }); + + describe('shouldDisableConfirmation', () => { + it('blocks while the scan is fetching', () => { + expect( + shouldDisableConfirmation({ scanFetchStatus: FetchStatus.Fetching }), ).toBe(true); }); - it('does not disable confirm for simulation errors', () => { + it('blocks when re-validation reports an error', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: { - status: 'ERROR', - estimatedChanges: { assets: [] }, - validation: null, - error: { - type: 'simulation', - code: 'insufficient_balance', - message: 'insufficient_balance', - }, - }, + shouldDisableConfirmation({ scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Error, }), - ).toBe(false); + ).toBe(true); }); - it('does not disable confirm for malicious validation when security alerts are disabled', () => { + it('does not block when scan is fetched and re-validation is clean', () => { expect( - isConfirmDisabledByScan({ - preferences: { - ...preferences, - useSecurityAlerts: false, - }, - scan: { - status: 'SUCCESS', - estimatedChanges: { assets: [] }, - validation: { - type: TransactionScanValidationType.Malicious, - reason: 'known_attacker', - description: null, - }, - error: null, - }, + shouldDisableConfirmation({ scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Fetched, }), ).toBe(false); }); + + it('does not block when both statuses are omitted', () => { + expect(shouldDisableConfirmation({})).toBe(false); + }); }); - describe('isConfirmDisabledByTransactionValidation', () => { - it('disables confirm when re-validation reports an error', () => { - expect(isConfirmDisabledByTransactionValidation(FetchStatus.Error)).toBe( - true, - ); + describe('requiresMaliciousAcknowledgement', () => { + it('requires acknowledgement for a malicious result when security alerts are enabled', () => { + expect( + requiresMaliciousAcknowledgement({ preferences, scan: maliciousScan }), + ).toBe(true); }); - it('does not disable confirm while re-validation is fetching', () => { + it('does not require acknowledgement when security alerts are disabled', () => { expect( - isConfirmDisabledByTransactionValidation(FetchStatus.Fetching), + requiresMaliciousAcknowledgement({ + preferences: { ...preferences, useSecurityAlerts: false }, + scan: maliciousScan, + }), ).toBe(false); }); - it('does not disable confirm when re-validation has fetched', () => { + it('does not require acknowledgement for warning-level results', () => { + expect( + requiresMaliciousAcknowledgement({ preferences, scan: warningScan }), + ).toBe(false); + }); + + it('does not require acknowledgement when there is no scan result', () => { expect( - isConfirmDisabledByTransactionValidation(FetchStatus.Fetched), + requiresMaliciousAcknowledgement({ preferences, scan: null }), ).toBe(false); }); + }); + + describe('isLocalTransactionValidationFailed', () => { + it('disables confirm when re-validation reports an error', () => { + expect(isLocalTransactionValidationFailed(FetchStatus.Error)).toBe(true); + }); + + it('does not disable confirm while re-validation is fetching', () => { + expect(isLocalTransactionValidationFailed(FetchStatus.Fetching)).toBe( + false, + ); + }); + + it('does not disable confirm when re-validation has fetched', () => { + expect(isLocalTransactionValidationFailed(FetchStatus.Fetched)).toBe( + false, + ); + }); it('does not disable confirm when the status is undefined', () => { - expect(isConfirmDisabledByTransactionValidation(undefined)).toBe(false); + expect(isLocalTransactionValidationFailed(undefined)).toBe(false); }); }); diff --git a/packages/snap/src/ui/confirmation/utils.ts b/packages/snap/src/ui/confirmation/utils.ts index e1b811f7..dc6c8743 100644 --- a/packages/snap/src/ui/confirmation/utils.ts +++ b/packages/snap/src/ui/confirmation/utils.ts @@ -169,24 +169,44 @@ export function formatFeeData( } /** - * Determines whether a transaction confirmation must be temporarily blocked by scan state. + * Determines whether the remote (Blockaid) transaction scan is still loading. + * + * @param params - Scan state. + * @param params.scanFetchStatus - Latest transaction scan fetch status. + * @returns True while the remote scan is still in flight. + */ +export function isRemoteTransactionScanLoading(params: { + scanFetchStatus: FetchStatus; +}): boolean { + // We only block while the scan is still running. A malicious result no longer + // disables confirm: per product/Blockaid, the user must always retain a + // "proceed anyway" path, gated behind the malicious acknowledgement screen + // (see {@link requiresMaliciousAcknowledgement}). + return params.scanFetchStatus === FetchStatus.Fetching; +} + +/** + * Determines whether a malicious scan result requires explicit user + * acknowledgement before the transaction can be confirmed. + * + * When true, the confirmation footer swaps its primary button to "Review alerts" + * and routes the user through the acknowledgement screen instead of confirming + * directly. Warning-level results intentionally do not require this (reduced + * friction): they show the banner only. * * @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 confirm action should be disabled. + * @returns True when the user must acknowledge a malicious result to proceed. */ -export function isConfirmDisabledByScan(params: { +export function requiresMaliciousAcknowledgement(params: { preferences: GetPreferencesResult; scan?: TransactionScanResult | null; - scanFetchStatus: FetchStatus; }): boolean { - const { preferences, scan, scanFetchStatus } = params; + const { preferences, scan } = params; return ( - scanFetchStatus === FetchStatus.Fetching || - (preferences.useSecurityAlerts && - scan?.validation?.type === TransactionScanValidationType.Malicious) + preferences.useSecurityAlerts && + scan?.validation?.type === TransactionScanValidationType.Malicious ); } @@ -203,18 +223,42 @@ export function hasEnabledTransactionScan( } /** - * Determines whether the confirm action must be blocked because background - * re-validation found the pending transaction is no longer valid. + * Determines whether local background re-validation found the pending + * transaction is no longer valid. * - * @param transactionsFetchStatus - Latest transaction validation fetch status. - * @returns True when the confirm action should be disabled. + * @param transactionsFetchStatus - Latest transaction re-validation fetch status. + * @returns True when local re-validation has failed. */ -export function isConfirmDisabledByTransactionValidation( +export function isLocalTransactionValidationFailed( transactionsFetchStatus: FetchStatus | undefined, ): boolean { return transactionsFetchStatus === FetchStatus.Error; } +/** + * Single source of truth for whether the confirm action must be disabled. + * + * Combines the remote-scan and local-re-validation guards so the confirmation + * footer and the malicious-acknowledgement proceed handler can never drift + * apart. Flows without background re-validation (e.g. dapp sign-transaction) + * simply omit `transactionsFetchStatus`, which is treated as "not blocked". + * + * @param params - Latest fetch state. + * @param params.scanFetchStatus - Latest transaction scan fetch status. + * @param params.transactionsFetchStatus - Latest transaction re-validation fetch status. + * @returns True when the confirm action should be disabled. + */ +export function shouldDisableConfirmation(params: { + scanFetchStatus?: FetchStatus; + transactionsFetchStatus?: FetchStatus; +}): boolean { + return ( + isRemoteTransactionScanLoading({ + scanFetchStatus: params.scanFetchStatus ?? FetchStatus.Initial, + }) || isLocalTransactionValidationFailed(params.transactionsFetchStatus) + ); +} + /** * The single banner the confirmation screen may show at the top. * @@ -254,7 +298,7 @@ export function resolveConfirmationBanner(params: { // already validated at build time), so the banner exists solely on Error. // - Scan is preference-driven: while scan prefs are on, TransactionAlert owns // the full async lifecycle (in-progress -> result/error -> null for benign). - if (isConfirmDisabledByTransactionValidation(transactionsFetchStatus)) { + if (isLocalTransactionValidationFailed(transactionsFetchStatus)) { return ConfirmationBanner.TransactionValidation; } diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index 115c9d0a..53b7e1d9 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -27,15 +25,20 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, ConfirmationAlerts, FeeRow } from '../../components'; +import { + Asset, + ConfirmationAlerts, + ConfirmationFooter, + FeeRow, +} from '../../components'; import { getAccountExplorerUrl, getAccountName, getClassicAssetExplorerUrl, getNetworkName, getSepAssetExplorerUrl, - isConfirmDisabledByScan, - isConfirmDisabledByTransactionValidation, + requiresMaliciousAcknowledgement, + shouldDisableConfirmation, } from '../../utils'; export type ConfirmSendTransactionProps = ConfirmationBaseProps & @@ -68,12 +71,10 @@ export const ConfirmSendTransaction = ({ const t = i18n(locale); const { address } = account; const { assetId, symbol } = assetMetadata; - const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + const shouldDisableConfirmButton = shouldDisableConfirmation({ + scanFetchStatus, + transactionsFetchStatus, + }); const parsedAsset = parseCaipAssetType(assetId); let assetLink: string | undefined; if (!isSlip44Id(assetId)) { @@ -184,17 +185,16 @@ export const ConfirmSendTransaction = ({ /> -
- - -
+ ); }; diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 2f71122e..c2dd786e 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -26,13 +24,19 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, AssetIcon, ConfirmationAlerts, FeeRow } from '../../components'; +import { + Asset, + AssetIcon, + ConfirmationAlerts, + ConfirmationFooter, + FeeRow, +} from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, - isConfirmDisabledByScan, - isConfirmDisabledByTransactionValidation, getNetworkName, + requiresMaliciousAcknowledgement, + shouldDisableConfirmation, } from '../../utils'; export type ConfirmSignChangeTrustOptInProps = ConfirmationBaseProps & @@ -59,12 +63,10 @@ export const ConfirmSignChangeTrustOptIn = ({ }: ConfirmSignChangeTrustOptInProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; - const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + const shouldDisableConfirmButton = shouldDisableConfirmation({ + scanFetchStatus, + transactionsFetchStatus, + }); return ( @@ -155,17 +157,16 @@ export const ConfirmSignChangeTrustOptIn = ({ /> -
- - -
+
); }; diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index 0fc632d9..f308c605 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -26,13 +24,19 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, AssetIcon, ConfirmationAlerts, FeeRow } from '../../components'; +import { + Asset, + AssetIcon, + ConfirmationAlerts, + ConfirmationFooter, + FeeRow, +} from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, - isConfirmDisabledByScan, - isConfirmDisabledByTransactionValidation, getNetworkName, + requiresMaliciousAcknowledgement, + shouldDisableConfirmation, } from '../../utils'; export type ConfirmSignChangeTrustOptOutProps = ConfirmationBaseProps & @@ -59,12 +63,10 @@ export const ConfirmSignChangeTrustOptOut = ({ }: ConfirmSignChangeTrustOptOutProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; - const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + const shouldDisableConfirmButton = shouldDisableConfirmation({ + scanFetchStatus, + transactionsFetchStatus, + }); return ( @@ -155,17 +157,16 @@ export const ConfirmSignChangeTrustOptOut = ({ /> -
- - -
+
); }; diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index b75ba77e..3a49be8c 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -26,14 +24,16 @@ import { STELLAR_IMAGE } from '../../../images/icon'; import type { ConfirmationBaseProps, FeeData } from '../../api'; import { FetchStatus } from '../../api'; import { Asset } from '../../components/Asset'; +import { ConfirmationFooter } from '../../components/ConfirmationFooter'; import { FeeRow } from '../../components/Fee'; import { TransactionAlert } from '../../components/TransactionAlert'; import { getAccountName, getNetworkName, hasEnabledTransactionScan, - isConfirmDisabledByScan, + requiresMaliciousAcknowledgement, resolveAssetDisplay, + shouldDisableConfirmation, } from '../../utils'; export type ConfirmSignTransactionProps = Omit< @@ -177,9 +177,9 @@ export const ConfirmSignTransaction = ({ const addressCaip10 = getAccountName(scope, address); const priceLoading = tokenPricesFetchStatus === FetchStatus.Fetching; const feePrice = tokenPrices?.[feeData.assetId] ?? null; - const shouldDisableConfirmButton = isConfirmDisabledByScan({ - preferences, - scan, + // Sign-transaction has no local re-validation (no validateTxn step), so only + // the remote-scan-loading guard applies here. + const shouldDisableConfirmButton = shouldDisableConfirmation({ scanFetchStatus, }); @@ -308,17 +308,16 @@ export const ConfirmSignTransaction = ({ ))} -
- - -
+ ); }; diff --git a/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx new file mode 100644 index 00000000..5287af42 --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx @@ -0,0 +1,101 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import { MaliciousAcknowledgementScreen } from './MaliciousAcknowledgementScreen'; + +type Element = { + type?: string; + props?: Record; +}; + +/** + * Recursively finds the first element matching a predicate. + * + * @param node - The element to search. + * @param match - Predicate over an element. + * @returns The matching element props, or undefined. + */ +function find( + node: ComponentOrElement | null, + match: (element: Element) => boolean, +): Record | undefined { + if (typeof node !== 'object' || node === null) { + return undefined; + } + const element = node as Element; + if (match(element)) { + return element.props; + } + const children = element.props?.children; + const list = Array.isArray(children) ? children : [children]; + for (const child of list) { + const found = find(child as ComponentOrElement | null, match); + if (found) { + return found; + } + } + return undefined; +} + +const byNamed = (type: string, name: string) => (element: Element) => + element.type === type && element.props?.name === name; + +const findButton = (node: ComponentOrElement | null, name: string) => + find(node, byNamed('Button', name)); + +const isBanner = (element: Element) => element.type === 'Banner'; + +describe('MaliciousAcknowledgementScreen', () => { + it('renders a danger banner with the malicious warning copy', () => { + const screen = MaliciousAcknowledgementScreen({ locale: 'en' }); + + const banner = find(screen, isBanner); + expect(banner).toMatchObject({ + severity: 'danger', + title: 'Malicious request', + }); + }); + + it('disables the proceed button until the risk is acknowledged', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: false, + }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Proceed)?.disabled, + ).toBe(true); + }); + + it('enables the proceed button once the risk is acknowledged', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: true, + }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Proceed)?.disabled, + ).toBe(false); + }); + + it('reflects the acknowledgement state on the checkbox', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: true, + }); + + const checkbox = find( + screen, + byNamed('Checkbox', MaliciousAcknowledgementFormNames.Acknowledge), + ); + expect(checkbox?.checked).toBe(true); + }); + + it('renders a back button', () => { + const screen = MaliciousAcknowledgementScreen({ locale: 'en' }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Back), + ).toBeDefined(); + }); +}); diff --git a/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx new file mode 100644 index 00000000..1e31bb5a --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx @@ -0,0 +1,68 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Banner, + Box, + Button, + Checkbox, + Container, + Footer, + Heading, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import type { Locale } from '../../../../utils'; +import { i18n } from '../../../../utils'; +import type { ConfirmationBaseProps } from '../../api'; + +export type MaliciousAcknowledgementScreenProps = { + locale: ConfirmationBaseProps['locale']; + acknowledged?: boolean; +}; + +/** + * Friction screen shown when the user chooses to review a malicious-scan alert. + * + * The user cannot be outright blocked, so this screen forces an explicit + * acknowledgement: "Confirm" stays disabled until the risk checkbox is checked. + * + * @param props - The screen props. + * @param props.locale - The active locale. + * @param props.acknowledged - Whether the risk checkbox is currently checked. + * @returns The acknowledgement screen. + */ +export const MaliciousAcknowledgementScreen = ({ + locale, + acknowledged = false, +}: MaliciousAcknowledgementScreenProps): ComponentOrElement => { + const t = i18n(locale as Locale); + + return ( + + + + {t('confirmation.maliciousAck.title')} + + + {t('confirmation.maliciousAck.description')} + + + +
+ + +
+
+ ); +}; diff --git a/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts new file mode 100644 index 00000000..949b9bc5 --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts @@ -0,0 +1,12 @@ +/** + * Shared form element names for the malicious acknowledgement screen. + * + * The screen is reused across every scanned confirmation flow, so these names + * live in one place and are handled by a single set of event handlers. + */ +export enum MaliciousAcknowledgementFormNames { + Review = 'malicious-acknowledgement-review', + Acknowledge = 'malicious-acknowledgement-checkbox', + Proceed = 'malicious-acknowledgement-proceed', + Back = 'malicious-acknowledgement-back', +} diff --git a/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx new file mode 100644 index 00000000..0150165d --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx @@ -0,0 +1,203 @@ +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import { createEventHandlers } from './events'; +import { resolveInterface, updateInterfaceIfExists } from '../../../../utils'; +import { ConfirmationInterfaceKey, FetchStatus } from '../../api'; +import { renderConfirmationView } from '../render'; + +jest.mock('../render', () => ({ + renderConfirmationView: jest.fn(() => 'RENDERED'), +})); + +jest.mock('../../../../utils', () => ({ + ...jest.requireActual('../../../../utils'), + resolveInterface: jest.fn(), + updateInterfaceIfExists: jest.fn(), +})); + +const INTERFACE_ID = 'interface-id'; + +const baseContext = { + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + locale: 'en', + acknowledgementScreen: false, + acknowledged: false, +}; + +const buttonEvent = (name: string): UserInputEvent => + ({ type: UserInputEventType.ButtonClickEvent, name }) as UserInputEvent; + +const checkboxEvent = (name: string, value: boolean): UserInputEvent => + ({ + type: UserInputEventType.InputChangeEvent, + name, + value, + }) as unknown as UserInputEvent; + +describe('malicious acknowledgement events', () => { + const handlers = createEventHandlers(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(renderConfirmationView) + .mockReturnValue( + 'RENDERED' as unknown as ReturnType, + ); + }); + + it('opens the acknowledgement screen on "Review alerts"', async () => { + const name = MaliciousAcknowledgementFormNames.Review; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: baseContext, + }); + + const expectedContext = { + ...baseContext, + acknowledgementScreen: true, + acknowledged: false, + }; + expect(renderConfirmationView).toHaveBeenCalledWith( + ConfirmationInterfaceKey.ConfirmSendTransaction, + expectedContext, + ); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expectedContext, + ); + expect(resolveInterface).not.toHaveBeenCalled(); + }); + + it('tracks the acknowledgement checkbox value', async () => { + const name = MaliciousAcknowledgementFormNames.Acknowledge; + await handlers[name]?.({ + id: INTERFACE_ID, + event: checkboxEvent(name, true), + context: { ...baseContext, acknowledgementScreen: true }, + }); + + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ acknowledged: true }), + ); + }); + + it('resolves the interface on "Confirm"', async () => { + const name = MaliciousAcknowledgementFormNames.Proceed; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + }, + }); + + expect(resolveInterface).toHaveBeenCalledWith(INTERFACE_ID, true); + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); + + it('does not resolve on "Confirm" when the risk is not acknowledged', async () => { + const name = MaliciousAcknowledgementFormNames.Proceed; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: false, + }, + }); + + expect(resolveInterface).not.toHaveBeenCalled(); + }); + + it('returns to the confirmation view on "Confirm" when background re-validation failed', async () => { + const name = MaliciousAcknowledgementFormNames.Proceed; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + transactionsFetchStatus: FetchStatus.Error, + }, + }); + + expect(resolveInterface).not.toHaveBeenCalled(); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ + acknowledgementScreen: false, + acknowledged: false, + }), + ); + }); + + it('returns to the confirmation view on "Confirm" while the scan is still fetching', async () => { + const name = MaliciousAcknowledgementFormNames.Proceed; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + scanFetchStatus: FetchStatus.Fetching, + }, + }); + + expect(resolveInterface).not.toHaveBeenCalled(); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ + acknowledgementScreen: false, + acknowledged: false, + }), + ); + }); + + it('returns to the confirmation view on "Go back"', async () => { + const name = MaliciousAcknowledgementFormNames.Back; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + }, + }); + + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ + acknowledgementScreen: false, + acknowledged: false, + }), + ); + expect(resolveInterface).not.toHaveBeenCalled(); + }); + + it('does nothing when the interface context is missing', async () => { + const name = MaliciousAcknowledgementFormNames.Review; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: null, + }); + + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx new file mode 100644 index 00000000..dfa12267 --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx @@ -0,0 +1,140 @@ +import type { InputChangeEvent } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface, updateInterfaceIfExists } from '../../../../utils'; +import type { ConfirmationInterfaceKey, FetchStatus } from '../../api'; +import { shouldDisableConfirmation } from '../../utils'; +import { renderConfirmationView } from '../render'; + +/** + * Re-renders the interface with a patched context. + * + * @param id - The interface id. + * @param context - The current interface context. + * @param patch - The context fields to override. + */ +async function reRender( + id: string, + context: Record, + patch: Record, +): Promise { + const nextContext = { ...context, ...patch }; + const interfaceKey = context.interfaceKey as ConfirmationInterfaceKey; + await updateInterfaceIfExists( + id, + renderConfirmationView(interfaceKey, nextContext), + nextContext, + ); +} + +/** + * Opens the malicious acknowledgement screen when the user clicks "Review alerts". + * + * @param options - The user input handler context. + */ +async function onReviewClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, context } = options; + if (!context) { + return; + } + await reRender(id, context, { + acknowledgementScreen: true, + acknowledged: false, + }); +} + +/** + * Tracks the risk-acknowledgement checkbox so the "Confirm" button can enable. + * + * @param options - The user input handler context. + */ +async function onAcknowledgeChange( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, event, context } = options; + if (!context) { + return; + } + const acknowledged = Boolean((event as InputChangeEvent).value); + await reRender(id, context, { acknowledged }); +} + +/** + * Confirms the transaction after the user acknowledged the malicious-scan risk. + * + * The proceed button is already disabled in the UI until the box is checked; + * this re-checks `acknowledged` defensively so we never resolve the interface + * for an unacknowledged risk. + * + * It also re-applies the confirmation footer's block guard: while the user was + * on the acknowledgement screen, a background refresher may have invalidated the + * transaction (failed re-validation) or left the scan pending. Rather than + * silently swallowing the click, we send the user back to the confirmation view + * where the disabled Confirm button and the validation/scan banner explain why they can't proceed. + * + * @param options - The user input handler context. + */ +async function onProceedClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, context } = options; + if (context?.acknowledged !== true) { + return; + } + + if ( + shouldDisableConfirmation({ + scanFetchStatus: context.scanFetchStatus as FetchStatus | undefined, + transactionsFetchStatus: context.transactionsFetchStatus as + | FetchStatus + | undefined, + }) + ) { + await reRender(id, context, { + acknowledgementScreen: false, + acknowledged: false, + }); + return; + } + + await resolveInterface(id, true); +} + +/** + * Returns from the acknowledgement screen to the confirmation view. + * + * @param options - The user input handler context. + */ +async function onBackClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, context } = options; + if (!context) { + return; + } + await reRender(id, context, { + acknowledgementScreen: false, + acknowledged: false, + }); +} + +/** + * Create the shared malicious-acknowledgement event handlers. + * + * @returns Object containing event handlers keyed by form element name. + */ +export function createEventHandlers(): Record { + return { + [MaliciousAcknowledgementFormNames.Review]: onReviewClick, + [MaliciousAcknowledgementFormNames.Acknowledge]: onAcknowledgeChange, + [MaliciousAcknowledgementFormNames.Proceed]: onProceedClick, + [MaliciousAcknowledgementFormNames.Back]: onBackClick, + }; +} diff --git a/packages/snap/src/ui/confirmation/views/render.tsx b/packages/snap/src/ui/confirmation/views/render.tsx new file mode 100644 index 00000000..cfc5b8ab --- /dev/null +++ b/packages/snap/src/ui/confirmation/views/render.tsx @@ -0,0 +1,86 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import type { ConfirmationBaseProps } from '../api'; +import { ConfirmationInterfaceKey } from '../api'; +import type { ConfirmSendTransactionProps } from './ConfirmSendTransaction/ConfirmSendTransaction'; +import { ConfirmSendTransaction } from './ConfirmSendTransaction/ConfirmSendTransaction'; +import type { ConfirmSignAuthEntryProps } from './ConfirmSignAuthEntry/ConfirmSignAuthEntry'; +import { ConfirmSignAuthEntry } from './ConfirmSignAuthEntry/ConfirmSignAuthEntry'; +import type { ConfirmSignChangeTrustOptInProps } from './ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import { ConfirmSignChangeTrustOptIn } from './ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import type { ConfirmSignChangeTrustOptOutProps } from './ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; +import { ConfirmSignChangeTrustOptOut } from './ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; +import type { ConfirmSignMessageProps } from './ConfirmSignMessage/ConfirmSignMessage'; +import { ConfirmSignMessage } from './ConfirmSignMessage/ConfirmSignMessage'; +import type { ConfirmSignTransactionProps } from './ConfirmSignTransaction/ConfirmSignTransaction'; +import { ConfirmSignTransaction } from './ConfirmSignTransaction/ConfirmSignTransaction'; +import { MaliciousAcknowledgementScreen } from './MaliciousAcknowledgement/MaliciousAcknowledgementScreen'; + +/** Serializable props bag stored on the interface and merged into each view. */ +export type ConfirmationViewProps = Record; + +/** + * Renders the confirmation view for an interface key and context. + * + * Shared by {@link ConfirmationUXController} and the malicious acknowledgement + * event handlers so both render through the same logic. When the context marks + * the acknowledgement screen as active, it takes over regardless of the key. + * + * @param interfaceKey - The confirmation flow to render. + * @param context - The serialized interface context (view props + flags). + * @returns The component to render. + */ +export function renderConfirmationView( + interfaceKey: ConfirmationInterfaceKey, + context: ConfirmationViewProps, +): ComponentOrElement { + const baseContext = context as ConfirmationBaseProps; + if (baseContext.acknowledgementScreen) { + return ( + + ); + } + + switch (interfaceKey) { + case ConfirmationInterfaceKey.ChangeTrustlineOptIn: + return ( + + ); + case ConfirmationInterfaceKey.ChangeTrustlineOptOut: + return ( + + ); + case ConfirmationInterfaceKey.SignTransaction: + return ( + + ); + case ConfirmationInterfaceKey.SignMessage: + return ; + case ConfirmationInterfaceKey.SignAuthEntry: + return ( + + ); + case ConfirmationInterfaceKey.ConfirmSendTransaction: + return ( + + ); + default: { + const exhaustive: never = interfaceKey; + throw new Error(`Unsupported interface key: ${String(exhaustive)}`); + } + } +}