From 8542e49ef16177be485d226b980ef597a4eaa4fc Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Mon, 13 Jan 2025 13:02:02 +0100 Subject: [PATCH 01/19] add origin throttling modal --- app/_locales/en/messages.json | 15 ++ .../origin-throttling-controller.test.ts | 160 ++++++++++++++++ .../origin-throttling-controller.ts | 168 +++++++++++++++++ .../createOriginThrottlingMiddleware.test.ts | 72 ++++++++ .../lib/createOriginThrottlingMiddleware.ts | 59 ++++++ app/scripts/metamask-controller.js | 28 +++ package.json | 3 +- shared/constants/origin-throttling.ts | 19 ++ ui/ducks/metamask/metamask.js | 1 + .../components/confirm/footer/footer.test.tsx | 25 +++ .../components/confirm/footer/footer.tsx | 23 ++- .../footer/origin-throttle-modal.test.tsx | 83 +++++++++ .../confirm/footer/origin-throttle-modal.tsx | 172 ++++++++++++++++++ .../hooks/useOriginThrottling.test.ts | 59 ++++++ .../hooks/useOriginThrottling.ts | 53 ++++++ ui/store/actions.ts | 6 + yarn.lock | 14 +- 17 files changed, 954 insertions(+), 6 deletions(-) create mode 100644 app/scripts/controllers/origin-throttling-controller.test.ts create mode 100644 app/scripts/controllers/origin-throttling-controller.ts create mode 100644 app/scripts/lib/createOriginThrottlingMiddleware.test.ts create mode 100644 app/scripts/lib/createOriginThrottlingMiddleware.ts create mode 100644 shared/constants/origin-throttling.ts create mode 100644 ui/pages/confirmations/components/confirm/footer/origin-throttle-modal.test.tsx create mode 100644 ui/pages/confirmations/components/confirm/footer/origin-throttle-modal.tsx create mode 100644 ui/pages/confirmations/hooks/useOriginThrottling.test.ts create mode 100644 ui/pages/confirmations/hooks/useOriginThrottling.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index bc681b5fec49..7ee433b96438 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5501,6 +5501,21 @@ "source": { "message": "Source" }, + "spamModalBlockedDescription": { + "message": "This site will be blocked for 1 minute." + }, + "spamModalBlockedTitle": { + "message": "You've temporarily blocked this site" + }, + "spamModalDescription": { + "message": "If you're being spammed with multiple requests, you can temporarily block the site." + }, + "spamModalTemporaryBlockButton": { + "message": "Temporarily block this site" + }, + "spamModalTitle": { + "message": "We've noticed multiple requests" + }, "speed": { "message": "Speed" }, diff --git a/app/scripts/controllers/origin-throttling-controller.test.ts b/app/scripts/controllers/origin-throttling-controller.test.ts new file mode 100644 index 000000000000..26d6d62f34c0 --- /dev/null +++ b/app/scripts/controllers/origin-throttling-controller.test.ts @@ -0,0 +1,160 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import { errorCodes } from '@metamask/rpc-errors'; +import { + ApprovalAcceptedEvent, + ApprovalRejectedEvent, + ApprovalRequest, +} from '@metamask/approval-controller'; +import { waitFor } from '@testing-library/react'; +import { + OriginThrottlingController, + OriginThrottlingControllerMessenger, + OriginThrottlingState, +} from './origin-throttling-controller'; + +const setupController = ({ + state, +}: { + state?: Partial; +}) => { + const messenger = new ControllerMessenger< + never, + ApprovalAcceptedEvent | ApprovalRejectedEvent + >(); + const originThrottlingControllerMessenger: OriginThrottlingControllerMessenger = + messenger.getRestricted({ + name: 'OriginThrottlingController', + allowedActions: [], + allowedEvents: [ + 'ApprovalController:accepted', + 'ApprovalController:rejected', + ], + }); + + const controller = new OriginThrottlingController({ + messenger: originThrottlingControllerMessenger, + state, + }); + + return { controller, messenger }; +}; + +describe('OriginThrottlingController', () => { + describe('resetOriginThrottlingState', () => { + it('should reset the throttling state for a given origin', () => { + const { controller } = setupController({ + state: { + throttledOrigins: { + 'example.com': { rejections: 3, lastRejection: Date.now() }, + }, + }, + }); + + controller.resetOriginThrottlingState('example.com'); + expect(controller.state.throttledOrigins['example.com']).toBeUndefined(); + }); + }); + + describe('isOriginBlockedForConfirmations', () => { + it('should return false if the origin is not throttled', () => { + const { controller } = setupController({}); + expect(controller.isOriginBlockedForConfirmations('example.com')).toBe( + false, + ); + }); + + it('should return true if the origin is throttled and within the blocking threshold', () => { + const { controller } = setupController({ + state: { + throttledOrigins: { + 'example.com': { + rejections: 5, + lastRejection: Date.now(), + }, + }, + }, + }); + + expect(controller.isOriginBlockedForConfirmations('example.com')).toBe( + true, + ); + }); + + it('should return false if the origin is throttled but outside the blocking threshold', () => { + const { controller } = setupController({ + state: { + throttledOrigins: { + 'example.com': { + rejections: 5, + lastRejection: Date.now() - 600000, // 10 minutes ago + }, + }, + }, + }); + + expect(controller.isOriginBlockedForConfirmations('example.com')).toBe( + false, + ); + }); + }); + + describe('ApprovalController:rejected event', () => { + it('should increase rejection count for user rejected errors', async () => { + const { controller, messenger } = setupController({}); + const origin = 'example.com'; + + messenger.publish('ApprovalController:rejected', { + approval: { + origin, + type: 'transaction', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as ApprovalRequest, + error: { + code: errorCodes.provider.userRejectedRequest, + } as unknown as Error, + }); + + await waitFor(() => { + expect(controller.state.throttledOrigins[origin].rejections).toBe(1); + }); + }); + + it('should not increase rejection count for non-user rejected errors', () => { + const { controller, messenger } = setupController({}); + const origin = 'example.com'; + + messenger.publish('ApprovalController:rejected', { + approval: { + origin, + type: 'transaction', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as ApprovalRequest, + error: { code: errorCodes.rpc.internal } as unknown as Error, + }); + + expect(controller.state.throttledOrigins[origin]).toBeUndefined(); + }); + }); + + describe('ApprovalController:accepted event', () => { + it('should reset throttling state on approval acceptance', () => { + const { controller, messenger } = setupController({ + state: { + throttledOrigins: { + 'example.com': { rejections: 3, lastRejection: Date.now() }, + }, + }, + }); + + messenger.publish('ApprovalController:accepted', { + approval: { + origin: 'example.com', + type: 'transaction', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as ApprovalRequest, + }); + + expect(controller.state.throttledOrigins['example.com']).toBeUndefined(); + }); + }); +}); diff --git a/app/scripts/controllers/origin-throttling-controller.ts b/app/scripts/controllers/origin-throttling-controller.ts new file mode 100644 index 000000000000..bd4d0543f7cb --- /dev/null +++ b/app/scripts/controllers/origin-throttling-controller.ts @@ -0,0 +1,168 @@ +import { + BaseController, + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { + ApprovalAcceptedEvent, + ApprovalRejectedEvent, + ApprovalRequest, +} from '@metamask/approval-controller'; +import { errorCodes } from '@metamask/rpc-errors'; +import { + BLOCKABLE_METHODS, + BLOCKING_THRESHOLD_IN_MS, + NUMBER_OF_REJECTIONS_THRESHOLD, + REJECTION_THRESHOLD_IN_MS, +} from '../../../shared/constants/origin-throttling'; + +const controllerName = 'OriginThrottlingController'; + +export type OriginState = { + rejections: number; + lastRejection: number; +}; + +export type OriginThrottlingState = { + throttledOrigins: { + [key: string]: OriginState; + }; +}; + +export type OriginThrottlingControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + OriginThrottlingState +>; + +export type OriginThrottlingControllerStateChangeEvent = + ControllerStateChangeEvent; + +export type OriginThrottlingControllerActions = + OriginThrottlingControllerGetStateAction; + +export type OriginThrottlingControllerEvents = + OriginThrottlingControllerStateChangeEvent; + +export type AllowedActions = never; + +export type AllowedEvents = ApprovalAcceptedEvent | ApprovalRejectedEvent; + +export type OriginThrottlingControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + OriginThrottlingControllerActions | AllowedActions, + OriginThrottlingControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +export type OriginThrottlingControllerOptions = { + state?: Partial; + messenger: OriginThrottlingControllerMessenger; +}; + +const controllerMetadata = { + throttledOrigins: { + persist: true, + anonymous: false, + }, +}; + +const getDefaultState = (): OriginThrottlingState => ({ + throttledOrigins: {}, +}); + +interface ErrorWithCode extends Error { + code?: number; +} + +const isUserRejectedError = (error: ErrorWithCode) => + error && error.code === errorCodes.provider.userRejectedRequest; + +export class OriginThrottlingController extends BaseController< + typeof controllerName, + OriginThrottlingState, + OriginThrottlingControllerMessenger +> { + constructor(opts: OriginThrottlingControllerOptions) { + super({ + messenger: opts.messenger, + name: controllerName, + state: { + ...getDefaultState(), + ...opts.state, + }, + metadata: controllerMetadata, + }); + + this.messagingSystem.subscribe( + 'ApprovalController:accepted', + this.#onApprovalAccepted.bind(this), + ); + + this.messagingSystem.subscribe( + 'ApprovalController:rejected', + this.#onApprovalRejected.bind(this), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + #onApprovalAccepted({ approval }: { approval: ApprovalRequest }) { + const { type, origin } = approval; + if (BLOCKABLE_METHODS.has(type)) { + this.resetOriginThrottlingState(origin); + } + } + + #onApprovalRejected({ + approval, + error, + }: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + approval: ApprovalRequest; + error: Error; + }) { + const { origin, type } = approval; + + if (BLOCKABLE_METHODS.has(type) && isUserRejectedError(error)) { + this.#onConfirmationRejectedByUser(origin); + } + } + + #onConfirmationRejectedByUser(origin: string): void { + const currentState = this.state.throttledOrigins[origin] || { + rejections: 0, + lastRejection: 0, + }; + const currentTime = Date.now(); + const isUnderThreshold = + currentTime - currentState.lastRejection < REJECTION_THRESHOLD_IN_MS; + const newRejections = isUnderThreshold ? currentState.rejections + 1 : 1; + + this.update((state) => { + state.throttledOrigins[origin] = { + rejections: newRejections, + lastRejection: currentTime, + }; + }); + } + + resetOriginThrottlingState(origin: string): void { + this.update((state) => { + delete state.throttledOrigins[origin]; + }); + } + + isOriginBlockedForConfirmations(origin: string): boolean { + const originState = this.state.throttledOrigins[origin]; + if (!originState) { + return false; + } + const currentTime = Date.now(); + const { rejections, lastRejection } = originState; + const isWithinOneMinute = + currentTime - lastRejection <= BLOCKING_THRESHOLD_IN_MS; + + return rejections >= NUMBER_OF_REJECTIONS_THRESHOLD && isWithinOneMinute; + } +} diff --git a/app/scripts/lib/createOriginThrottlingMiddleware.test.ts b/app/scripts/lib/createOriginThrottlingMiddleware.test.ts new file mode 100644 index 000000000000..df7919bee135 --- /dev/null +++ b/app/scripts/lib/createOriginThrottlingMiddleware.test.ts @@ -0,0 +1,72 @@ +// app/scripts/lib/__tests__/createOriginThrottlingMiddleware.test.ts + +import { OriginThrottlingController } from '../controllers/origin-throttling-controller'; +import createOriginThrottlingMiddleware, { + SPAM_FILTER_ACTIVATED_ERROR, + ExtendedJSONRPCRequest, +} from './createOriginThrottlingMiddleware'; + +describe('createOriginThrottlingMiddleware', () => { + let originThrottlingController: OriginThrottlingController; + let middleware: ReturnType; + + beforeEach(() => { + originThrottlingController = { + isOriginBlockedForConfirmations: jest.fn(), + } as unknown as OriginThrottlingController; + + middleware = createOriginThrottlingMiddleware({ + originThrottlingController, + }); + }); + + it('should call next if the method is not blockable', async () => { + const req = { + method: 'nonBlockableMethod', + origin: 'testOrigin', + } as unknown as ExtendedJSONRPCRequest; + const next = jest.fn(); + const end = jest.fn(); + + await middleware(req, {}, next, end); + + expect(next).toHaveBeenCalled(); + expect(end).not.toHaveBeenCalled(); + }); + + it('should end with SPAM_FILTER_ACTIVATED_ERROR if the origin is blocked', async () => { + const req = { + method: 'transaction', + origin: 'testOrigin', + } as unknown as ExtendedJSONRPCRequest; + const next = jest.fn(); + const end = jest.fn(); + + ( + originThrottlingController.isOriginBlockedForConfirmations as jest.Mock + ).mockReturnValue(true); + + await middleware(req, {}, next, end); + + expect(end).toHaveBeenCalledWith(SPAM_FILTER_ACTIVATED_ERROR); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next if the origin is not blocked', async () => { + const req = { + method: 'transaction', + origin: 'testOrigin', + } as unknown as ExtendedJSONRPCRequest; + const next = jest.fn(); + const end = jest.fn(); + + ( + originThrottlingController.isOriginBlockedForConfirmations as jest.Mock + ).mockReturnValue(false); + + await middleware(req, {}, next, end); + + expect(next).toHaveBeenCalled(); + expect(end).not.toHaveBeenCalled(); + }); +}); diff --git a/app/scripts/lib/createOriginThrottlingMiddleware.ts b/app/scripts/lib/createOriginThrottlingMiddleware.ts new file mode 100644 index 000000000000..7db829775f9b --- /dev/null +++ b/app/scripts/lib/createOriginThrottlingMiddleware.ts @@ -0,0 +1,59 @@ +// Request and responses are currently untyped. +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { providerErrors } from '@metamask/rpc-errors'; +import { JsonRpcRequest } from '@metamask/utils'; +import { OriginThrottlingController } from '../controllers/origin-throttling-controller'; +import { BLOCKABLE_METHODS } from '../../../shared/constants/origin-throttling'; + +export type ExtendedJSONRPCRequest = JsonRpcRequest & { origin: string }; + +export const SPAM_FILTER_ACTIVATED_ERROR = providerErrors.unauthorized( + 'Request blocked due to spam filter.', +); + +export function validateOriginThrottling({ + req, + end, + originThrottlingController, +}: { + req: ExtendedJSONRPCRequest; + end: any; + originThrottlingController: OriginThrottlingController; +}): boolean { + const isDappBlocked = + originThrottlingController.isOriginBlockedForConfirmations(req.origin); + + if (isDappBlocked) { + end(SPAM_FILTER_ACTIVATED_ERROR); + } + + return isDappBlocked; +} + +export default function createOriginThrottlingMiddleware({ + originThrottlingController, +}: { + originThrottlingController: OriginThrottlingController; +}) { + return async function originThrottlingMiddleware( + req: ExtendedJSONRPCRequest, + _res: any, + next: any, + end: any, + ) { + const isBlockableRPCMethod = BLOCKABLE_METHODS.has(req.method); + if (!isBlockableRPCMethod) { + return next(); + } + + const isDappBlocked = validateOriginThrottling({ + end, + originThrottlingController, + req, + }); + + if (!isDappBlocked) { + return next(); + } + }; +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index c98ba72c0f96..14b6343fe504 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -314,6 +314,7 @@ import { PreferencesController } from './controllers/preferences-controller'; import { AppStateController } from './controllers/app-state-controller'; import { AlertController } from './controllers/alert-controller'; import OnboardingController from './controllers/onboarding'; +import { OriginThrottlingController } from './controllers/origin-throttling-controller'; import Backup from './lib/backup'; import DecryptMessageController from './controllers/decrypt-message'; import SwapsController from './controllers/swaps'; @@ -376,6 +377,7 @@ import { onPushNotificationReceived, } from './controllers/push-notifications'; import createTracingMiddleware from './lib/createTracingMiddleware'; +import createOriginThrottlingMiddleware from './lib/createOriginThrottlingMiddleware'; import { PatchStore } from './lib/PatchStore'; import { sanitizeUIState } from './lib/state-utils'; import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; @@ -2444,6 +2446,17 @@ export default class MetamaskController extends EventEmitter { }), }); + this.originThrottlingController = new OriginThrottlingController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'OriginThrottlingController', + allowedEvents: [ + 'ApprovalController:accepted', + 'ApprovalController:rejected', + ], + }), + state: initState.OriginThrottlingController, + }); + this.metamaskMiddleware = createMetamaskMiddleware({ static: { eth_syncing: false, @@ -2611,6 +2624,7 @@ export default class MetamaskController extends EventEmitter { NotificationServicesPushController: this.notificationServicesPushController, RemoteFeatureFlagController: this.remoteFeatureFlagController, + OriginThrottlingController: this.originThrottlingController, ...resetOnRestartStore, }); @@ -2667,6 +2681,7 @@ export default class MetamaskController extends EventEmitter { NotificationServicesPushController: this.notificationServicesPushController, RemoteFeatureFlagController: this.remoteFeatureFlagController, + OriginThrottlingController: this.originThrottlingController, ...resetOnRestartStore, }, controllerMessenger: this.controllerMessenger, @@ -3472,6 +3487,7 @@ export default class MetamaskController extends EventEmitter { userStorageController, notificationServicesController, notificationServicesPushController, + originThrottlingController, } = this; return { @@ -4235,6 +4251,12 @@ export default class MetamaskController extends EventEmitter { approvalController.addAndShowApprovalRequest.bind(approvalController), resolvePendingApproval: this.resolvePendingApproval, + // Origin Throttling + resetOriginThrottlingState: + originThrottlingController.resetOriginThrottlingState.bind( + originThrottlingController, + ), + // Notifications resetViewedNotifications: announcementController.resetViewed.bind( announcementController, @@ -5996,6 +6018,12 @@ export default class MetamaskController extends EventEmitter { engine.push(createTracingMiddleware()); + engine.push( + createOriginThrottlingMiddleware({ + originThrottlingController: this.originThrottlingController, + }), + ); + engine.push( createPPOMMiddleware( this.ppomController, diff --git a/package.json b/package.json index 1e084dbda471..108cc64954c9 100644 --- a/package.json +++ b/package.json @@ -258,7 +258,8 @@ "tslib@npm:^2.3.0": "~2.6.0", "tslib@npm:^2.3.1": "~2.6.0", "tslib@npm:^2.4.0": "~2.6.0", - "tslib@npm:^2.6.2": "~2.6.0" + "tslib@npm:^2.6.2": "~2.6.0", + "@metamask/approval-controller@npm:^7.0.0": "npm:@metamask-previews/approval-controller@7.1.1-preview-ae144301" }, "dependencies": { "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", diff --git a/shared/constants/origin-throttling.ts b/shared/constants/origin-throttling.ts new file mode 100644 index 000000000000..9a49026da768 --- /dev/null +++ b/shared/constants/origin-throttling.ts @@ -0,0 +1,19 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { MESSAGE_TYPE } from './app'; + +export const NUMBER_OF_REJECTIONS_THRESHOLD = 3; +export const REJECTION_THRESHOLD_IN_MS = 30000; +export const BLOCKING_THRESHOLD_IN_MS = 60000; + +export const BLOCKABLE_METHODS: Set = new Set([ + ApprovalType.Transaction, + MESSAGE_TYPE.ETH_SEND_TRANSACTION, + MESSAGE_TYPE.ETH_SIGN_TYPED_DATA, + MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V1, + MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V3, + MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4, + MESSAGE_TYPE.PERSONAL_SIGN, + MESSAGE_TYPE.WATCH_ASSET, + MESSAGE_TYPE.ADD_ETHEREUM_CHAIN, + MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN, +]); diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index c9c57057f2f9..0af666f3e135 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -66,6 +66,7 @@ const initialState = { conversionRate: null, }, }, + throttledOrigins: {}, }; /** diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index 7d56833c8807..e4052aadb9ac 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -27,6 +27,7 @@ import * as Actions from '../../../../../store/actions'; import configureStore from '../../../../../store/store'; import * as confirmContext from '../../../context/confirm'; import { SignatureRequestType } from '../../../types/confirm'; +import useOriginThrottling from '../../../hooks/useOriginThrottling'; import Footer from './footer'; jest.mock('react-redux', () => ({ @@ -45,6 +46,8 @@ jest.mock( }), ); +jest.mock('../../../hooks/useOriginThrottling'); + const render = (args?: Record) => { const store = configureStore(args ?? getMockPersonalSignConfirmState()); @@ -52,6 +55,14 @@ const render = (args?: Record) => { }; describe('ConfirmFooter', () => { + const mockUseOriginThrottling = useOriginThrottling as jest.Mock; + + beforeEach(() => { + mockUseOriginThrottling.mockReturnValue({ + willNextRejectionReachThreshold: false, + }); + }); + it('should match snapshot with signature confirmation', () => { const { container } = render(getMockPersonalSignConfirmState()); expect(container).toMatchSnapshot(); @@ -212,6 +223,20 @@ describe('ConfirmFooter', () => { expect(submitButton).toHaveClass('mm-button-primary--type-danger'); }); + it('no action is taken when the origin is on threshold and cancel button is clicked', () => { + mockUseOriginThrottling.mockReturnValue({ + willNextRejectionReachThreshold: true, + }); + const rejectSpy = jest.spyOn(Actions, 'rejectPendingApproval'); + + const { getAllByRole } = render(getMockPersonalSignConfirmState()); + + const cancelButton = getAllByRole('button')[0]; + fireEvent.click(cancelButton); + + expect(rejectSpy).not.toHaveBeenCalled(); + }); + it('disables submit button if required LedgerHidConnection is not yet established', () => { const { getAllByRole } = render( getMockPersonalSignConfirmStateForRequest( diff --git a/ui/pages/confirmations/components/confirm/footer/footer.tsx b/ui/pages/confirmations/components/confirm/footer/footer.tsx index 301338fbae69..3cb2255edf25 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.tsx @@ -44,6 +44,8 @@ import { MetaMetricsEventLocation } from '../../../../../../shared/constants/met import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; +import useOriginThrottling from '../../../hooks/useOriginThrottling'; +import OriginThrottleModal from './origin-throttle-modal'; export type OnCancelHandler = ({ location, @@ -173,6 +175,8 @@ const Footer = () => { const { currentConfirmation, isScrollToBottomCompleted } = useConfirmContext(); const { from } = getConfirmationSender(currentConfirmation); + const { willNextRejectionReachThreshold } = useOriginThrottling(); + const [showOriginThrottleModal, setShowOriginThrottleModal] = useState(false); ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const noteToTraderMessage = useSelector(getNoteToTraderMessage); @@ -267,16 +271,27 @@ const Footer = () => { ///: END:ONLY_INCLUDE_IF ]); - const onFooterCancel = useCallback(() => { - onCancel({ location: MetaMetricsEventLocation.Confirmation }); - }, [currentConfirmation, onCancel]); + const onFooterCancel = useCallback( + (forceCancel = false) => { + if (willNextRejectionReachThreshold && !forceCancel) { + setShowOriginThrottleModal(true); + return; + } + onCancel({ location: MetaMetricsEventLocation.Confirmation }); + }, + [currentConfirmation, onCancel], + ); return ( +