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 @@
-
\ No newline at end of file
+
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 (
+
+ );
+};
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)}`);
+ }
+ }
+}