diff --git a/packages/approval-controller/src/ApprovalController.ts b/packages/approval-controller/src/ApprovalController.ts index 73cf15154be..86a9d37477a 100644 --- a/packages/approval-controller/src/ApprovalController.ts +++ b/packages/approval-controller/src/ApprovalController.ts @@ -1,1044 +1,1044 @@ -import { BaseController } from '@metamask/base-controller'; -import type { - ControllerGetStateAction, - StateMetadata, -} from '@metamask/base-controller'; -import type { ControllerStateChangeEvent } from '@metamask/base-controller'; -import type { Messenger } from '@metamask/messenger'; -import type { JsonRpcError, DataWithOptionalCause } from '@metamask/rpc-errors'; -import { rpcErrors } from '@metamask/rpc-errors'; -import type { Json, OptionalField } from '@metamask/utils'; -import { nanoid } from 'nanoid'; - -import type { ApprovalControllerMethodActions } from './ApprovalController-method-action-types.js'; -import { - ApprovalRequestNotFoundError, - ApprovalRequestNoResultSupportError, - EndInvalidFlowError, - NoApprovalFlowsError, - MissingApprovalFlowError, -} from './errors.js'; - -// Constants - -// Avoiding dependency on controller-utils -export const ORIGIN_METAMASK = 'metamask'; -export const APPROVAL_TYPE_RESULT_ERROR = 'result_error'; -export const APPROVAL_TYPE_RESULT_SUCCESS = 'result_success'; - -const controllerName = 'ApprovalController'; - -const stateMetadata: StateMetadata = { - pendingApprovals: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: true, - usedInUi: true, - }, - pendingApprovalCount: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - approvalFlows: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, -}; - -const getAlreadyPendingMessage = (origin: string, type: string) => - `Request of type '${type}' already pending for origin ${origin}. Please wait.`; - -const getDefaultState = (): ApprovalControllerState => { - return { - pendingApprovals: {}, - pendingApprovalCount: 0, - approvalFlows: [], - }; -}; - -// === MESSENGER === - -const MESSENGER_EXPOSED_METHODS = [ - 'acceptRequest', - 'add', - 'addAndShowApprovalRequest', - 'addRequest', - 'clearRequests', - 'endFlow', - 'get', - 'getApprovalCount', - 'getTotalApprovalCount', - 'hasRequest', - 'rejectRequest', - 'setFlowLoadingText', - 'showError', - 'showSuccess', - 'startFlow', - 'updateRequestState', -] as const; - -// Internal Types - -type ApprovalPromiseResolve = (value?: unknown | AddResult) => void; - -type ApprovalPromiseReject = (error?: unknown) => void; - -type ApprovalRequestData = Record | null; - -type ApprovalRequestState = Record | null; - -type ApprovalCallbacks = { - resolve: ApprovalPromiseResolve; - reject: ApprovalPromiseReject; -}; - -type ApprovalFlow = { - id: string; - loadingText: string | null; -}; - -type ResultOptions = { - flowToEnd?: string; - header?: (string | ResultComponent)[]; - icon?: string | null; - title?: string | null; -}; - -// Miscellaneous Types - -export type ApprovalRequest = { - /** - * The ID of the approval request. - */ - id: string; - - /** - * The origin of the approval request. - */ - origin: string; - - /** - * The time that the request was received, per Date.now(). - */ - time: number; - - /** - * The type of the approval request. - * Unfortunately, not all values will match the `ApprovalType` enum, so we are using `string` here. - * TODO: Replace `string` with `ApprovalType` when all `type` values used by the clients can be encompassed by the `ApprovalType` enum. - */ - type: string; - - /** - * Additional data associated with the request. - */ - requestData: RequestData; - - /** - * Additional mutable state associated with the request - */ - requestState: ApprovalRequestState; - - /** - * Whether the request expects a result object to be returned instead of just the approval value. - */ - expectsResult: boolean; -}; - -export type ApprovalFlowState = ApprovalFlow; - -export type ApprovalControllerState = { - pendingApprovals: Record>>; - pendingApprovalCount: number; - approvalFlows: ApprovalFlowState[]; -}; - -export type ApprovalControllerMessenger = Messenger< - typeof controllerName, - ApprovalControllerActions, - ApprovalControllerEvents ->; - -// Option Types - -export type ShowApprovalRequest = () => void | Promise; - -export type ResultComponent = { - /** - * A unique identifier for this instance of the component. - */ - key: string; - - /** - * The name of the component to render. - */ - name: string; - - /** - * Any properties required by the component. - */ - properties?: Record; - - /** - * Any child components to render inside the component. - */ - children?: string | ResultComponent | (string | ResultComponent)[]; -}; - -export type ApprovalControllerOptions = { - messenger: ApprovalControllerMessenger; - showApprovalRequest: ShowApprovalRequest; - state?: Partial; - typesExcludedFromRateLimiting?: string[]; -}; - -export type AddApprovalOptions = { - id?: string; - origin: string; - type: string; - requestData?: Record; - requestState?: Record; - expectsResult?: boolean; -}; - -export type UpdateRequestStateOptions = { - id: string; - requestState: Record; -}; - -export type AcceptOptions = { - /** - * Whether to resolve the returned promise only when the request creator indicates the success of the - * post-approval logic using the result callbacks. - * If false or unspecified, the promise will resolve immediately. - */ - waitForResult?: boolean; - - /** - * Whether to delete the approval request after a result callback is called. - * If false or unspecified, the approval request will be deleted immediately. - * Ignored if `waitForResult` is false or unspecified. - */ - deleteAfterResult?: boolean; -}; - -export type StartFlowOptions = OptionalField< - ApprovalFlow, - 'id' | 'loadingText' -> & { show?: boolean }; - -export type EndFlowOptions = Pick; - -export type SetFlowLoadingTextOptions = ApprovalFlow; - -export type SuccessOptions = ResultOptions & { - message?: string | ResultComponent | (string | ResultComponent)[]; -}; - -export type ErrorOptions = ResultOptions & { - error?: string | ResultComponent | (string | ResultComponent)[]; -}; - -// Result Types - -export type AcceptResultCallbacks = { - /** - * Inform the request acceptor that the post-approval logic was successful. - * - * @param value - An optional value generated by the post-approval logic. - */ - success: (value?: unknown) => void; - - /** - * Inform the request acceptor that the post-approval logic failed. - * - * @param error - The reason for the failure. - */ - error: (error: Error) => void; -}; - -export type AddResult = { - /** - * An optional value provided by the request acceptor. - */ - value?: unknown; - - /** - * Callback functions that must be used to indicate to the request acceptor whether the post-approval logic was successful or not. - * Will be undefined if the request acceptor did not specify that they want to wait for a result. - */ - resultCallbacks?: AcceptResultCallbacks; -}; - -export type AcceptResult = { - /** - * An optional value provided by the request creator when indicating a successful result. - */ - value?: unknown; -}; - -export type ApprovalFlowStartResult = ApprovalFlow; - -export type SuccessResult = Record; - -export type ErrorResult = Record; - -// Event Types - -export type ApprovalStateChange = ControllerStateChangeEvent< - typeof controllerName, - ApprovalControllerState ->; - -export type ApprovalControllerEvents = ApprovalStateChange; - -// Action Types - -export type ApprovalControllerGetStateAction = ControllerGetStateAction< - typeof controllerName, - ApprovalControllerState ->; - -export type ApprovalControllerActions = - | ApprovalControllerGetStateAction - | ApprovalControllerMethodActions; - -/** - * Controller for managing requests that require user approval. - * - * Enables limiting the number of pending requests by origin and type, counting - * pending requests, and more. - * - * Adding a request returns a promise that resolves or rejects when the request - * is approved or denied, respectively. - */ -export class ApprovalController extends BaseController< - typeof controllerName, - ApprovalControllerState, - ApprovalControllerMessenger -> { - readonly #approvals: Map; - - readonly #origins: Map>; - - readonly #showApprovalRequest: () => void; - - readonly #typesExcludedFromRateLimiting: string[]; - - /** - * Construct an Approval controller. - * - * @param options - The controller options. - * @param options.showApprovalRequest - Function for opening the UI such that - * the request can be displayed to the user. - * @param options.messenger - The restricted messenger for the Approval controller. - * @param options.state - The initial controller state. - * @param options.typesExcludedFromRateLimiting - Array of approval types which allow multiple pending approval requests from the same origin. - */ - constructor({ - messenger, - showApprovalRequest, - state = {}, - typesExcludedFromRateLimiting = [], - }: ApprovalControllerOptions) { - super({ - name: controllerName, - metadata: stateMetadata, - messenger, - state: { ...getDefaultState(), ...state }, - }); - - this.#approvals = new Map(); - this.#origins = new Map(); - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.#showApprovalRequest = showApprovalRequest; - this.#typesExcludedFromRateLimiting = typesExcludedFromRateLimiting; - this.messenger.registerMethodActionHandlers( - this, - MESSENGER_EXPOSED_METHODS, - ); - } - - /** - * Adds an approval request per the given arguments, optionally showing - * the approval request to the user. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. A random id will be - * generated if none is provided. - * @param opts.origin - The origin of the approval request. - * @param opts.type - The type associated with the approval request. - * @param opts.requestData - Additional data associated with the request, - * if any. - * @param opts.requestState - Additional state associated with the request, - * if any. - * @param shouldShowRequest - Whether to show the approval request to the user. - * @returns The approval promise. - */ - addRequest( - opts: AddApprovalOptions, - shouldShowRequest: boolean, - ): Promise { - if (shouldShowRequest) { - return this.addAndShowApprovalRequest(opts); - } - return this.add(opts); - } - - /** - * Adds an approval request per the given arguments, calls the show approval - * request function, and returns the associated approval promise resolving to - * an AddResult object. - * - * There can only be one approval per origin and type. An error is thrown if - * attempting to add an invalid or duplicate request. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. A random id will be - * generated if none is provided. - * @param opts.origin - The origin of the approval request. - * @param opts.type - The type associated with the approval request. - * @param opts.requestData - Additional data associated with the request, - * @param opts.requestState - Additional state associated with the request, - * if any. - * @returns The approval promise resolving to an AddResult object. - */ - addAndShowApprovalRequest( - opts: AddApprovalOptions & { expectsResult: true }, - ): Promise; - - /** - * Adds an approval request per the given arguments, calls the show approval - * request function, and returns the associated approval promise resolving - * to a value provided during acceptance. - * - * There can only be one approval per origin and type. An error is thrown if - * attempting to add an invalid or duplicate request. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. A random id will be - * generated if none is provided. - * @param opts.origin - The origin of the approval request. - * @param opts.type - The type associated with the approval request. - * @param opts.requestData - Additional data associated with the request, - * @param opts.requestState - Additional state associated with the request, - * if any. - * @returns The approval promise resolving to a value provided during acceptance. - */ - addAndShowApprovalRequest(opts: AddApprovalOptions): Promise; - - addAndShowApprovalRequest(opts: AddApprovalOptions): Promise { - const promise = this.#add( - opts.origin, - opts.type, - opts.id, - opts.requestData, - opts.requestState, - opts.expectsResult, - ); - this.#showApprovalRequest(); - return promise; - } - - /** - * Adds an approval request per the given arguments and returns the approval - * promise resolving to an AddResult object. - * - * There can only be one approval per origin and type. An error is thrown if - * attempting to add an invalid or duplicate request. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. A random id will be - * generated if none is provided. - * @param opts.origin - The origin of the approval request. - * @param opts.type - The type associated with the approval request. - * @param opts.requestData - Additional data associated with the request, - * if any. - * @returns The approval promise resolving to an AddResult object. - */ - add(opts: AddApprovalOptions & { expectsResult: true }): Promise; - - /** - * Adds an approval request per the given arguments and returns the approval - * promise resolving to a value provided during acceptance. - * - * There can only be one approval per origin and type. An error is thrown if - * attempting to add an invalid or duplicate request. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. A random id will be - * generated if none is provided. - * @param opts.origin - The origin of the approval request. - * @param opts.type - The type associated with the approval request. - * @param opts.requestData - Additional data associated with the request, - * if any. - * @returns The approval promise resolving to a value provided during acceptance. - */ - add(opts: AddApprovalOptions): Promise; - - add(opts: AddApprovalOptions): Promise { - return this.#add( - opts.origin, - opts.type, - opts.id, - opts.requestData, - opts.requestState, - opts.expectsResult, - ); - } - - /** - * Gets the info for the approval request with the given id. - * - * @param id - The id of the approval request. - * @returns The approval request data associated with the id. - */ - get(id: string): ApprovalRequest | undefined { - return this.state.pendingApprovals[id]; - } - - /** - * Gets the number of pending approvals, by origin and/or type. - * - * If only `origin` is specified, all approvals for that origin will be - * counted, regardless of type. - * If only `type` is specified, all approvals for that type will be counted, - * regardless of origin. - * If both `origin` and `type` are specified, 0 or 1 will be returned. - * - * @param opts - The approval count options. - * @param opts.origin - An approval origin. - * @param opts.type - The type of the approval request. - * @returns The current approval request count for the given origin and/or - * type. - */ - getApprovalCount(opts: { origin?: string; type?: string } = {}): number { - if (!opts.origin && !opts.type) { - throw new Error('Must specify origin, type, or both.'); - } - const { origin, type: _type } = opts; - - if (origin && _type) { - return this.#origins.get(origin)?.get(_type) || 0; - } - - if (origin) { - return Array.from( - (this.#origins.get(origin) || new Map()).values(), - ).reduce((total, value) => total + value, 0); - } - - // Only "type" was specified - let count = 0; - for (const approval of Object.values(this.state.pendingApprovals)) { - if (approval.type === _type) { - count += 1; - } - } - return count; - } - - /** - * Get the total count of all pending approval requests for all origins. - * - * @returns The total pending approval request count. - */ - getTotalApprovalCount(): number { - return this.state.pendingApprovalCount; - } - - /** - * Checks if there's a pending approval request per the given parameters. - * At least one parameter must be specified. An error will be thrown if the - * parameters are invalid. - * - * If `id` is specified, all other parameters will be ignored. - * If `id` is not specified, the method will check for requests that match - * all of the specified parameters. - * - * @param opts - Options bag. - * @param opts.id - The ID to check for. - * @param opts.origin - The origin to check for. - * @param opts.type - The type to check for. - * @returns `true` if a matching approval is found, and `false` otherwise. - */ - hasRequest( - opts: { id?: string; origin?: string; type?: string } = {}, - ): boolean { - const { id, origin, type: _type } = opts; - - if (id) { - if (typeof id !== 'string') { - throw new Error('May not specify non-string id.'); - } - return this.#approvals.has(id); - } - - if (_type && typeof _type !== 'string') { - throw new Error('May not specify non-string type.'); - } - - if (origin) { - if (typeof origin !== 'string') { - throw new Error('May not specify non-string origin.'); - } - - // Check origin and type pair if type also specified - if (_type) { - return Boolean(this.#origins.get(origin)?.get(_type)); - } - return this.#origins.has(origin); - } - - if (_type) { - for (const approval of Object.values(this.state.pendingApprovals)) { - if (approval.type === _type) { - return true; - } - } - return false; - } - throw new Error( - 'Must specify a valid combination of id, origin, and type.', - ); - } - - /** - * Resolves the promise of the approval with the given id, and deletes the - * approval. Throws an error if no such approval exists. - * - * @param id - The id of the approval request. - * @param value - The value to resolve the approval promise with. - * @param options - Options bag. - * @returns A promise that either resolves once a result is provided by - * the creator of the approval request, or immediately if `options.waitForResult` - * is `false` or `undefined`. - */ - acceptRequest( - id: string, - value?: unknown, - options?: AcceptOptions, - ): Promise { - // Safe to cast as the delete method below will throw if the ID is not found - const approval = this.get(id) as ApprovalRequest; - const requestPromise = this.#getCallbacks(id); - let requestDeleted = false; - - if (!options?.deleteAfterResult || !options.waitForResult) { - this.#delete(id); - requestDeleted = true; - } - - return new Promise((resolve, reject) => { - const resultCallbacks: AcceptResultCallbacks = { - success: (acceptValue?: unknown) => resolve({ value: acceptValue }), - error: reject, - }; - - if (options?.waitForResult && !approval.expectsResult) { - reject(new ApprovalRequestNoResultSupportError(id)); - return; - } - - const resultValue = options?.waitForResult ? resultCallbacks : undefined; - - const resolveValue = approval.expectsResult - ? { value, resultCallbacks: resultValue } - : value; - - requestPromise.resolve(resolveValue); - - if (!options?.waitForResult) { - resolve({ value: undefined }); - } - }).finally(() => { - if (!requestDeleted) { - this.#delete(id); - } - }); - } - - /** - * Rejects the promise of the approval with the given id, and deletes the - * approval. Throws an error if no such approval exists. - * - * @param id - The id of the approval request. - * @param error - The error to reject the approval promise with. - */ - rejectRequest(id: string, error: unknown): void { - const callbacks = this.#getCallbacks(id); - this.#delete(id); - callbacks.reject(error); - } - - /** - * Rejects and deletes all approval requests. - * - * @param rejectionError - The JsonRpcError to reject the approval - * requests with. - */ - clearRequests(rejectionError: JsonRpcError): void { - for (const id of this.#approvals.keys()) { - this.rejectRequest(id, rejectionError); - } - this.#origins.clear(); - this.update((draftState) => { - draftState.pendingApprovals = {}; - draftState.pendingApprovalCount = 0; - }); - } - - /** - * Updates the request state of the approval with the given id. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval request. - * @param opts.requestState - Additional data associated with the request - */ - updateRequestState(opts: UpdateRequestStateOptions): void { - if (!this.state.pendingApprovals[opts.id]) { - throw new ApprovalRequestNotFoundError(opts.id); - } - - this.update((draftState) => { - draftState.pendingApprovals[opts.id].requestState = - opts.requestState as never; - }); - } - - /** - * Starts a new approval flow. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval flow. - * @param opts.loadingText - The loading text that will be associated to the approval flow. - * @param opts.show - A flag to determine whether the approval should show to the user. - * @returns The object containing the approval flow id. - */ - startFlow(opts: StartFlowOptions = {}): ApprovalFlowStartResult { - const id = opts.id ?? nanoid(); - const loadingText = opts.loadingText ?? null; - - this.update((draftState) => { - draftState.approvalFlows.push({ id, loadingText }); - }); - - // By default, if nothing else is specified, we always show the approval. - if (opts.show !== false) { - this.#showApprovalRequest(); - } - - return { id, loadingText }; - } - - /** - * Ends the current approval flow. - * - * @param opts - Options bag. - * @param opts.id - The id of the approval flow that will be finished. - */ - endFlow({ id }: EndFlowOptions) { - if (!this.state.approvalFlows.length) { - throw new NoApprovalFlowsError(); - } - - const currentFlow = this.state.approvalFlows.slice(-1)[0]; - - if (id !== currentFlow.id) { - throw new EndInvalidFlowError( - id, - this.state.approvalFlows.map((flow) => flow.id), - ); - } - - this.update((draftState) => { - draftState.approvalFlows.pop(); - }); - } - - /** - * Sets the loading text for the approval flow. - * - * @param opts - Options bag. - * @param opts.id - The approval flow loading text that will be displayed. - * @param opts.loadingText - The loading text that will be associated to the approval flow. - */ - setFlowLoadingText({ id, loadingText }: SetFlowLoadingTextOptions) { - const flowIndex = this.state.approvalFlows.findIndex( - (flow) => flow.id === id, - ); - - if (flowIndex === -1) { - throw new MissingApprovalFlowError(id); - } - - this.update((draftState) => { - draftState.approvalFlows[flowIndex].loadingText = loadingText; - }); - } - - /** - * Show a success page. - * - * @param opts - Options bag. - * @param opts.message - The message text or components to display in the page. - * @param opts.header - The text or components to display in the header of the page. - * @param opts.flowToEnd - The ID of the approval flow to end once the success page is approved. - * @param opts.title - The title to display above the message. Shown by default but can be hidden with `null`. - * @param opts.icon - The icon to display in the page. Shown by default but can be hidden with `null`. - * @returns Empty object to support future additions. - */ - async showSuccess(opts: SuccessOptions = {}): Promise { - await this.#result(APPROVAL_TYPE_RESULT_SUCCESS, opts, { - message: opts.message, - header: opts.header, - title: opts.title, - icon: opts.icon, - } as Record); - - return {}; - } - - /** - * Show an error page. - * - * @param opts - Options bag. - * @param opts.message - The message text or components to display in the page. - * @param opts.header - The text or components to display in the header of the page. - * @param opts.flowToEnd - The ID of the approval flow to end once the error page is approved. - * @param opts.title - The title to display above the message. Shown by default but can be hidden with `null`. - * @param opts.icon - The icon to display in the page. Shown by default but can be hidden with `null`. - * @returns Empty object to support future additions. - */ - async showError(opts: ErrorOptions = {}): Promise { - await this.#result(APPROVAL_TYPE_RESULT_ERROR, opts, { - error: opts.error, - header: opts.header, - title: opts.title, - icon: opts.icon, - } as Record); - - return {}; - } - - /** - * Implementation of add operation. - * - * @param origin - The origin of the approval request. - * @param type - The type associated with the approval request. - * @param id - The id of the approval request. - * @param requestData - The request data associated with the approval request. - * @param requestState - The request state associated with the approval request. - * @param expectsResult - Whether the approval request expects a result object to be returned. - * @returns The approval promise. - */ - #add( - origin: string, - type: string, - id: string = nanoid(), - requestData?: Record, - requestState?: Record, - expectsResult?: boolean, - ): Promise { - this.#validateAddParams(id, origin, type, requestData, requestState); - - if ( - !this.#typesExcludedFromRateLimiting.includes(type) && - this.hasRequest({ origin, type }) - ) { - throw rpcErrors.resourceUnavailable( - getAlreadyPendingMessage(origin, type), - ); - } - - // add pending approval - return new Promise((resolve, reject) => { - this.#approvals.set(id, { resolve, reject }); - this.#addPendingApprovalOrigin(origin, type); - - this.#addToStore( - id, - origin, - type, - requestData, - requestState, - expectsResult, - ); - }); - } - - /** - * Validates parameters to the add method. - * - * @param id - The id of the approval request. - * @param origin - The origin of the approval request. - * @param type - The type associated with the approval request. - * @param requestData - The request data associated with the approval request. - * @param requestState - The request state associated with the approval request. - */ - #validateAddParams( - id: string, - origin: string, - type: string, - requestData?: Record, - requestState?: Record, - ): void { - let errorMessage = null; - if (!id || typeof id !== 'string') { - errorMessage = 'Must specify non-empty string id.'; - } else if (this.#approvals.has(id)) { - errorMessage = `Approval request with id '${id}' already exists.`; - } else if (!origin || typeof origin !== 'string') { - errorMessage = 'Must specify non-empty string origin.'; - } else if (!type || typeof type !== 'string') { - errorMessage = 'Must specify non-empty string type.'; - } else if ( - requestData && - (typeof requestData !== 'object' || Array.isArray(requestData)) - ) { - errorMessage = 'Request data must be a plain object if specified.'; - } else if ( - requestState && - (typeof requestState !== 'object' || Array.isArray(requestState)) - ) { - errorMessage = 'Request state must be a plain object if specified.'; - } - - if (errorMessage) { - throw rpcErrors.internal(errorMessage); - } - } - - /** - * Adds an entry to _origins. - * Performs no validation. - * - * @param origin - The origin of the approval request. - * @param type - The type associated with the approval request. - */ - #addPendingApprovalOrigin(origin: string, type: string): void { - let originMap = this.#origins.get(origin); - - if (!originMap) { - originMap = new Map(); - this.#origins.set(origin, originMap); - } - - const currentValue = originMap.get(type) || 0; - originMap.set(type, currentValue + 1); - } - - /** - * Adds an entry to the store. - * Performs no validation. - * - * @param id - The id of the approval request. - * @param origin - The origin of the approval request. - * @param type - The type associated with the approval request. - * @param requestData - The request data associated with the approval request. - * @param requestState - The request state associated with the approval request. - * @param expectsResult - Whether the request expects a result object to be returned. - */ - #addToStore( - id: string, - origin: string, - type: string, - requestData?: Record, - requestState?: Record, - expectsResult?: boolean, - ): void { - const approval = { - id, - origin, - type, - time: Date.now(), - requestData: requestData || null, - requestState: requestState || null, - expectsResult: expectsResult || false, - }; - - this.update((draftState) => { - draftState.pendingApprovals[id] = approval as never; - - draftState.pendingApprovalCount = Object.keys( - draftState.pendingApprovals, - ).length; - }); - } - - /** - * Deletes the approval with the given id. - * - * Deletion is an internal operation because approval state is solely - * managed by this controller. - * - * @param id - The id of the approval request to be deleted. - */ - #delete(id: string): void { - if (!this.#approvals.has(id)) { - throw new ApprovalRequestNotFoundError(id); - } - - this.#approvals.delete(id); - - const { origin, type } = this.state.pendingApprovals[id]; - - const originMap = this.#origins.get(origin) as Map; - const originTotalCount = this.getApprovalCount({ origin }); - const originTypeCount = originMap.get(type) as number; - - if (originTotalCount === 1) { - this.#origins.delete(origin); - } else { - originMap.set(type, originTypeCount - 1); - } - - this.update((draftState) => { - delete draftState.pendingApprovals[id]; - draftState.pendingApprovalCount = Object.keys( - draftState.pendingApprovals, - ).length; - }); - } - - #getCallbacks(id: string): ApprovalCallbacks { - const callbacks = this.#approvals.get(id); - - if (!callbacks) { - throw new ApprovalRequestNotFoundError(id); - } - - return callbacks; - } - - async #result( - type: string, - opts: ResultOptions, - requestData: Record, - ) { - try { - await this.addAndShowApprovalRequest({ - origin: ORIGIN_METAMASK, - type, - requestData, - }); - } catch (error) { - console.info('Failed to display result page', error); - } finally { - if (opts.flowToEnd) { - try { - this.endFlow({ id: opts.flowToEnd }); - } catch (error) { - console.info('Failed to end flow', { id: opts.flowToEnd, error }); - } - } - } - } -} - -export default ApprovalController; +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + StateMetadata, +} from '@metamask/base-controller'; +import type { ControllerStateChangeEvent } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { JsonRpcError, DataWithOptionalCause } from '@metamask/rpc-errors'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Json, OptionalField } from '@metamask/utils'; +import { nanoid } from 'nanoid'; + +import type { ApprovalControllerMethodActions } from './ApprovalController-method-action-types.js'; +import { + ApprovalRequestNotFoundError, + ApprovalRequestNoResultSupportError, + EndInvalidFlowError, + NoApprovalFlowsError, + MissingApprovalFlowError, +} from './errors.js'; + +// Constants + +// Avoiding dependency on controller-utils +export const ORIGIN_METAMASK = 'metamask'; +export const APPROVAL_TYPE_RESULT_ERROR = 'result_error'; +export const APPROVAL_TYPE_RESULT_SUCCESS = 'result_success'; + +const controllerName = 'ApprovalController'; + +const stateMetadata: StateMetadata = { + pendingApprovals: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, + pendingApprovalCount: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + approvalFlows: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +const getAlreadyPendingMessage = (origin: string, type: string) => + `Request of type '${type}' already pending for origin ${origin}. Please wait.`; + +const getDefaultState = (): ApprovalControllerState => { + return { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + }; +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'acceptRequest', + 'add', + 'addAndShowApprovalRequest', + 'addRequest', + 'clearRequests', + 'endFlow', + 'get', + 'getApprovalCount', + 'getTotalApprovalCount', + 'hasRequest', + 'rejectRequest', + 'setFlowLoadingText', + 'showError', + 'showSuccess', + 'startFlow', + 'updateRequestState', +] as const; + +// Internal Types + +type ApprovalPromiseResolve = (value?: unknown | AddResult) => void; + +type ApprovalPromiseReject = (error?: unknown) => void; + +type ApprovalRequestData = Record | null; + +type ApprovalRequestState = Record | null; + +type ApprovalCallbacks = { + resolve: ApprovalPromiseResolve; + reject: ApprovalPromiseReject; +}; + +type ApprovalFlow = { + id: string; + loadingText: string | null; +}; + +type ResultOptions = { + flowToEnd?: string; + header?: (string | ResultComponent)[]; + icon?: string | null; + title?: string | null; +}; + +// Miscellaneous Types + +export type ApprovalRequest = { + /** + * The ID of the approval request. + */ + id: string; + + /** + * The origin of the approval request. + */ + origin: string; + + /** + * The time that the request was received, per Date.now(). + */ + time: number; + + /** + * The type of the approval request. + * Unfortunately, not all values will match the `ApprovalType` enum, so we are using `string` here. + * TODO: Replace `string` with `ApprovalType` when all `type` values used by the clients can be encompassed by the `ApprovalType` enum. + */ + type: string; + + /** + * Additional data associated with the request. + */ + requestData: RequestData; + + /** + * Additional mutable state associated with the request + */ + requestState: ApprovalRequestState; + + /** + * Whether the request expects a result object to be returned instead of just the approval value. + */ + expectsResult: boolean; +}; + +export type ApprovalFlowState = ApprovalFlow; + +export type ApprovalControllerState = { + pendingApprovals: Record>>; + pendingApprovalCount: number; + approvalFlows: ApprovalFlowState[]; +}; + +export type ApprovalControllerMessenger = Messenger< + typeof controllerName, + ApprovalControllerActions, + ApprovalControllerEvents +>; + +// Option Types + +export type ShowApprovalRequest = () => void | Promise; + +export type ResultComponent = { + /** + * A unique identifier for this instance of the component. + */ + key: string; + + /** + * The name of the component to render. + */ + name: string; + + /** + * Any properties required by the component. + */ + properties?: Record; + + /** + * Any child components to render inside the component. + */ + children?: string | ResultComponent | (string | ResultComponent)[]; +}; + +export type ApprovalControllerOptions = { + messenger: ApprovalControllerMessenger; + showApprovalRequest: ShowApprovalRequest; + state?: Partial; + typesExcludedFromRateLimiting?: string[]; +}; + +export type AddApprovalOptions = { + id?: string; + origin: string; + type: string; + requestData?: Record; + requestState?: Record; + expectsResult?: boolean; +}; + +export type UpdateRequestStateOptions = { + id: string; + requestState: Record; +}; + +export type AcceptOptions = { + /** + * Whether to resolve the returned promise only when the request creator indicates the success of the + * post-approval logic using the result callbacks. + * If false or unspecified, the promise will resolve immediately. + */ + waitForResult?: boolean; + + /** + * Whether to delete the approval request after a result callback is called. + * If false or unspecified, the approval request will be deleted immediately. + * Ignored if `waitForResult` is false or unspecified. + */ + deleteAfterResult?: boolean; +}; + +export type StartFlowOptions = OptionalField< + ApprovalFlow, + 'id' | 'loadingText' +> & { show?: boolean }; + +export type EndFlowOptions = Pick; + +export type SetFlowLoadingTextOptions = ApprovalFlow; + +export type SuccessOptions = ResultOptions & { + message?: string | ResultComponent | (string | ResultComponent)[]; +}; + +export type ErrorOptions = ResultOptions & { + error?: string | ResultComponent | (string | ResultComponent)[]; +}; + +// Result Types + +export type AcceptResultCallbacks = { + /** + * Inform the request acceptor that the post-approval logic was successful. + * + * @param value - An optional value generated by the post-approval logic. + */ + success: (value?: unknown) => void; + + /** + * Inform the request acceptor that the post-approval logic failed. + * + * @param error - The reason for the failure. + */ + error: (error: Error) => void; +}; + +export type AddResult = { + /** + * An optional value provided by the request acceptor. + */ + value?: unknown; + + /** + * Callback functions that must be used to indicate to the request acceptor whether the post-approval logic was successful or not. + * Will be undefined if the request acceptor did not specify that they want to wait for a result. + */ + resultCallbacks?: AcceptResultCallbacks; +}; + +export type AcceptResult = { + /** + * An optional value provided by the request creator when indicating a successful result. + */ + value?: unknown; +}; + +export type ApprovalFlowStartResult = ApprovalFlow; + +export type SuccessResult = Record; + +export type ErrorResult = Record; + +// Event Types + +export type ApprovalStateChange = ControllerStateChangeEvent< + typeof controllerName, + ApprovalControllerState +>; + +export type ApprovalControllerEvents = ApprovalStateChange; + +// Action Types + +export type ApprovalControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ApprovalControllerState +>; + +export type ApprovalControllerActions = + | ApprovalControllerGetStateAction + | ApprovalControllerMethodActions; + +/** + * Controller for managing requests that require user approval. + * + * Enables limiting the number of pending requests by origin and type, counting + * pending requests, and more. + * + * Adding a request returns a promise that resolves or rejects when the request + * is approved or denied, respectively. + */ +export class ApprovalController extends BaseController< + typeof controllerName, + ApprovalControllerState, + ApprovalControllerMessenger +> { + readonly #approvals: Map; + + readonly #origins: Map>; + + readonly #showApprovalRequest: () => void; + + readonly #typesExcludedFromRateLimiting: string[]; + + /** + * Construct an Approval controller. + * + * @param options - The controller options. + * @param options.showApprovalRequest - Function for opening the UI such that + * the request can be displayed to the user. + * @param options.messenger - The restricted messenger for the Approval controller. + * @param options.state - The initial controller state. + * @param options.typesExcludedFromRateLimiting - Array of approval types which allow multiple pending approval requests from the same origin. + */ + constructor({ + messenger, + showApprovalRequest, + state = {}, + typesExcludedFromRateLimiting = [], + }: ApprovalControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { ...getDefaultState(), ...state }, + }); + + this.#approvals = new Map(); + this.#origins = new Map(); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.#showApprovalRequest = showApprovalRequest; + this.#typesExcludedFromRateLimiting = typesExcludedFromRateLimiting; + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Adds an approval request per the given arguments, optionally showing + * the approval request to the user. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request. + * @param opts.requestData - Additional data associated with the request, + * if any. + * @param opts.requestState - Additional state associated with the request, + * if any. + * @param shouldShowRequest - Whether to show the approval request to the user. + * @returns The approval promise. + */ + addRequest( + opts: AddApprovalOptions, + shouldShowRequest: boolean, + ): Promise { + if (shouldShowRequest) { + return this.addAndShowApprovalRequest(opts); + } + return this.add(opts); + } + + /** + * Adds an approval request per the given arguments, calls the show approval + * request function, and returns the associated approval promise resolving to + * an AddResult object. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting to add an invalid or duplicate request. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request. + * @param opts.requestData - Additional data associated with the request, + * @param opts.requestState - Additional state associated with the request, + * if any. + * @returns The approval promise resolving to an AddResult object. + */ + addAndShowApprovalRequest( + opts: AddApprovalOptions & { expectsResult: true }, + ): Promise; + + /** + * Adds an approval request per the given arguments, calls the show approval + * request function, and returns the associated approval promise resolving + * to a value provided during acceptance. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting to add an invalid or duplicate request. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request. + * @param opts.requestData - Additional data associated with the request, + * @param opts.requestState - Additional state associated with the request, + * if any. + * @returns The approval promise resolving to a value provided during acceptance. + */ + addAndShowApprovalRequest(opts: AddApprovalOptions): Promise; + + addAndShowApprovalRequest(opts: AddApprovalOptions): Promise { + const promise = this.#add( + opts.origin, + opts.type, + opts.id, + opts.requestData, + opts.requestState, + opts.expectsResult, + ); + this.#showApprovalRequest(); + return promise; + } + + /** + * Adds an approval request per the given arguments and returns the approval + * promise resolving to an AddResult object. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting to add an invalid or duplicate request. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request. + * @param opts.requestData - Additional data associated with the request, + * if any. + * @returns The approval promise resolving to an AddResult object. + */ + add(opts: AddApprovalOptions & { expectsResult: true }): Promise; + + /** + * Adds an approval request per the given arguments and returns the approval + * promise resolving to a value provided during acceptance. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting to add an invalid or duplicate request. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request. + * @param opts.requestData - Additional data associated with the request, + * if any. + * @returns The approval promise resolving to a value provided during acceptance. + */ + add(opts: AddApprovalOptions): Promise; + + add(opts: AddApprovalOptions): Promise { + return this.#add( + opts.origin, + opts.type, + opts.id, + opts.requestData, + opts.requestState, + opts.expectsResult, + ); + } + + /** + * Gets the info for the approval request with the given id. + * + * @param id - The id of the approval request. + * @returns The approval request data associated with the id. + */ + get(id: string): ApprovalRequest | undefined { + return this.state.pendingApprovals[id]; + } + + /** + * Gets the number of pending approvals, by origin and/or type. + * + * If only `origin` is specified, all approvals for that origin will be + * counted, regardless of type. + * If only `type` is specified, all approvals for that type will be counted, + * regardless of origin. + * If both `origin` and `type` are specified, 0 or 1 will be returned. + * + * @param opts - The approval count options. + * @param opts.origin - An approval origin. + * @param opts.type - The type of the approval request. + * @returns The current approval request count for the given origin and/or + * type. + */ + getApprovalCount(opts: { origin?: string; type?: string } = {}): number { + if (!opts.origin && !opts.type) { + throw new Error('Must specify origin, type, or both.'); + } + const { origin, type: _type } = opts; + + if (origin && _type) { + return this.#origins.get(origin)?.get(_type) || 0; + } + + if (origin) { + return Array.from( + (this.#origins.get(origin) || new Map()).values(), + ).reduce((total, value) => total + value, 0); + } + + // Only "type" was specified + let count = 0; + for (const approval of Object.values(this.state.pendingApprovals)) { + if (approval.type === _type) { + count += 1; + } + } + return count; + } + + /** + * Get the total count of all pending approval requests for all origins. + * + * @returns The total pending approval request count. + */ + getTotalApprovalCount(): number { + return this.state.pendingApprovalCount; + } + + /** + * Checks if there's a pending approval request per the given parameters. + * At least one parameter must be specified. An error will be thrown if the + * parameters are invalid. + * + * If `id` is specified, all other parameters will be ignored. + * If `id` is not specified, the method will check for requests that match + * all of the specified parameters. + * + * @param opts - Options bag. + * @param opts.id - The ID to check for. + * @param opts.origin - The origin to check for. + * @param opts.type - The type to check for. + * @returns `true` if a matching approval is found, and `false` otherwise. + */ + hasRequest( + opts: { id?: string; origin?: string; type?: string } = {}, + ): boolean { + const { id, origin, type: _type } = opts; + + if (id) { + if (typeof id !== 'string') { + throw new Error('May not specify non-string id.'); + } + return this.#approvals.has(id); + } + + if (_type && typeof _type !== 'string') { + throw new Error('May not specify non-string type.'); + } + + if (origin) { + if (typeof origin !== 'string') { + throw new Error('May not specify non-string origin.'); + } + + // Check origin and type pair if type also specified + if (_type) { + return Boolean(this.#origins.get(origin)?.get(_type)); + } + return this.#origins.has(origin); + } + + if (_type) { + for (const approval of Object.values(this.state.pendingApprovals)) { + if (approval.type === _type) { + return true; + } + } + return false; + } + throw new Error( + 'Must specify a valid combination of id, origin, and type.', + ); + } + + /** + * Resolves the promise of the approval with the given id, and deletes the + * approval. Throws an error if no such approval exists. + * + * @param id - The id of the approval request. + * @param value - The value to resolve the approval promise with. + * @param options - Options bag. + * @returns A promise that either resolves once a result is provided by + * the creator of the approval request, or immediately if `options.waitForResult` + * is `false` or `undefined`. + */ + acceptRequest( + id: string, + value?: unknown, + options?: AcceptOptions, + ): Promise { + // Safe to cast as the delete method below will throw if the ID is not found + const approval = this.get(id) as ApprovalRequest; + const requestPromise = this.#getCallbacks(id); + let requestDeleted = false; + + if (!options?.deleteAfterResult || !options.waitForResult) { + this.#delete(id); + requestDeleted = true; + } + + return new Promise((resolve, reject) => { + const resultCallbacks: AcceptResultCallbacks = { + success: (acceptValue?: unknown) => resolve({ value: acceptValue }), + error: reject, + }; + + if (options?.waitForResult && !approval.expectsResult) { + reject(new ApprovalRequestNoResultSupportError(id)); + return; + } + + const resultValue = options?.waitForResult ? resultCallbacks : undefined; + + const resolveValue = approval.expectsResult + ? { value, resultCallbacks: resultValue } + : value; + + requestPromise.resolve(resolveValue); + + if (!options?.waitForResult) { + resolve({ value: undefined }); + } + }).finally(() => { + if (!requestDeleted) { + this.#delete(id); + } + }); + } + + /** + * Rejects the promise of the approval with the given id, and deletes the + * approval. Throws an error if no such approval exists. + * + * @param id - The id of the approval request. + * @param error - The error to reject the approval promise with. + */ + rejectRequest(id: string, error: unknown): void { + const callbacks = this.#getCallbacks(id); + this.#delete(id); + callbacks.reject(error); + } + + /** + * Rejects and deletes all approval requests. + * + * @param rejectionError - The JsonRpcError to reject the approval + * requests with. + */ + clearRequests(rejectionError: JsonRpcError): void { + for (const id of this.#approvals.keys()) { + this.rejectRequest(id, rejectionError); + } + this.#origins.clear(); + this.update((draftState) => { + draftState.pendingApprovals = {}; + draftState.pendingApprovalCount = 0; + }); + } + + /** + * Updates the request state of the approval with the given id. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. + * @param opts.requestState - Additional data associated with the request + */ + updateRequestState(opts: UpdateRequestStateOptions): void { + if (!this.state.pendingApprovals[opts.id]) { + throw new ApprovalRequestNotFoundError(opts.id); + } + + this.update((draftState) => { + draftState.pendingApprovals[opts.id].requestState = + opts.requestState as never; + }); + } + + /** + * Starts a new approval flow. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval flow. + * @param opts.loadingText - The loading text that will be associated to the approval flow. + * @param opts.show - A flag to determine whether the approval should show to the user. + * @returns The object containing the approval flow id. + */ + startFlow(opts: StartFlowOptions = {}): ApprovalFlowStartResult { + const id = opts.id ?? nanoid(); + const loadingText = opts.loadingText ?? null; + + this.update((draftState) => { + draftState.approvalFlows.push({ id, loadingText }); + }); + + // By default, if nothing else is specified, we always show the approval. + if (opts.show !== false) { + this.#showApprovalRequest(); + } + + return { id, loadingText }; + } + + /** + * Ends the current approval flow. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval flow that will be finished. + */ + endFlow({ id }: EndFlowOptions) { + if (!this.state.approvalFlows.length) { + throw new NoApprovalFlowsError(); + } + + const currentFlow = this.state.approvalFlows.slice(-1)[0]; + + if (id !== currentFlow.id) { + throw new EndInvalidFlowError( + id, + this.state.approvalFlows.map((flow) => flow.id), + ); + } + + this.update((draftState) => { + draftState.approvalFlows.pop(); + }); + } + + /** + * Sets the loading text for the approval flow. + * + * @param opts - Options bag. + * @param opts.id - The approval flow loading text that will be displayed. + * @param opts.loadingText - The loading text that will be associated to the approval flow. + */ + setFlowLoadingText({ id, loadingText }: SetFlowLoadingTextOptions) { + const flowIndex = this.state.approvalFlows.findIndex( + (flow) => flow.id === id, + ); + + if (flowIndex === -1) { + throw new MissingApprovalFlowError(id); + } + + this.update((draftState) => { + draftState.approvalFlows[flowIndex].loadingText = loadingText; + }); + } + + /** + * Show a success page. + * + * @param opts - Options bag. + * @param opts.message - The message text or components to display in the page. + * @param opts.header - The text or components to display in the header of the page. + * @param opts.flowToEnd - The ID of the approval flow to end once the success page is approved. + * @param opts.title - The title to display above the message. Shown by default but can be hidden with `null`. + * @param opts.icon - The icon to display in the page. Shown by default but can be hidden with `null`. + * @returns Empty object to support future additions. + */ + async showSuccess(opts: SuccessOptions = {}): Promise { + await this.#result(APPROVAL_TYPE_RESULT_SUCCESS, opts, { + message: opts.message, + header: opts.header, + title: opts.title, + icon: opts.icon, + } as Record); + + return {}; + } + + /** + * Show an error page. + * + * @param opts - Options bag. + * @param opts.message - The message text or components to display in the page. + * @param opts.header - The text or components to display in the header of the page. + * @param opts.flowToEnd - The ID of the approval flow to end once the error page is approved. + * @param opts.title - The title to display above the message. Shown by default but can be hidden with `null`. + * @param opts.icon - The icon to display in the page. Shown by default but can be hidden with `null`. + * @returns Empty object to support future additions. + */ + async showError(opts: ErrorOptions = {}): Promise { + await this.#result(APPROVAL_TYPE_RESULT_ERROR, opts, { + error: opts.error, + header: opts.header, + title: opts.title, + icon: opts.icon, + } as Record); + + return {}; + } + + /** + * Implementation of add operation. + * + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + * @param id - The id of the approval request. + * @param requestData - The request data associated with the approval request. + * @param requestState - The request state associated with the approval request. + * @param expectsResult - Whether the approval request expects a result object to be returned. + * @returns The approval promise. + */ + #add( + origin: string, + type: string, + id: string = nanoid(), + requestData?: Record, + requestState?: Record, + expectsResult?: boolean, + ): Promise { + this.#validateAddParams(id, origin, type, requestData, requestState); + + if ( + !this.#typesExcludedFromRateLimiting.includes(type) && + this.hasRequest({ origin, type }) + ) { + throw rpcErrors.resourceUnavailable( + getAlreadyPendingMessage(origin, type), + ); + } + + // add pending approval + return new Promise((resolve, reject) => { + this.#approvals.set(id, { resolve, reject }); + this.#addPendingApprovalOrigin(origin, type); + + this.#addToStore( + id, + origin, + type, + requestData, + requestState, + expectsResult, + ); + }); + } + + /** + * Validates parameters to the add method. + * + * @param id - The id of the approval request. + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + * @param requestData - The request data associated with the approval request. + * @param requestState - The request state associated with the approval request. + */ + #validateAddParams( + id: string, + origin: string, + type: string, + requestData?: Record, + requestState?: Record, + ): void { + let errorMessage = null; + if (!id || typeof id !== 'string') { + errorMessage = 'Must specify non-empty string id.'; + } else if (this.#approvals.has(id)) { + errorMessage = `Approval request with id '${id}' already exists.`; + } else if (!origin || typeof origin !== 'string') { + errorMessage = 'Must specify non-empty string origin.'; + } else if (!type || typeof type !== 'string') { + errorMessage = 'Must specify non-empty string type.'; + } else if ( + requestData && + (typeof requestData !== 'object' || Array.isArray(requestData)) + ) { + errorMessage = 'Request data must be a plain object if specified.'; + } else if ( + requestState && + (typeof requestState !== 'object' || Array.isArray(requestState)) + ) { + errorMessage = 'Request state must be a plain object if specified.'; + } + + if (errorMessage) { + throw rpcErrors.internal(errorMessage); + } + } + + /** + * Adds an entry to _origins. + * Performs no validation. + * + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + */ + #addPendingApprovalOrigin(origin: string, type: string): void { + let originMap = this.#origins.get(origin); + + if (!originMap) { + originMap = new Map(); + this.#origins.set(origin, originMap); + } + + const currentValue = originMap.get(type) || 0; + originMap.set(type, currentValue + 1); + } + + /** + * Adds an entry to the store. + * Performs no validation. + * + * @param id - The id of the approval request. + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + * @param requestData - The request data associated with the approval request. + * @param requestState - The request state associated with the approval request. + * @param expectsResult - Whether the request expects a result object to be returned. + */ + #addToStore( + id: string, + origin: string, + type: string, + requestData?: Record, + requestState?: Record, + expectsResult?: boolean, + ): void { + const approval = { + id, + origin, + type, + time: Date.now(), + requestData: requestData || null, + requestState: requestState || null, + expectsResult: expectsResult || false, + }; + + this.update((draftState) => { + draftState.pendingApprovals[id] = approval as never; + + draftState.pendingApprovalCount = Object.keys( + draftState.pendingApprovals, + ).length; + }); + } + + /** + * Deletes the approval with the given id. + * + * Deletion is an internal operation because approval state is solely + * managed by this controller. + * + * @param id - The id of the approval request to be deleted. + */ + #delete(id: string): void { + if (!this.#approvals.has(id)) { + throw new ApprovalRequestNotFoundError(id); + } + + this.#approvals.delete(id); + + const { origin, type } = this.state.pendingApprovals[id]; + + const originMap = this.#origins.get(origin) as Map; + const originTotalCount = this.getApprovalCount({ origin }); + const originTypeCount = originMap.get(type) as number; + + if (originTotalCount === 1) { + this.#origins.delete(origin); + } else { + originMap.set(type, originTypeCount - 1); + } + + this.update((draftState) => { + delete draftState.pendingApprovals[id]; + draftState.pendingApprovalCount = Object.keys( + draftState.pendingApprovals, + ).length; + }); + } + + #getCallbacks(id: string): ApprovalCallbacks { + const callbacks = this.#approvals.get(id); + + if (!callbacks) { + throw new ApprovalRequestNotFoundError(id); + } + + return callbacks; + } + + async #result( + type: string, + opts: ResultOptions, + requestData: Record, + ) { + try { + await this.addAndShowApprovalRequest({ + origin: ORIGIN_METAMASK, + type, + requestData, + }); + } catch (error) { + console.info('Failed to display result page', error); + } finally { + if (opts.flowToEnd) { + try { + this.endFlow({ id: opts.flowToEnd }); + } catch (error) { + console.info('Failed to end flow', { id: opts.flowToEnd, error }); + } + } + } + } +} + +export default ApprovalController; diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 10b063ecb85..b49c688b807 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1,3405 +1,3435 @@ -import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; -import { isValidPrivate, getBinarySize } from '@ethereumjs/util'; -import { BaseController } from '@metamask/base-controller'; -import type * as encryptorUtils from '@metamask/browser-passworder'; -import { HdKeyring } from '@metamask/eth-hd-keyring'; -import { HdKeyring as HdKeyringV2 } from '@metamask/eth-hd-keyring/v2'; -import { normalize as ethNormalize } from '@metamask/eth-sig-util'; -import SimpleKeyring from '@metamask/eth-simple-keyring'; -import { SimpleKeyring as SimpleKeyringV2 } from '@metamask/eth-simple-keyring/v2'; -import type { - KeyringExecutionContext, - EthBaseTransaction, - EthBaseUserOperation, - EthUserOperation, - EthUserOperationPatch, - KeyringAccount, -} from '@metamask/keyring-api'; -import type { - Keyring as KeyringV2, - KeyringType, -} from '@metamask/keyring-api/v2'; -import type { EthKeyring } from '@metamask/keyring-internal-api'; -import type { Keyring, KeyringClass } from '@metamask/keyring-utils'; -import type { Messenger } from '@metamask/messenger'; -import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; -import { - add0x, - assertIsStrictHexString, - bytesToHex, - hasProperty, - hexToBytes, - isObject, - isStrictHexString, - isValidHexAddress, - isValidJson, - remove0x, -} from '@metamask/utils'; -import { Mutex } from 'async-mutex'; -import type { MutexInterface } from 'async-mutex'; -import * as ethereumjsWallet from 'ethereumjs-wallet'; -import type { Patch } from 'immer'; -import { cloneDeep } from 'lodash-es'; -// When generating a ULID within the same millisecond, monotonicFactory provides some guarantees regarding sort order. -import { ulid } from 'ulid'; - -import { KeyringControllerErrorMessage } from './constants.js'; -import { KeyringControllerError } from './errors.js'; -import type { KeyringControllerMethodActions } from './KeyringController-method-action-types.js'; -import type { - Eip7702AuthorizationParams, - Credentials, - PersonalMessageParams, - TypedMessageParams, -} from './types.js'; - -/** - * `ethereumjs-wallet` is CommonJS, and Node cannot reliably detect its named - * exports, so importing `thirdparty` directly fails at run time. It also - * declares a TypeScript-style default export, which means `module.exports` is - * reached through `default` under Node's ESM interop but is the namespace - * itself once `esModuleInterop` has unwrapped it. Both shapes are resolved - * here so the imports work whichever applies. - */ -/* istanbul ignore next: only one branch is reachable per module system */ -const walletModule = (typeof ethereumjsWallet.default === 'object' && -ethereumjsWallet.default - ? ethereumjsWallet.default - : ethereumjsWallet) as unknown as { - default: typeof ethereumjsWallet.default; - thirdparty: typeof ethereumjsWallet.thirdparty; -}; - -const Wallet = walletModule.default; -const importers = walletModule.thirdparty; - -const name = 'KeyringController'; - -const MESSENGER_EXPOSED_METHODS = [ - 'signMessage', - 'signEip7702Authorization', - 'signPersonalMessage', - 'signTransaction', - 'signTypedMessage', - 'decryptMessage', - 'getEncryptionPublicKey', - 'getAccounts', - 'getKeyringsByType', - 'getKeyringForAccount', - 'persistAllKeyrings', - 'prepareUserOperation', - 'patchUserOperation', - 'signUserOperation', - 'addNewAccount', - 'withController', - 'withKeyring', - 'withKeyringUnsafe', - 'withKeyringV2', - 'withKeyringV2Unsafe', - 'addNewKeyring', - 'createNewVaultAndKeychain', - 'createNewVaultAndRestore', - 'removeAccount', - 'isUnlocked', - 'exportSeedPhrase', - 'changePassword', - 'exportAccount', - 'exportEncryptionKey', - 'getAccountKeyringType', - 'importAccountWithStrategy', - 'setLocked', - 'submitEncryptionKey', - 'submitPassword', - 'verifyPassword', -] as const; - -/** - * Available keyring types - * - * @deprecated Use `KeyringType` from `@metamask/keyring-api/v2` instead. This enum will be removed - * in a future release once V2 is fully adopted. Only use it if the keyring you are trying to access - * has no V2 builder available yet. - */ -export enum KeyringTypes { - // Changing this would be a breaking change, and not worth the effort at this - // time, so we disable the linting rule for this block. - /* eslint-disable @typescript-eslint/naming-convention */ - simple = 'Simple Key Pair', - hd = 'HD Key Tree', - qr = 'QR Hardware Wallet Device', - trezor = 'Trezor Hardware', - oneKey = 'OneKey Hardware', - ledger = 'Ledger Hardware', - lattice = 'Lattice Hardware', - snap = 'Snap Keyring', - money = 'Money Keyring', - /* eslint-enable @typescript-eslint/naming-convention */ -} - -/** - * Custody keyring types are a special case, as they are not a single type - * but they all start with the prefix "Custody". - * - * @param keyringType - The type of the keyring. - * @returns Whether the keyring type is a custody keyring. - */ -export const isCustodyKeyring = (keyringType: string): boolean => { - return keyringType.startsWith('Custody'); -}; - -/** - * The KeyringController state - */ -export type KeyringControllerState = { - /** - * Encrypted array of serialized keyrings data. - */ - vault?: string; - /** - * Whether the vault has been decrypted successfully and - * keyrings contained within are deserialized and available. - */ - isUnlocked: boolean; - /** - * Representations of managed keyrings. - */ - keyrings: KeyringObject[]; - /** - * The encryption key derived from the password and used to encrypt - * the vault. This is only stored if the `cacheEncryptionKey` option - * is enabled. - */ - encryptionKey?: string; - /** - * The salt used to derive the encryption key from the password. - */ - encryptionSalt?: string; -}; - -export type KeyringControllerMemState = Omit< - KeyringControllerState, - 'vault' | 'encryptionKey' | 'encryptionSalt' ->; - -export type KeyringControllerGetStateAction = { - type: `${typeof name}:getState`; - handler: () => KeyringControllerState; -}; - -export type KeyringControllerStateChangeEvent = { - type: `${typeof name}:stateChange`; - payload: [KeyringControllerState, Patch[]]; -}; - -export type KeyringControllerAccountRemovedEvent = { - type: `${typeof name}:accountRemoved`; - payload: [string]; -}; - -export type KeyringControllerLockEvent = { - type: `${typeof name}:lock`; - payload: []; -}; - -export type KeyringControllerUnlockEvent = { - type: `${typeof name}:unlock`; - payload: []; -}; - -export type KeyringControllerActions = - | KeyringControllerGetStateAction - | KeyringControllerMethodActions; - -export type KeyringControllerEvents = - | KeyringControllerStateChangeEvent - | KeyringControllerLockEvent - | KeyringControllerUnlockEvent - | KeyringControllerAccountRemovedEvent; - -export type KeyringControllerMessenger = Messenger< - typeof name, - KeyringControllerActions, - KeyringControllerEvents ->; - -export type KeyringControllerOptions< - EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, - SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, - EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, -> = { - keyringBuilders?: { (): EthKeyring; type: string }[]; - keyringV2Builders?: KeyringV2Builder[]; - messenger: KeyringControllerMessenger; - state?: { vault?: string; keyringsMetadata?: KeyringMetadata[] }; - encryptor: Encryptor< - EncryptionKey, - SupportedKeyDerivationOptions, - EncryptionResult - >; -}; - -/** - * A keyring object representation. - */ -export type KeyringObject = { - /** - * Accounts associated with the keyring. - */ - accounts: string[]; - /** - * Keyring type. - */ - type: string; - /** - * Additional data associated with the keyring. - */ - metadata: KeyringMetadata; -}; - -/** - * Additional information related to a keyring. - */ -export type KeyringMetadata = { - /** - * Keyring ID - */ - id: string; - /** - * Keyring name - */ - name: string; -}; - -/** - * A keyring entry, including the keyring instance (+ v2 instance) and its metadata. - */ -export type KeyringEntry = { - /** - * The keyring instance. - */ - keyring: EthKeyring; - - /** - * The keyring V2 instance, if available. - */ - keyringV2?: KeyringV2; - - /** - * The keyring metadata. - */ - metadata: KeyringMetadata; -}; - -/** - * A restricted view of the {@link KeyringController} exposed to the callback - * passed to {@link KeyringController.withController}. - * - * It provides a read-only live view of all keyrings and the ability to stage - * keyring additions and removals atomically within a single transaction. - */ -export type RestrictedController = { - /** - * Read-only live view of all keyrings in the current transaction (original - * keyrings plus any added, minus any removed so far in this callback). - */ - readonly keyrings: readonly KeyringEntry[]; - /** - * Create a new keyring of the given type and stage it for commit. The new - * entry is immediately visible in {@link RestrictedController.keyrings}. - * - * @param type - The type of keyring to create. - * @param opts - Optional data to pass to the keyring builder. - * @returns The newly created `{ keyring, metadata }` entry. - */ - addNewKeyring(type: string, opts?: unknown): Promise; - /** - * Stage the keyring with the given id for removal. The keyring is - * immediately removed from {@link RestrictedController.keyrings}. - * - * @param id - The id of the keyring to remove. - */ - removeKeyring(id: string): Promise; -}; - -/** - * A strategy for importing an account - */ -export enum AccountImportStrategy { - // Changing this would be a breaking change, and not worth the effort at this - // time, so we disable the linting rule for this block. - /* eslint-disable @typescript-eslint/naming-convention */ - privateKey = 'privateKey', - json = 'json', - /* eslint-enable @typescript-eslint/naming-convention */ -} - -/** - * The `signTypedMessage` version - * - * @see https://docs.metamask.io/guide/signing-data.html - */ -export enum SignTypedDataVersion { - V1 = 'V1', - V3 = 'V3', - V4 = 'V4', -} - -/** - * A serialized keyring object. - */ -export type SerializedKeyring = { - type: string; - data: Json; - metadata?: KeyringMetadata; -}; - -/** - * Cached encryption key used to encrypt/decrypt the vault. - */ -type CachedEncryptionKey = { - /** - * The serialized encryption key. - */ - serialized: string; - /** - * The salt used to derive the encryption key. - */ - salt: string; -}; - -/** - * State/data that can be updated during a `withKeyring` operation. - */ -type SessionState = { - keyrings: SerializedKeyring[]; - encryptionKey?: CachedEncryptionKey; -}; - -export type EncryptionResultConstraint = { - salt?: string; - keyMetadata?: SupportedKeyMetadata; -}; - -export type DefaultEncryptionResult = { - data: string; - iv: string; - salt?: string; - keyMetadata?: SupportedKeyMetadata; -}; - -/** - * An encryptor interface that supports encrypting and decrypting - * serializable data with a password, and exporting and importing keys. - */ -export type Encryptor< - EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, - SupportedKeyDerivationParams = encryptorUtils.KeyDerivationOptions, - EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, -> = { - /** - * Encrypts the given object with the given password. - * - * @param password - The password to encrypt with. - * @param object - The object to encrypt. - * @returns The encrypted string. - */ - encrypt: (password: string, object: Json) => Promise; - /** - * Decrypts the given encrypted string with the given password. - * - * @param password - The password to decrypt with. - * @param encryptedString - The encrypted string to decrypt. - * @returns The decrypted object. - */ - decrypt: (password: string, encryptedString: string) => Promise; - /** - * Optional vault migration helper. Checks if the provided vault is up to date - * with the desired encryption algorithm. - * - * @param vault - The encrypted string to check. - * @param targetDerivationParams - The desired target derivation params. - * @returns The updated encrypted string. - */ - isVaultUpdated?: ( - vault: string, - targetDerivationParams?: encryptorUtils.KeyDerivationOptions, - ) => boolean; - /** - * Encrypts the given object with the given encryption key. - * - * @param key - The encryption key to encrypt with. - * @param object - The object to encrypt. - * @returns The encryption result. - */ - encryptWithKey: ( - key: EncryptionKey, - object: Json, - ) => Promise; - /** - * Encrypts the given object with the given password, and returns the - * encryption result and the serialized key string. - * - * @param password - The password to encrypt with. - * @param object - The object to encrypt. - * @param salt - The optional salt to use for encryption. - * @returns The encrypted string and the serialized key string. - */ - encryptWithDetail: ( - password: string, - object: Json, - salt?: string, - ) => Promise; - /** - * Decrypts the given encrypted string with the given encryption key. - * - * @param key - The encryption key to decrypt with. - * @param encryptedObject - The encrypted string to decrypt. - * @returns The decrypted object. - */ - decryptWithKey: ( - key: EncryptionKey, - encryptedObject: EncryptionResult, - ) => Promise; - /** - * Decrypts the given encrypted string with the given password, and returns - * the decrypted object and the salt and serialized key string used for - * encryption. - * - * @param password - The password to decrypt with. - * @param encryptedString - The encrypted string to decrypt. - * @returns The decrypted object and the salt and serialized key string used for - * encryption. - */ - decryptWithDetail: ( - password: string, - encryptedString: string, - ) => Promise; - /** - * Generates an encryption key from a serialized key. - * - * @param key - The serialized key string. - * @returns The encryption key. - */ - importKey: (key: string) => Promise; - /** - * Exports the encryption key as a string. - * - * @param key - The encryption key to export. - * @returns The serialized key string. - */ - exportKey: (key: EncryptionKey) => Promise; - /** - * Derives an encryption key from a password. - * - * @param password - The password to derive the key from. - * @param salt - The salt to use for key derivation. - * @param exportable - Whether the key should be exportable or not. - * @param options - Optional key derivation options. - * @returns The derived encryption key. - */ - keyFromPassword: ( - password: string, - salt: string, - exportable?: boolean, - keyDerivationOptions?: SupportedKeyDerivationParams, - ) => Promise; - /** - * Generates a random salt for key derivation. - */ - generateSalt: typeof encryptorUtils.generateSalt; -}; - -/** - * Keyring selector used for `withKeyring`. - */ -export type KeyringSelector = - | { - type: string; - index?: number; - } - | { - address: Hex; - } - | { - id: string; - } - | { - /** - * A predicate function used to select a keyring. The first keyring for - * which this function returns `true` will be selected. - * - * NOTE: The caller must not mutate the keyring instance passed to this - * function. Mutations bypass the controller's state management - * safeguards and will lead to inconsistent state. The instance is not - * frozen for performance reasons, but treating it as read-only is a - * firm requirement — any mutation is a bug in the caller. - */ - filter: - | ((keyring: EthKeyring, metadata: KeyringMetadata) => boolean) - // Variant of the `filter` function that also acts as a type - // guard, allowing callers to narrow the keyring type within the - // callback. - | (( - keyring: EthKeyring, - metadata: KeyringMetadata, - ) => keyring is SelectedKeyring); - }; - -/** - * Keyring selector used for `withKeyringV2` (see {@link KeyringController#withKeyringV2} and {@link KeyringSelector}). - */ -export type KeyringSelectorV2 = - | { - type: `${KeyringType}`; - index?: number; - } - | { - address: KeyringAccount['address']; - } - | { - id: KeyringMetadata['id']; - } - | { - /** Similar to {@link KeyringSelector.filter} but for `KeyringV2` instances. */ - filter: - | ((keyring: KeyringV2, metadata: KeyringMetadata) => boolean) - | (( - keyring: KeyringV2, - metadata: KeyringMetadata, - ) => keyring is SelectedKeyring); - }; - -/** - * Keyring builder. - */ -export type KeyringBuilder = { - (): Keyring; - type: string; -}; - -/** - * A builder that wraps a legacy `Keyring` into a `KeyringV2` adapter. - * - * The controller calls the builder once when the V1 keyring is created - * or restored; the resulting wrapper is cached for the keyring's lifetime. - */ -export type KeyringV2Builder = { - (keyring: Keyring, metadata: KeyringMetadata): KeyringV2; - type: string; -}; - -/** - * A function executed within a mutually exclusive lock, with - * a mutex releaser in its option bag. - * - * @param releaseLock - A function to release the lock. - */ -type MutuallyExclusiveCallback = ({ - releaseLock, -}: { - releaseLock: MutexInterface.Releaser; -}) => Promise; - -/** - * Get builder function for `Keyring` - * - * Returns a builder function for `Keyring` with a `type` property. - * - * @param KeyringConstructor - The Keyring class for the builder. - * @returns A builder function for the given Keyring. - */ -export function keyringBuilderFactory( - KeyringConstructor: KeyringClass, -): KeyringBuilder { - const builder: KeyringBuilder = (): Keyring => new KeyringConstructor(); - - builder.type = KeyringConstructor.type; - - return builder; -} - -const defaultKeyringBuilders = [ - // todo: keyring types are mismatched, this should be fixed in they keyrings themselves - // @ts-expect-error keyring types are mismatched - keyringBuilderFactory(SimpleKeyring), - keyringBuilderFactory(HdKeyring), -]; - -const hdKeyringV2Builder: KeyringV2Builder = Object.assign( - (keyring: Keyring, metadata: KeyringMetadata): KeyringV2 => - new HdKeyringV2({ - legacyKeyring: keyring as HdKeyring, - entropySource: metadata.id, - }), - { type: KeyringTypes.hd as string }, -); - -const simpleKeyringV2Builder: KeyringV2Builder = Object.assign( - (keyring: Keyring): KeyringV2 => - new SimpleKeyringV2({ - // @ts-expect-error TODO: `Keyring` here comes from `@metamask/keyring-utils`, - // which still depends on `@metamask/utils@^11`, while this package now - // depends on the workspace copy at v12. That leaves two distinct identities - // for the same type, so the cast no longer overlaps. Remove this once the - // keyring packages depend on v12. - legacyKeyring: keyring as SimpleKeyring, - }), - { type: KeyringTypes.simple as string }, -); - -const defaultKeyringV2Builders: KeyringV2Builder[] = [ - simpleKeyringV2Builder, - hdKeyringV2Builder, -]; - -export const getDefaultKeyringState = (): KeyringControllerState => { - return { - isUnlocked: false, - keyrings: [], - }; -}; - -/** - * Assert that the given keyring has an exportable - * mnemonic. - * - * @param keyring - The keyring to check - * @throws When the keyring does not have a mnemonic - */ -function assertHasUint8ArrayMnemonic( - keyring: EthKeyring, -): asserts keyring is EthKeyring & { mnemonic: Uint8Array } { - if ( - !( - hasProperty(keyring, 'mnemonic') && keyring.mnemonic instanceof Uint8Array - ) - ) { - throw new KeyringControllerError("Can't get mnemonic bytes from keyring"); - } -} - -/** - * Assert that the provided password is a valid non-empty string. - * - * @param password - The password to check. - * @throws If the password is not a valid string. - */ -function assertIsValidPassword(password: unknown): asserts password is string { - if (typeof password !== 'string') { - throw new KeyringControllerError( - KeyringControllerErrorMessage.WrongPasswordType, - ); - } - - if (!password?.length) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.InvalidEmptyPassword, - ); - } -} - -/** - * Assert that the provided encryption key is a valid non-empty string. - * - * @param encryptionKey - The encryption key to check. - * @throws If the encryption key is not a valid string. - */ -function assertIsEncryptionKeySet( - encryptionKey: string | undefined, -): asserts encryptionKey is string { - if (!encryptionKey) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.EncryptionKeyNotSet, - ); - } -} - -/** - * Checks if the provided value is a serialized keyrings array. - * - * @param array - The value to check. - * @returns True if the value is a serialized keyrings array. - */ -function isSerializedKeyringsArray( - array: unknown, -): array is SerializedKeyring[] { - return ( - typeof array === 'object' && - Array.isArray(array) && - array.every((value) => value.type && isValidJson(value.data)) - ); -} - -/** - * Display For Keyring - * - * Is used for adding the current keyrings to the state object. - * - * @param keyringWithMetadata - The keyring and its metadata. - * @param keyringWithMetadata.keyring - The keyring to display. - * @param keyringWithMetadata.metadata - The metadata of the keyring. - * @returns A keyring display object, with type and accounts properties. - */ -async function displayForKeyring({ - keyring, - metadata, -}: KeyringEntry): Promise { - const accounts = await keyring.getAccounts(); - - return { - type: keyring.type, - // Cast to `string[]` here is safe here because `accounts` has no nullish - // values, and `normalize` returns `string` unless given a nullish value - accounts: accounts.map(normalize) as string[], - metadata, - }; -} - -/** - * Check if address is an ethereum address - * - * @param address - An address. - * @returns Returns true if the address is an ethereum one, false otherwise. - */ -function isEthAddress(address: string): boolean { - // We first check if it's a matching `Hex` string, so that is narrows down - // `address` as an `Hex` type, allowing us to use `isValidHexAddress` - return ( - // NOTE: This function only checks for lowercased strings - isStrictHexString(address.toLowerCase()) && - // This checks for lowercased addresses and checksum addresses too - isValidHexAddress(address as Hex) - ); -} - -/** - * Normalize ethereum or non-EVM address. - * - * @param address - Ethereum or non-EVM address. - * @returns The normalized address. - */ -function normalize(address: string): string | undefined { - // Since the `KeyringController` is only dealing with address, we have - // no other way to get the associated account type with this address. So we - // are down to check the actual address format for now - // TODO: Find a better way to not have those runtime checks based on the - // address value! - return isEthAddress(address) ? ethNormalize(address) : address; -} - -/** - * Controller responsible for establishing and managing user identity. - * - * This class is a wrapper around the `eth-keyring-controller` package. The - * `eth-keyring-controller` manages the "vault", which is an encrypted store of private keys, and - * it manages the wallet "lock" state. This wrapper class has convenience methods for interacting - * with the internal keyring controller and handling certain complex operations that involve the - * keyrings. - */ -export class KeyringController< - EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, - SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, - EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, -> extends BaseController< - typeof name, - KeyringControllerState, - KeyringControllerMessenger -> { - readonly #controllerOperationMutex = new Mutex(); - - readonly #vaultOperationMutex = new Mutex(); - - readonly #keyringBuilders: { (): EthKeyring; type: string }[]; - - readonly #keyringV2Builders: KeyringV2Builder[]; - - readonly #encryptor: Encryptor< - EncryptionKey, - SupportedKeyDerivationOptions, - EncryptionResult - >; - - #keyrings: KeyringEntry[]; - - #unsupportedKeyrings: SerializedKeyring[]; - - #encryptionKey?: CachedEncryptionKey; - - /** - * Creates a KeyringController instance. - * - * @param options - Initial options used to configure this controller - * @param options.encryptor - An optional object for defining encryption schemes. - * @param options.keyringBuilders - Set a new name for account. - * @param options.cacheEncryptionKey - Whether to cache or not encryption key. - * @param options.messenger - A restricted messenger. - * @param options.state - Initial state to set on this controller. - */ - constructor( - options: KeyringControllerOptions< - EncryptionKey, - SupportedKeyDerivationOptions, - EncryptionResult - >, - ) { - const { encryptor, keyringBuilders, keyringV2Builders, messenger, state } = - options; - - super({ - name, - metadata: { - vault: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - isUnlocked: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: true, - usedInUi: true, - }, - keyrings: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - encryptionKey: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - encryptionSalt: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - }, - messenger, - state: { - ...getDefaultKeyringState(), - ...state, - }, - }); - - this.#keyringBuilders = keyringBuilders - ? keyringBuilders.concat(defaultKeyringBuilders) - : defaultKeyringBuilders; - - this.#keyringV2Builders = keyringV2Builders - ? keyringV2Builders.concat(defaultKeyringV2Builders) - : defaultKeyringV2Builders; - - this.#encryptor = encryptor; - this.#keyrings = []; - this.#unsupportedKeyrings = []; - - this.#registerMessageHandlers(); - } - - /** - * Adds a new account to the default (first) HD seed phrase keyring. - * - * @param accountCount - Number of accounts before adding a new one, used to - * make the method idempotent. - * @returns Promise resolving to the added account address. - */ - async addNewAccount(accountCount?: number): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as - | EthKeyring - | undefined; - if (!primaryKeyring) { - throw new KeyringControllerError('No HD keyring found'); - } - const oldAccounts = await primaryKeyring.getAccounts(); - - if (accountCount && oldAccounts.length !== accountCount) { - if (accountCount > oldAccounts.length) { - throw new KeyringControllerError('Account out of sequence'); - } - // we return the account already existing at index `accountCount` - const existingAccount = oldAccounts[accountCount]; - - if (!existingAccount) { - throw new KeyringControllerError( - `Can't find account at index ${accountCount}`, - ); - } - - return existingAccount; - } - - const [addedAccountAddress] = await primaryKeyring.addAccounts(1); - await this.#verifySeedPhrase(); - - return addedAccountAddress; - }); - } - - /** - * Adds a new account to the specified keyring. - * - * @param keyring - Keyring to add the account to. - * @param accountCount - Number of accounts before adding a new one, used to make the method idempotent. - * @returns Promise resolving to the added account address - */ - async addNewAccountForKeyring( - keyring: EthKeyring, - accountCount?: number, - ): Promise { - // READ THIS CAREFULLY: - // We still uses `Hex` here, since we are not using this method when creating - // and account using a "Snap Keyring". This function assume the `keyring` is - // ethereum compatible, but "Snap Keyring" might not be. - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - const oldAccounts = await this.#getAccountsFromKeyrings(); - - if (accountCount && oldAccounts.length !== accountCount) { - if (accountCount > oldAccounts.length) { - throw new KeyringControllerError('Account out of sequence'); - } - - const existingAccount = oldAccounts[accountCount]; - assertIsStrictHexString(existingAccount); - - return existingAccount; - } - - await keyring.addAccounts(1); - - const addedAccountAddress = (await this.#getAccountsFromKeyrings()).find( - (selectedAddress) => !oldAccounts.includes(selectedAddress), - ); - assertIsStrictHexString(addedAccountAddress); - - return addedAccountAddress; - }); - } - - /** - * Effectively the same as creating a new keychain then populating it - * using the given seed phrase. - * - * @param password - Password to unlock keychain. - * @param seed - A BIP39-compliant seed phrase as Uint8Array, - * either as a string or an array of UTF-8 bytes that represent the string. - * @returns Promise resolving when the operation ends successfully. - */ - async createNewVaultAndRestore( - password: string, - seed: Uint8Array, - ): Promise { - return this.#persistOrRollback(async () => { - assertIsValidPassword(password); - - await this.#createNewVaultWithKeyring(password, { - type: KeyringTypes.hd, - opts: { - mnemonic: seed, - numberOfAccounts: 1, - }, - }); - }); - } - - /** - * Create a new vault and primary keyring. - * - * This only works if keyrings are empty. If there is a pre-existing unlocked vault, calling this will have no effect. - * If there is a pre-existing locked vault, it will be replaced. - * - * @param password - Password to unlock the new vault. - * @returns Promise resolving when the operation ends successfully. - */ - async createNewVaultAndKeychain(password: string): Promise { - return this.#persistOrRollback(async () => { - const accounts = await this.#getAccountsFromKeyrings(); - if (!accounts.length) { - await this.#createNewVaultWithKeyring(password, { - type: KeyringTypes.hd, - }); - } - }); - } - - /** - * Adds a new keyring of the given `type`. - * - * @param type - Keyring type name. - * @param opts - Keyring options. - * @throws If a builder for the given `type` does not exist. - * @returns Promise resolving to the new keyring metadata. - */ - async addNewKeyring( - type: KeyringTypes | string, - opts?: unknown, - ): Promise { - this.#assertIsUnlocked(); - - return this.#getKeyringMetadata( - await this.#persistOrRollback(async () => this.#newKeyring(type, opts)), - ); - } - - /** - * Method to verify a given password validity. Throws an - * error if the password is invalid. - * - * @param password - Password of the keyring. - */ - async verifyPassword(password: string): Promise { - if (!this.state.vault) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.VaultError, - ); - } - await this.#encryptor.decrypt(password, this.state.vault); - } - - /** - * Method to verify a given encryption key validity. Throws an error if the - * encryption key is invalid, i.e. it cannot decrypt the vault. - * - * @param encryptionKey - Serialized vault encryption key. - * @param encryptionSalt - Optional salt to verify against the vault. When - * omitted, the salt serialized alongside the vault is used. - */ - async #verifyEncryptionKey( - encryptionKey: string, - encryptionSalt?: string, - ): Promise { - if (!this.state.vault) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.VaultError, - ); - } - - const parsedEncryptedVault = JSON.parse(this.state.vault); - const salt = encryptionSalt ?? parsedEncryptedVault.salt; - - if (parsedEncryptedVault.salt !== salt) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.ExpiredCredentials, - ); - } - - const key = await this.#encryptor.importKey(encryptionKey); - await this.#encryptor.decryptWithKey(key, parsedEncryptedVault); - } - - /** - * Verifies export credentials by checking either the wallet password or the - * vault encryption key. - * - * @param credentials - Object holding either the `password` or the vault - * `encryptionKey`. - */ - async #verifyCredentials(credentials: Credentials): Promise { - // eslint-disable-next-line no-restricted-syntax - if ('password' in credentials) { - await this.verifyPassword(credentials.password); - } else { - await this.#verifyEncryptionKey( - credentials.encryptionKey, - credentials.encryptionSalt, - ); - } - } - - /** - * Returns the status of the vault. - * - * @returns Boolean returning true if the vault is unlocked. - */ - isUnlocked(): boolean { - return this.state.isUnlocked; - } - - /** - * Gets the seed phrase of the HD keyring. - * - * @param credentials - Object holding either the `password` or the vault - * `encryptionKey`. - * @param keyringId - The id of the keyring. - * @returns Promise resolving to the seed phrase. - */ - async exportSeedPhrase( - credentials: Credentials, - keyringId?: string, - ): Promise { - this.#assertIsUnlocked(); - - await this.#verifyCredentials(credentials); - - const selectedKeyring = this.#getKeyringByIdOrDefault(keyringId); - if (!selectedKeyring) { - throw new KeyringControllerError('Keyring not found'); - } - assertHasUint8ArrayMnemonic(selectedKeyring); - - return selectedKeyring.mnemonic; - } - - /** - * Gets the private key from the keyring controlling an address. - * - * @param credentials - Object holding either the `password` or the vault - * `encryptionKey`. - * @param address - Address to export. - * @returns Promise resolving to the private key for an address. - */ - async exportAccount( - credentials: Credentials, - address: string, - ): Promise { - this.#assertIsUnlocked(); - - await this.#verifyCredentials(credentials); - - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.exportAccount) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedExportAccount, - ); - } - - return await keyring.exportAccount(normalize(address) as Hex); - } - - /** - * Returns the public addresses of all accounts from every keyring. - * - * @returns A promise resolving to an array of addresses. - */ - async getAccounts(): Promise { - this.#assertIsUnlocked(); - return this.state.keyrings.reduce( - (accounts, keyring) => accounts.concat(keyring.accounts), - [], - ); - } - - /** - * Get encryption public key. - * - * @param account - An account address. - * @param opts - Additional encryption options. - * @throws If the `account` does not exist or does not support the `getEncryptionPublicKey` method - * @returns Promise resolving to encyption public key of the `account` if one exists. - */ - async getEncryptionPublicKey( - account: string, - opts?: Record, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(account) as Hex; - const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; - if (!keyring.getEncryptionPublicKey) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedGetEncryptionPublicKey, - ); - } - - return await keyring.getEncryptionPublicKey(address, opts); - } - - /** - * Attempts to decrypt the provided message parameters. - * - * @param messageParams - The decryption message parameters. - * @param messageParams.from - The address of the account you want to use to decrypt the message. - * @param messageParams.data - The encrypted data that you want to decrypt. - * @returns The raw decryption result. - */ - async decryptMessage(messageParams: { - from: string; - data: Eip1024EncryptedData; - }): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(messageParams.from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.decryptMessage) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedDecryptMessage, - ); - } - - return keyring.decryptMessage(address, messageParams.data); - } - - /** - * Returns the currently initialized keyring that manages - * the specified `address` if one exists. - * - * @deprecated Use of this method is discouraged as actions executed directly on - * keyrings are not being reflected in the KeyringController state and not - * persisted in the vault. Use `withKeyring` instead. - * @param account - An account address. - * @returns Promise resolving to keyring of the `account` if one exists. - */ - async getKeyringForAccount(account: string): Promise { - this.#assertIsUnlocked(); - const keyring = await this.#getKeyringForAccount(account); - if (keyring) { - return keyring; - } - - if (this.#keyrings.length === 0) { - throw new KeyringControllerError(KeyringControllerErrorMessage.NoKeyring); - } - - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - async #getKeyringForAccount( - account: string, - ): Promise { - this.#assertIsUnlocked(); - const entry = await this.#getKeyringEntryForAccount(account); - return entry?.keyring; - } - - async #getKeyringEntryForAccount( - account: string, - ): Promise { - this.#assertIsUnlocked(); - const keyringIndex = await this.#findKeyringIndexForAccount(account); - if (keyringIndex > -1) { - return this.#keyrings[keyringIndex]; - } - return undefined; - } - - async #findKeyringIndexForAccount(account: string): Promise { - this.#assertIsUnlocked(); - const address = account.toLowerCase(); - const accountsPerKeyring = await Promise.all( - this.#keyrings.map(({ keyring }) => keyring.getAccounts()), - ); - return accountsPerKeyring.findIndex((accounts) => - accounts.map((a) => a.toLowerCase()).includes(address), - ); - } - - /** - * Returns all keyrings of the given type. - * - * @deprecated Use of this method is discouraged as actions executed directly on - * keyrings are not being reflected in the KeyringController state and not - * persisted in the vault. Use `withKeyring` instead. - * @param type - Keyring type name. - * @returns An array of keyrings of the given type. - */ - getKeyringsByType(type: KeyringTypes | string): unknown[] { - this.#assertIsUnlocked(); - return this.#getKeyringEntriesByType({ v2: false, type }).map( - ({ keyring }) => keyring, - ); - } - - #getKeyringEntriesByType({ - v2, - type, - }: - | { - v2: false; - type: KeyringTypes | string; - } - | { - v2: true; - type: `${KeyringType}`; - }): KeyringEntry[] { - this.#assertIsUnlocked(); - return this.#keyrings.filter(({ keyring, keyringV2 }) => - v2 ? keyringV2?.type === type : keyring.type === type, - ); - } - - /** - * Persist all serialized keyrings in the vault. - * - * @deprecated This method is being phased out in favor of `withKeyring`. - * @returns Promise resolving with `true` value when the - * operation completes. - */ - async persistAllKeyrings(): Promise { - return this.#withRollback(async () => { - this.#assertIsUnlocked(); - - await this.#updateVault(); - return true; - }); - } - - /** - * Imports an account with the specified import strategy. - * - * @param strategy - Import strategy name. - * @param args - Array of arguments to pass to the underlying stategy. - * @throws Will throw when passed an unrecognized strategy. - * @returns Promise resolving to the imported account address. - */ - async importAccountWithStrategy( - strategy: AccountImportStrategy, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any[], - ): Promise { - this.#assertIsUnlocked(); - return this.#persistOrRollback(async () => { - let privateKey; - switch (strategy) { - case AccountImportStrategy.privateKey: { - const [importedKey] = args; - if (!importedKey) { - throw new KeyringControllerError('Cannot import an empty key.'); - } - const prefixed = add0x(importedKey); - - let bufferedPrivateKey; - try { - bufferedPrivateKey = hexToBytes(prefixed); - } catch { - throw new KeyringControllerError( - 'Cannot import invalid private key.', - ); - } - - if ( - !isValidPrivate(bufferedPrivateKey) || - // ensures that the key is 64 bytes long - getBinarySize(prefixed) !== 64 + '0x'.length - ) { - throw new KeyringControllerError( - 'Cannot import invalid private key.', - ); - } - - privateKey = remove0x(prefixed); - break; - } - case AccountImportStrategy.json: { - let wallet; - const [input, password] = args; - try { - wallet = importers.fromEtherWallet(input, password); - } catch { - // @ts-expect-error: Wallet.fromV3 does not exist? - wallet = wallet ?? (await Wallet.fromV3(input, password, true)); - } - privateKey = bytesToHex(new Uint8Array(wallet.getPrivateKey())); - break; - } - default: - throw new KeyringControllerError( - `Unexpected import strategy: '${String(strategy)}'`, - ); - } - const newKeyring = await this.#newKeyring(KeyringTypes.simple, [ - privateKey, - ]); - const accounts = await newKeyring.getAccounts(); - return accounts[0]; - }); - } - - /** - * Removes an account from keyring state. - * - * @param address - Address of the account to remove. - * @fires KeyringController:accountRemoved - * @returns Promise resolving when the account is removed. - */ - async removeAccount(address: string): Promise { - this.#assertIsUnlocked(); - - await this.#persistOrRollback(async () => { - const keyringIndex = await this.#findKeyringIndexForAccount(address); - - if (keyringIndex === -1) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.NoKeyring, - ); - } - - const { keyring, keyringV2 } = this.#keyrings[keyringIndex]; - - const isPrimaryKeyring = keyringIndex === 0; - const shouldRemoveKeyring = (await keyring.getAccounts()).length === 1; - - // Primary keyring should never be removed, so we need to keep at least one account in it - if (isPrimaryKeyring && shouldRemoveKeyring) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, - ); - } - - // Not all the keyrings support this, so we have to check - if (!keyring.removeAccount) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedRemoveAccount, - ); - } - - // FIXME #1: We do cast to `Hex` to make the type checker happy here, and - // because `Keyring.removeAccount` requires address to be `Hex`. - // Those types would need to be updated for a full non-EVM support. - // - // FIXME #2: The `removeAccount` method of snaps keyring is async. We have - // to update the interface of the other keyrings to be async as well. - // eslint-disable-next-line @typescript-eslint/await-thenable - await keyring.removeAccount(address as Hex); - - if (shouldRemoveKeyring) { - this.#keyrings.splice(keyringIndex, 1); - await this.#destroyKeyring(keyring, keyringV2); - } - }); - - this.messenger.publish(`${name}:accountRemoved`, address); - } - - /** - * Deallocates all secrets and locks the wallet. - * - * @returns Promise resolving when the operation completes. - */ - async setLocked(): Promise { - this.#assertIsUnlocked(); - - return this.#withRollback(async () => { - this.#encryptionKey = undefined; - await this.#clearKeyrings(); - - this.update((state) => { - state.isUnlocked = false; - state.keyrings = []; - delete state.encryptionKey; - delete state.encryptionSalt; - }); - - this.messenger.publish(`${name}:lock`); - }); - } - - /** - * Signs message by calling down into a specific keyring. - * - * @param messageParams - PersonalMessageParams object to sign. - * @returns Promise resolving to a signed message string. - */ - async signMessage(messageParams: PersonalMessageParams): Promise { - this.#assertIsUnlocked(); - - if (!messageParams.data) { - throw new KeyringControllerError("Can't sign an empty message"); - } - - const address = ethNormalize(messageParams.from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.signMessage) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignMessage, - ); - } - - return await keyring.signMessage(address, messageParams.data); - } - - /** - * Signs EIP-7702 Authorization message by calling down into a specific keyring. - * - * @param params - EIP7702AuthorizationParams object to sign. - * @returns Promise resolving to an EIP-7702 Authorization signature. - * @throws Will throw UnsupportedSignEIP7702Authorization if the keyring does not support signing EIP-7702 Authorization messages. - */ - async signEip7702Authorization( - params: Eip7702AuthorizationParams, - ): Promise { - const from = ethNormalize(params.from) as Hex; - - const keyring = (await this.getKeyringForAccount(from)) as EthKeyring; - - if (!keyring.signEip7702Authorization) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignEip7702Authorization, - ); - } - - const { chainId, nonce } = params; - const contractAddress = ethNormalize(params.contractAddress) as - | Hex - | undefined; - - if (contractAddress === undefined) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.MissingEip7702AuthorizationContractAddress, - ); - } - - return await keyring.signEip7702Authorization(from, [ - chainId, - contractAddress, - nonce, - ]); - } - - /** - * Signs personal message by calling down into a specific keyring. - * - * @param messageParams - PersonalMessageParams object to sign. - * @returns Promise resolving to a signed message string. - */ - async signPersonalMessage( - messageParams: PersonalMessageParams, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(messageParams.from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.signPersonalMessage) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignPersonalMessage, - ); - } - - const normalizedData = normalize(messageParams.data) as Hex; - - return await keyring.signPersonalMessage(address, normalizedData); - } - - /** - * Signs typed message by calling down into a specific keyring. - * - * @param messageParams - TypedMessageParams object to sign. - * @param version - Compatibility version EIP712. - * @throws Will throw when passed an unrecognized version. - * @returns Promise resolving to a signed message string or an error if any. - */ - async signTypedMessage( - messageParams: TypedMessageParams, - version: SignTypedDataVersion, - ): Promise { - this.#assertIsUnlocked(); - - try { - if ( - ![ - SignTypedDataVersion.V1, - SignTypedDataVersion.V3, - SignTypedDataVersion.V4, - ].includes(version) - ) { - throw new KeyringControllerError( - `Unexpected signTypedMessage version: '${version}'`, - ); - } - - // Cast to `Hex` here is safe here because `messageParams.from` is not nullish. - // `normalize` returns `Hex` unless given a nullish value. - const address = ethNormalize(messageParams.from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.signTypedData) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignTypedMessage, - ); - } - - return await keyring.signTypedData( - address, - version !== SignTypedDataVersion.V1 && - typeof messageParams.data === 'string' - ? JSON.parse(messageParams.data) - : messageParams.data, - { version }, - ); - } catch (error) { - const errorMessage = - error instanceof Error - ? `${error.name}: ${error.message}` - : String(error); - throw new KeyringControllerError( - `Keyring Controller signTypedMessage: ${errorMessage}`, - error instanceof Error ? error : undefined, - ); - } - } - - /** - * Signs a transaction by calling down into a specific keyring. - * - * @param transaction - Transaction object to sign. Must be a `ethereumjs-tx` transaction instance. - * @param from - Address to sign from, should be in keychain. - * @param opts - An optional options object. - * @returns Promise resolving to a signed transaction string. - */ - async signTransaction( - transaction: TypedTransaction, - from: string, - opts?: Record, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - if (!keyring.signTransaction) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignTransaction, - ); - } - - return await keyring.signTransaction(address, transaction, opts); - } - - /** - * Convert a base transaction to a base UserOperation. - * - * @param from - Address of the sender. - * @param transactions - Base transactions to include in the UserOperation. - * @param executionContext - The execution context to use for the UserOperation. - * @returns A pseudo-UserOperation that can be used to construct a real. - */ - async prepareUserOperation( - from: string, - transactions: EthBaseTransaction[], - executionContext: KeyringExecutionContext, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - - if (!keyring.prepareUserOperation) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedPrepareUserOperation, - ); - } - - return await keyring.prepareUserOperation( - address, - transactions, - executionContext, - ); - } - - /** - * Patches properties of a UserOperation. Currently, only the - * `paymasterAndData` can be patched. - * - * @param from - Address of the sender. - * @param userOp - UserOperation to patch. - * @param executionContext - The execution context to use for the UserOperation. - * @returns A patch to apply to the UserOperation. - */ - async patchUserOperation( - from: string, - userOp: EthUserOperation, - executionContext: KeyringExecutionContext, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - - if (!keyring.patchUserOperation) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedPatchUserOperation, - ); - } - - return await keyring.patchUserOperation(address, userOp, executionContext); - } - - /** - * Signs an UserOperation. - * - * @param from - Address of the sender. - * @param userOp - UserOperation to sign. - * @param executionContext - The execution context to use for the UserOperation. - * @returns The signature of the UserOperation. - */ - async signUserOperation( - from: string, - userOp: EthUserOperation, - executionContext: KeyringExecutionContext, - ): Promise { - this.#assertIsUnlocked(); - const address = ethNormalize(from) as Hex; - const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - - if (!keyring.signUserOperation) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedSignUserOperation, - ); - } - - return await keyring.signUserOperation(address, userOp, executionContext); - } - - /** - * Changes the password used to encrypt the vault. - * - * @param password - The new password. - * @returns Promise resolving when the operation completes. - */ - changePassword(password: string): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - assertIsValidPassword(password); - await this.#deriveAndSetEncryptionKey(password, { - ignoreExistingVault: true, - }); - }); - } - - /** - * Attempts to decrypt the current vault and load its keyrings, using the - * given encryption key and salt. The optional salt can be used to check for - * consistency with the vault salt. - * - * @param encryptionKey - Key to unlock the keychain. - * @param encryptionSalt - Optional salt to unlock the keychain. - * @returns Promise resolving when the operation completes. - */ - async submitEncryptionKey( - encryptionKey: string, - encryptionSalt?: string, - ): Promise { - const { hasChanged } = await this.#withRollback(async () => { - const result = await this.#unlockKeyrings({ - encryptionKey, - encryptionSalt, - }); - this.#setUnlocked(); - return result; - }); - - try { - // if new metadata has been generated during login, we - // can attempt to upgrade the vault. - await this.#withRollback(async () => { - if (hasChanged) { - await this.#updateVault(); - } - }); - } catch (error) { - // We don't want to throw an error if the upgrade fails - // since the controller is already unlocked. - console.error('Failed to update vault during login:', error); - } - } - - /** - * Exports the vault encryption key. - * - * @returns The vault encryption key. - */ - async exportEncryptionKey(): Promise { - this.#assertIsUnlocked(); - - return await this.#withControllerLock(async () => { - assertIsEncryptionKeySet(this.#encryptionKey?.serialized); - return this.#encryptionKey.serialized; - }); - } - - /** - * Attempts to decrypt the current vault and load its keyrings, - * using the given password. - * - * @param password - Password to unlock the keychain. - * @returns Promise resolving when the operation completes. - */ - async submitPassword(password: string): Promise { - const { hasChanged } = await this.#withRollback(async () => { - const result = await this.#unlockKeyrings({ password }); - this.#setUnlocked(); - return result; - }); - - try { - // If there are stronger encryption params available, or - // if the keyring state has changed during deserialization, we - // can attempt to upgrade the vault. - await this.#withRollback(async () => { - if (hasChanged || this.#isNewEncryptionAvailable()) { - await this.#deriveAndSetEncryptionKey(password, { - // If the vault is being upgraded, we want to ignore the metadata - // that is already in the vault, so we can effectively - // re-encrypt the vault with the new encryption config. - ignoreExistingVault: true, - }); - await this.#updateVault(); - } - }); - } catch (error) { - // We don't want to throw an error if the upgrade fails - // since the controller is already unlocked. - console.error('Failed to update vault during login:', error); - } - } - - /** - * Verifies the that the seed phrase restores the current keychain's accounts. - * - * @param keyringId - The id of the keyring to verify. - * @returns Promise resolving to the seed phrase as Uint8Array. - */ - async verifySeedPhrase(keyringId?: string): Promise { - this.#assertIsUnlocked(); - - return this.#withControllerLock(async () => - this.#verifySeedPhrase(keyringId), - ); - } - - /** - * Asserts a value is not a specific keyring instance, and throws an error if it is. - * - * @param value The value to check. - * @param keyring The keyring instance to check against. - * @throws If the value is the same instance as the keyring. - * @returns The original value if the check passes. - */ - #assertNoUnsafeDirectKeyringAccess( - value: Value, - keyring: SelectedKeyring, - ): Value { - if (Object.is(value, keyring)) { - // Access to a keyring instance outside of controller safeguards - // should be discouraged, as it can lead to unexpected behavior. - // This error is thrown to prevent consumers using `withKeyring` - // as a way to get a reference to a keyring instance. - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, - ); - } - - return value; - } - - /** - * Select a keyring and execute the given operation with - * the selected keyring, as a mutually exclusive atomic - * operation. - * - * The method automatically persists changes at the end of the - * function execution, or rolls back the changes if an error - * is thrown. - * - * @param selector - Keyring selector object. - * @param operation - Function to execute with the selected keyring. - * @param options - Additional options. - * @param options.createIfMissing - Whether to create a new keyring if the selected one is missing. - * @param options.createWithData - Optional data to use when creating a new keyring. - * @returns Promise resolving to the result of the function execution. - * @template SelectedKeyring - The type of the selected keyring. - * @template CallbackResult - The type of the value resolved by the callback function. - * @deprecated This method overload is deprecated. Use `withKeyring` without options instead. - */ - async withKeyring< - SelectedKeyring extends EthKeyring = EthKeyring, - CallbackResult = void, - >( - selector: KeyringSelector, - operation: ({ keyring, metadata }: KeyringEntry) => Promise, - // eslint-disable-next-line @typescript-eslint/unified-signatures - options: - | { createIfMissing?: false } - | { createIfMissing: true; createWithData?: unknown }, - ): Promise; - - /** - * Select a keyring and execute the given operation with - * the selected keyring, as a mutually exclusive atomic - * operation. - * - * The method automatically persists changes at the end of the - * function execution, or rolls back the changes if an error - * is thrown. - * - * @param selector - Keyring selector object. - * @param operation - Function to execute with the selected keyring. - * @returns Promise resolving to the result of the function execution. - * @template SelectedKeyring - The type of the selected keyring. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withKeyring< - SelectedKeyring extends EthKeyring = EthKeyring, - CallbackResult = void, - >( - selector: KeyringSelector, - operation: ({ keyring, metadata }: KeyringEntry) => Promise, - ): Promise; - - async withKeyring< - SelectedKeyring extends EthKeyring = EthKeyring, - CallbackResult = void, - >( - selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, - options: - | { createIfMissing?: false } - | { createIfMissing: true; createWithData?: unknown } = { - createIfMissing: false, - }, - ): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - let entry: KeyringEntry | undefined = await this.#selectKeyringEntry({ - v2: false, - selector, - }); - - if (!entry && 'type' in selector && options.createIfMissing) { - const newKeyring = (await this.#newKeyring( - selector.type, - options.createWithData, - )) as SelectedKeyring; - entry = this.#keyrings.find(({ keyring }) => keyring === newKeyring); - } - - if (!entry) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - const { metadata } = entry; - const keyring = entry.keyring as SelectedKeyring; - - return this.#assertNoUnsafeDirectKeyringAccess( - await this.#cleanUpEmptiedKeyringsAfter(async () => - operation({ keyring, metadata }), - ), - keyring, - ); - }); - } - - /** - * Select a keyring and execute the given operation with the selected - * keyring, **without** acquiring the controller's mutual exclusion lock. - * - * ## When to use this method - * - * This method is an escape hatch for read-only access to keyring data that - * is immutable once the keyring is initialized. A typical safe use case is - * reading the `mnemonic` from an `HdKeyring`: the mnemonic is set during - * `deserialize()` and never mutated afterwards, so it can safely be read - * without holding the lock. - * - * ## Why it is "unsafe" - * - * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in - * Rust: the method itself does not enforce thread-safety guarantees. By - * calling this method the **caller** explicitly takes responsibility for - * ensuring that: - * - * - The operation is **read-only** — no state is mutated. - * - The data being read is **immutable** after the keyring is initialized, - * so concurrent locked operations cannot alter it while this callback - * runs. - * - * Do **not** use this method to: - * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyring`. - * - Read mutable fields that could change during concurrent operations. - * - * @param selector - Keyring selector object. - * @param operation - Read-only function to execute with the selected keyring. - * @returns Promise resolving to the result of the function execution. - * @template SelectedKeyring - The type of the selected keyring. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withKeyringUnsafe< - SelectedKeyring extends EthKeyring = EthKeyring, - CallbackResult = void, - >( - selector: KeyringSelector, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, - ): Promise { - this.#assertIsUnlocked(); - - const entry = await this.#selectKeyringEntry({ v2: false, selector }); - - if (!entry) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - const { metadata } = entry; - const keyring = entry.keyring as SelectedKeyring; - - // Even if this method is "unsafe", we still want to prevent returning - // the keyring directly. - return this.#assertNoUnsafeDirectKeyringAccess( - await operation({ keyring, metadata }), - keyring, - ); - } - - /** - * Select a keyring using its `KeyringV2` adapter, and execute - * the given operation with the wrapped keyring as a mutually - * exclusive atomic operation. - * - * The cached `KeyringV2` adapter is retrieved from the keyring - * entry. - * - * A `KeyringV2Builder` for the selected keyring's type must exist - * (either as a default or registered via the `keyringV2Builders` - * constructor option); otherwise an error is thrown. - * - * The method automatically persists changes at the end of the - * function execution, or rolls back the changes if an error - * is thrown. - * - * @param selector - Keyring selector object. - * @param operation - Function to execute with the wrapped V2 keyring. - * @returns Promise resolving to the result of the function execution. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withKeyringV2< - SelectedKeyring extends KeyringV2 = KeyringV2, - CallbackResult = void, - >( - selector: KeyringSelectorV2, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, - ): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - const entry = await this.#selectKeyringEntry({ - v2: true, - selector, - }); - - if (!entry) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - if (!entry.keyringV2) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringV2NotSupported, - ); - } - - const { metadata } = entry; - const keyring = entry.keyringV2 as SelectedKeyring; - - return this.#assertNoUnsafeDirectKeyringAccess( - await this.#cleanUpEmptiedKeyringsAfter(async () => - operation({ - keyring, - metadata, - }), - ), - keyring, - ); - }); - } - - /** - * Select a keyring, wrap it in a `KeyringV2` adapter, and execute - * the given read-only operation **without** acquiring the controller's - * mutual exclusion lock. - * - * ## When to use this method - * - * This method is an escape hatch for read-only access to keyring data that - * is immutable once the keyring is initialized. A typical safe use case is - * reading immutable fields from a `KeyringV2` adapter: data that is set - * during initialization and never mutated afterwards. - * - * ## Why it is "unsafe" - * - * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in - * Rust: the method itself does not enforce thread-safety guarantees. By - * calling this method the **caller** explicitly takes responsibility for - * ensuring that: - * - * - The operation is **read-only** — no state is mutated. - * - The data being read is **immutable** after the keyring is initialized, - * so concurrent locked operations cannot alter it while this callback - * runs. - * - * Do **not** use this method to: - * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyringV2`. - * - Read mutable fields that could change during concurrent operations. - * - * @param selector - Keyring selector object. - * @param operation - Read-only function to execute with the wrapped V2 keyring. - * @returns Promise resolving to the result of the function execution. - * @template SelectedKeyring - The type of the selected V2 keyring. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withKeyringV2Unsafe< - SelectedKeyring extends KeyringV2 = KeyringV2, - CallbackResult = void, - >( - selector: KeyringSelectorV2, - operation: ({ - keyring, - metadata, - }: { - keyring: SelectedKeyring; - metadata: KeyringMetadata; - }) => Promise, - ): Promise { - this.#assertIsUnlocked(); - - const entry = await this.#selectKeyringEntry({ - v2: true, - selector, - }); - - if (!entry) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - if (!entry.keyringV2) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringV2NotSupported, - ); - } - - const { metadata } = entry; - const keyring = entry.keyringV2 as SelectedKeyring; - - // Even if this method is "unsafe", we still want to prevent returning - // the keyring directly. - return this.#assertNoUnsafeDirectKeyringAccess( - await operation({ keyring, metadata }), - keyring, - ); - } - - /** - * Execute an operation against all keyrings as a mutually exclusive atomic - * operation. The operation receives a {@link RestrictedController} instance - * that exposes a read-only live view of all keyrings as well as - * `addNewKeyring` and `removeKeyring` methods to stage mutations. - * - * The method automatically persists changes at the end of the function - * execution, or rolls back the changes if an error is thrown. - * - * @param operation - Function to execute with the restricted controller. - * @returns Promise resolving to the result of the function execution. - * @template CallbackResult - The type of the value resolved by the callback function. - */ - async withController( - operation: ( - restrictedController: RestrictedController, - ) => Promise, - ): Promise { - this.#assertIsUnlocked(); - - return this.#persistOrRollback(async () => { - // Track created and removed keyrings during the operation execution. - const createdEntries = new Set(); - const removedEntries = new Set(); - - // Copy of the current keyrings that is mutated during the operation execution. - const restrictedEntries = [...this.#keyrings]; - - // The restricted controller proxies the current keyrings and allows staging - // mutations that are only applied to the real keyrings if the operation - // completes successfully. This allows us to have a single source of truth - // for the keyrings during the operation execution, and to automatically - // roll back any changes if an error is thrown. - const restrictedController: RestrictedController = { - // We freeze the array to prevent direct mutations, but the keyring instances - // themselves are not frozen, allowing safe read-only access. - get keyrings() { - return Object.freeze([...restrictedEntries]); - }, - - // Method to create a new keyring and adds it to the restricted entries. - addNewKeyring: async (type: string, opts?: unknown) => { - const entry = await this.#createKeyring(type, opts); - - restrictedEntries.push(entry); - createdEntries.add(entry); - - return entry; - }, - - // Method to remove a keyring from the restricted entries. - removeKeyring: async (id: string) => { - const index = restrictedEntries.findIndex( - (entry) => entry.metadata.id === id, - ); - if (index === -1) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - this.#assertNotRemovingPrimaryKeyring( - restrictedEntries[index], - restrictedEntries, - ); - - const [removed] = restrictedEntries.splice(index, 1) as [ - KeyringEntry, - ]; - removedEntries.add(removed); - }, - }; - - const destroyKeyrings = async ( - entries: Iterable, - ): Promise => { - await Promise.all( - [...entries].map(({ keyring, keyringV2 }) => - this.#destroyKeyring(keyring, keyringV2), - ), - ); - }; - - let result: CallbackResult; - try { - result = await operation(restrictedController); - } catch (error) { - await destroyKeyrings(createdEntries); - - throw error; - } - - await destroyKeyrings(removedEntries); - - // We update the real keyrings only after the operation completes successfully, so that - // they will be persisted in the vault. - this.#keyrings = restrictedEntries; - - // As usual, we want to prevent returning direct references to keyring instances, so we check - // the result for any unsafe direct access before returning. - for (const { keyring, keyringV2 } of [ - ...this.#keyrings, - // We also check for keyrings that got removed during the operation, since the result could - // still have references to them. - ...removedEntries, - ]) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyring); - if (keyringV2) { - this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); - } - } - - return result; - }); - } - - /** - * Gets the type of the keyring that manages the specified account. - * - * @param account - The account address to look up. - * @returns A promise that resolves to the type of the keyring managing the account. - */ - async getAccountKeyringType(account: string): Promise { - this.#assertIsUnlocked(); - - const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; - return keyring.type; - } - - /** - * Constructor helper for registering this controller's messeger - * actions. - */ - #registerMessageHandlers(): void { - this.messenger.registerMethodActionHandlers( - this, - MESSENGER_EXPOSED_METHODS, - ); - } - - /** - * Select a keyring entry using a selector without acquiring the controller lock. - * - * @param options - Selection options. - * @param options.v2 - Tag to indicate whether the selector is for a V2 keyring. - * @param options.selector - Keyring selector object. - * @returns The selected keyring entry, or `undefined` if no match is found. - * @template SelectedKeyring - The expected type of the selected keyring. - * @template SelectedKeyringV2 - The expected type of the selected keyring (v2). - */ - async #selectKeyringEntry< - SelectedKeyring extends EthKeyring, - SelectedKeyringV2 extends KeyringV2, - >({ - v2, - selector, - }: // Use distinct union tags to ensure proper type narrowing of the selector object. - | { - v2: false; - selector: KeyringSelector; - } - | { - v2: true; - selector: KeyringSelectorV2; - }): Promise { - let entry: KeyringEntry | undefined; - - if ('address' in selector) { - entry = await this.#getKeyringEntryForAccount(selector.address); - } else if ('type' in selector) { - const entries = v2 - ? this.#getKeyringEntriesByType({ v2: true, type: selector.type }) - : this.#getKeyringEntriesByType({ v2: false, type: selector.type }); - entry = entries[selector.index ?? 0]; - } else if ('id' in selector) { - entry = this.#getKeyringEntryById(selector.id); - } else if ('filter' in selector) { - entry = this.#keyrings.find(({ keyring, keyringV2, metadata }) => { - // If v2, then we'll use the v2 selector which expects a `KeyringV2` instance. - if (v2) { - // However, some keyrings do not have a v2 wrapper, so we just skip them. - if (!keyringV2) { - return false; - } - - return selector.filter(keyringV2, metadata); - } - - return selector.filter(keyring, metadata); - }); - } - - return entry; - } - - /** - * Get the keyring by id. - * - * @param keyringId - The id of the keyring. - * @returns The keyring. - */ - #getKeyringById(keyringId: string): EthKeyring | undefined { - return this.#getKeyringEntryById(keyringId)?.keyring; - } - - #getKeyringEntryById(keyringId: string): KeyringEntry | undefined { - return this.#keyrings.find(({ metadata }) => metadata.id === keyringId); - } - - /** - * Get the keyring by id or return the first keyring if the id is not found. - * - * @param keyringId - The id of the keyring. - * @returns The keyring. - */ - #getKeyringByIdOrDefault(keyringId?: string): EthKeyring | undefined { - if (!keyringId) { - return this.#keyrings[0]?.keyring; - } - - return this.#getKeyringById(keyringId); - } - - /** - * Get the metadata for the specified keyring. - * - * @param keyring - The keyring instance to get the metadata for. - * @returns The keyring metadata. - */ - #getKeyringMetadata(keyring: unknown): KeyringMetadata { - const keyringWithMetadata = this.#keyrings.find( - (candidate) => candidate.keyring === keyring, - ); - if (!keyringWithMetadata) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - return keyringWithMetadata.metadata; - } - - /** - * Get the keyring builder for the given `type`. - * - * @param type - The type of keyring to get the builder for. - * @returns The keyring builder, or undefined if none exists. - */ - #getKeyringBuilderForType( - type: string, - ): { (): EthKeyring; type: string } | undefined { - return this.#keyringBuilders.find( - (keyringBuilder) => keyringBuilder.type === type, - ); - } - - /** - * Get the V2 keyring builder for the given `type`. - * - * @param type - The type of keyring to get the builder for. - * @returns The V2 keyring builder, or undefined if none exists. - */ - #getKeyringV2BuilderForType(type: string): KeyringV2Builder | undefined { - return this.#keyringV2Builders.find((builder) => builder.type === type); - } - - /** - * Create new vault with an initial keyring - * - * Destroys any old encrypted storage, - * creates a new encrypted store with the given password, - * creates a new wallet with 1 account. - * - * @fires KeyringController:unlock - * @param password - The password to encrypt the vault with. - * @param keyring - A object containing the params to instantiate a new keyring. - * @param keyring.type - The keyring type. - * @param keyring.opts - Optional parameters required to instantiate the keyring. - * @returns A promise that resolves to the state. - */ - async #createNewVaultWithKeyring( - password: string, - keyring: { - type: string; - opts?: unknown; - }, - ): Promise { - this.#assertControllerMutexIsLocked(); - - if (typeof password !== 'string') { - throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); - } - - this.update((state) => { - delete state.encryptionKey; - delete state.encryptionSalt; - }); - - await this.#deriveAndSetEncryptionKey(password, { - ignoreExistingVault: true, - }); - - await this.#clearKeyrings(); - await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts); - this.#setUnlocked(); - } - - /** - * Derive the vault encryption key from the provided password, and - * assign it to the instance variable for later use with cryptographic - * functions. - * - * When the controller has a vault in its state, the key is derived - * using the salt from the vault. If the vault is empty, a new salt - * is generated and used to derive the key. - * - * If `options.ignoreExistingVault` is set to `true`, the existing - * vault is completely ignored: the new key won't be able to decrypt - * the existing vault, and should be used to re-encrypt it. - * - * @param password - The password to use for decryption or derivation. - * @param options - Options for the key derivation. - * @param options.ignoreExistingVault - Whether to ignore the existing vault salt and key metadata - */ - async #deriveAndSetEncryptionKey( - password: string, - options: { ignoreExistingVault: boolean } = { - ignoreExistingVault: false, - }, - ): Promise { - this.#assertControllerMutexIsLocked(); - const { vault } = this.state; - - if (typeof password !== 'string') { - throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); - } - - let serializedEncryptionKey: string, salt: string; - if (vault && !options.ignoreExistingVault) { - // The `decryptWithDetail` method is being used here instead of - // `keyFromPassword` + `exportKey` to let the encryptor handle - // any legacy encryption formats and metadata that might be - // present (or absent) in the vault. - const { exportedKeyString, salt: existingSalt } = - await this.#encryptor.decryptWithDetail(password, vault); - serializedEncryptionKey = exportedKeyString; - salt = existingSalt; - } else { - salt = this.#encryptor.generateSalt(); - serializedEncryptionKey = await this.#encryptor.exportKey( - await this.#encryptor.keyFromPassword(password, salt, true), - ); - } - - this.#encryptionKey = { - salt, - serialized: serializedEncryptionKey, - }; - } - - /** - * Set the the `#encryptionKey` instance variable. - * This method is used when the user provides an encryption key and salt - * to unlock the keychain, instead of using a password. - * - * @param encryptionKey - The encryption key to use. - * @param keyDerivationSalt - The salt to use for the encryption key. - */ - #setEncryptionKey(encryptionKey: string, keyDerivationSalt: string): void { - this.#assertControllerMutexIsLocked(); - - if ( - typeof encryptionKey !== 'string' || - typeof keyDerivationSalt !== 'string' - ) { - throw new TypeError(KeyringControllerErrorMessage.WrongEncryptionKeyType); - } - - const { vault } = this.state; - if (vault && JSON.parse(vault).salt !== keyDerivationSalt) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.ExpiredCredentials, - ); - } - - this.#encryptionKey = { - salt: keyDerivationSalt, - serialized: encryptionKey, - }; - } - - /** - * Internal non-exclusive method to verify the seed phrase. - * - * @param keyringId - The id of the keyring to verify the seed phrase for. - * @returns A promise resolving to the seed phrase as Uint8Array. - */ - async #verifySeedPhrase(keyringId?: string): Promise { - this.#assertControllerMutexIsLocked(); - - const keyring = this.#getKeyringByIdOrDefault(keyringId); - - if (!keyring) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.KeyringNotFound, - ); - } - - if (keyring.type !== (KeyringTypes.hd as string)) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedVerifySeedPhrase, - ); - } - - assertHasUint8ArrayMnemonic(keyring); - - const seedWords = keyring.mnemonic; - const accounts = await keyring.getAccounts(); - /* istanbul ignore if */ - if (accounts.length === 0) { - throw new KeyringControllerError('Cannot verify an empty keyring.'); - } - - // The HD Keyring Builder is a default keyring builder - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const hdKeyringBuilder = this.#getKeyringBuilderForType(KeyringTypes.hd)!; - - const hdKeyring = hdKeyringBuilder(); - // @ts-expect-error @metamask/eth-hd-keyring correctly handles - // Uint8Array seed phrases in the `deserialize` method. - await hdKeyring.deserialize({ - mnemonic: seedWords, - numberOfAccounts: accounts.length, - }); - const testAccounts = await hdKeyring.getAccounts(); - /* istanbul ignore if */ - if (testAccounts.length !== accounts.length) { - throw new KeyringControllerError( - 'Seed phrase imported incorrect number of accounts.', - ); - } - - testAccounts.forEach((account: string, i: number) => { - /* istanbul ignore if */ - if (account.toLowerCase() !== accounts[i].toLowerCase()) { - throw new KeyringControllerError( - 'Seed phrase imported different accounts.', - ); - } - }); - - return seedWords; - } - - /** - * Get the updated array of each keyring's type and - * accounts list. - * - * @returns A promise resolving to the updated keyrings array. - */ - async #getUpdatedKeyrings(): Promise { - return Promise.all(this.#keyrings.map(displayForKeyring)); - } - - /** - * Serialize the current array of keyring instances, - * including unsupported keyrings by default. - * - * @param options - Method options. - * @param options.includeUnsupported - Whether to include unsupported keyrings. - * @returns The serialized keyrings. - */ - async #getSerializedKeyrings( - { includeUnsupported }: { includeUnsupported: boolean } = { - includeUnsupported: true, - }, - ): Promise { - const serializedKeyrings: SerializedKeyring[] = await Promise.all( - this.#keyrings.map(async ({ keyring, metadata }) => { - return { - type: keyring.type, - data: await keyring.serialize(), - metadata, - }; - }), - ); - - if (includeUnsupported) { - serializedKeyrings.push(...this.#unsupportedKeyrings); - } - - return serializedKeyrings; - } - - /** - * Get a snapshot of session data held by instance variables. - * - * @returns An object with serialized keyrings, keyrings metadata, - * and the user password. - */ - async #getSessionState(): Promise { - return { - keyrings: await this.#getSerializedKeyrings(), - encryptionKey: this.#encryptionKey, - }; - } - - /** - * Restore a serialized keyrings array. - * - * @param serializedKeyrings - The serialized keyrings array. - * @returns The restored keyrings. - */ - async #restoreSerializedKeyrings( - serializedKeyrings: SerializedKeyring[], - ): Promise<{ - keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; - hasChanged: boolean; - }> { - await this.#clearKeyrings(); - const keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[] = []; - let hasChanged = false; - - for (const serializedKeyring of serializedKeyrings) { - const result = await this.#restoreKeyring(serializedKeyring); - if (result) { - const { keyring, metadata } = result; - keyrings.push({ keyring, metadata }); - if (result.hasChanged) { - hasChanged = true; - } - } - } - - return { keyrings, hasChanged }; - } - - /** - * Unlock Keyrings, decrypting the vault and deserializing all - * keyrings contained in it, using a password or an encryption key with salt. - * - * @param credentials - The credentials to unlock the keyrings. - * @returns A promise resolving to the deserialized keyrings array. - */ - async #unlockKeyrings(credentials: Credentials): Promise<{ - keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; - hasChanged: boolean; - }> { - return this.#withVaultLock(async () => { - if (!this.state.vault) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.VaultError, - ); - } - const parsedEncryptedVault = JSON.parse(this.state.vault); - - if ('password' in credentials) { - await this.#deriveAndSetEncryptionKey(credentials.password); - } else { - this.#setEncryptionKey( - credentials.encryptionKey, - credentials.encryptionSalt ?? parsedEncryptedVault.salt, - ); - } - - const encryptionKey = this.#encryptionKey?.serialized; - if (!encryptionKey) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.MissingCredentials, - ); - } - - const key = await this.#encryptor.importKey(encryptionKey); - const vault = await this.#encryptor.decryptWithKey( - key, - parsedEncryptedVault, - ); - - if (!isSerializedKeyringsArray(vault)) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.VaultDataError, - ); - } - - const { keyrings, hasChanged } = - await this.#restoreSerializedKeyrings(vault); - - const updatedKeyrings = await this.#getUpdatedKeyrings(); - - this.update((state) => { - state.keyrings = updatedKeyrings; - state.encryptionKey = encryptionKey; - state.encryptionSalt = this.#encryptionKey?.salt; - }); - - return { keyrings, hasChanged }; - }); - } - - /** - * Update the vault with the current keyrings. - * - * @returns A promise resolving to `true` if the operation is successful. - */ - #updateVault(): Promise { - return this.#withVaultLock(async () => { - // Ensure no duplicate accounts are persisted. - await this.#assertNoDuplicateAccounts(); - - if (!this.#encryptionKey) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.MissingCredentials, - ); - } - - const serializedKeyrings = await this.#getSerializedKeyrings(); - - if ( - !serializedKeyrings.some( - (keyring) => keyring.type === (KeyringTypes.hd as string), - ) - ) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.NoHdKeyring, - ); - } - - const key = await this.#encryptor.importKey( - this.#encryptionKey.serialized, - ); - const encryptedVault = await this.#encryptor.encryptWithKey( - key, - serializedKeyrings, - ); - // We need to include the salt used to derive - // the encryption key, to be able to derive it - // from password again. - encryptedVault.salt = this.#encryptionKey.salt; - const updatedState: Partial = { - vault: JSON.stringify(encryptedVault), - encryptionKey: this.#encryptionKey.serialized, - encryptionSalt: this.#encryptionKey.salt, - }; - - const updatedKeyrings = await this.#getUpdatedKeyrings(); - - this.update((state) => { - state.vault = updatedState.vault; - state.keyrings = updatedKeyrings; - state.encryptionKey = updatedState.encryptionKey; - state.encryptionSalt = updatedState.encryptionSalt; - }); - - return true; - }); - } - - /** - * Check if there are new encryption parameters available. - * - * @returns A promise resolving to `void`. - */ - #isNewEncryptionAvailable(): boolean { - const { vault } = this.state; - - if (!vault || !this.#encryptor.isVaultUpdated) { - return false; - } - - return !this.#encryptor.isVaultUpdated(vault); - } - - /** - * Retrieves all the accounts from keyrings instances - * that are currently in memory. - * - * @param additionalKeyrings - Additional keyrings to include in the search. - * @returns A promise resolving to an array of accounts. - */ - async #getAccountsFromKeyrings( - additionalKeyrings: EthKeyring[] = [], - ): Promise { - const keyrings = this.#keyrings.map(({ keyring }) => keyring); - - const keyringArrays = await Promise.all( - [...keyrings, ...additionalKeyrings].map(async (keyring) => - keyring.getAccounts(), - ), - ); - const addresses = keyringArrays.reduce((res, arr) => { - return res.concat(arr); - }, []); - - // Cast to `string[]` here is safe here because `addresses` has no nullish - // values, and `normalize` returns `string` unless given a nullish value - return addresses.map(normalize) as string[]; - } - - /** - * Create a new keyring, ensuring that the first account is - * also created. - * - * @param type - Keyring type to instantiate. - * @param opts - Optional parameters required to instantiate the keyring. - * @returns A promise that resolves if the operation is successful. - */ - async #createKeyringWithFirstAccount( - type: string, - opts?: unknown, - ): Promise { - this.#assertControllerMutexIsLocked(); - - const keyring = await this.#newKeyring(type, opts); - - const [firstAccount] = await keyring.getAccounts(); - if (!firstAccount) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.NoFirstAccount, - ); - } - return firstAccount; - } - - /** - * Instantiate, initialize and return a new keyring of the given `type`, - * using the given `opts`. The keyring is built using the keyring builder - * registered for the given `type`. - * - * The internal keyring and keyring metadata arrays are updated with the new - * keyring as well. - * - * @param type - The type of keyring to add. - * @param data - Keyring initialization options. - * @returns The new keyring. - * @throws If the keyring includes duplicated accounts. - */ - async #newKeyring(type: string, data?: unknown): Promise { - const { keyring, keyringV2, metadata } = await this.#createKeyring( - type, - data, - ); - - this.#keyrings.push({ keyring, keyringV2, metadata }); - - return keyring; - } - - /** - * Instantiate, initialize and return a keyring of the given `type` using the - * given `opts`. The keyring is built using the keyring builder registered - * for the given `type`. - * - * The keyring might be new, or it might be restored from the vault. This - * function should only be called from `#newKeyring` or `#restoreKeyring`, - * for the "new" and "restore" cases respectively. - * - * The internal keyring and keyring metadata arrays are *not* updated, the - * caller is expected to update them. - * - * @param type - The type of keyring to add. - * @param data - Keyring initialization options. - * @param metadata - Keyring metadata if available. - * @returns The new keyring. - * @throws If the keyring includes duplicated accounts. - */ - async #createKeyring( - type: string, - data?: unknown, - metadata?: KeyringMetadata, - ): Promise { - this.#assertControllerMutexIsLocked(); - - const keyringMetadata = metadata ?? getDefaultKeyringMetadata(); - - const keyringBuilder = this.#getKeyringBuilderForType(type); - if (!keyringBuilder) { - throw new KeyringControllerError( - `${KeyringControllerErrorMessage.NoKeyringBuilder}. Keyring type: ${type}`, - ); - } - - const keyring = keyringBuilder(); - if (data) { - // @ts-expect-error Enforce data type after updating clients - await keyring.deserialize(data); - } - - if (keyring.init) { - await keyring.init(); - } - - if ( - type === (KeyringTypes.hd as string) && - (!isObject(data) || !data.mnemonic) - ) { - if (!keyring.generateRandomMnemonic) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.UnsupportedGenerateRandomMnemonic, - ); - } - - // NOTE: Not all keyrings implement this method in a asynchronous-way. Using `await` for - // non-thenable will still be valid (despite not being really useful). It allows us to cover both - // cases and allow retro-compatibility too. - await keyring.generateRandomMnemonic(); - await keyring.addAccounts(1); - } - - // We now create the keyring V2 wrappers and store them in memory. - const keyringBuilderV2 = this.#getKeyringV2BuilderForType(type); - let keyringV2: KeyringV2 | undefined; - if (keyringBuilderV2) { - keyringV2 = keyringBuilderV2(keyring, keyringMetadata); - } - - return { keyring, keyringV2, metadata: keyringMetadata }; - } - - /** - * Run the given operation and afterwards clean up any keyring whose - * account list transitioned from non-empty to empty during the operation. - * - * This mirrors the cleanup behavior of {@link KeyringController.removeAccount} - * for code paths where the consumer mutates a keyring directly via - * {@link KeyringController.withKeyring} or - * {@link KeyringController.withKeyringV2}: if the consumer drains the last - * account from a keyring, the now-empty keyring is removed from - * {@link KeyringController.#keyrings} and destroyed before persistence runs. - * - * Pre-existing empty keyrings (e.g. those created intentionally via - * {@link KeyringController.addNewKeyring} without subsequent account - * creation) are left alone, as are keyrings created within the operation - * itself (they are not part of the pre-operation snapshot). The primary - * keyring (see {@link KeyringController.#isPrimaryKeyring}) is also preserved - * unconditionally to keep `removeAccount`'s primary-keyring invariant intact. - * - * @param operation - The operation to execute. - * @returns The result of the operation. - * @template Result - The type of the value resolved by the operation. - */ - async #cleanUpEmptiedKeyringsAfter( - operation: () => Promise, - ): Promise { - // Only the primary keyring exists, which is never auto-removed, so there - // is nothing to clean up regardless of what the operation does. - if (this.#keyrings.length <= 1) { - return operation(); - } - - const wasNonEmpty = new WeakSet(); - await Promise.all( - this.#keyrings.map(async ({ keyring }) => { - if ((await keyring.getAccounts()).length > 0) { - wasNonEmpty.add(keyring); - } - }), - ); - - const result = await operation(); - - const isNowEmpty = await Promise.all( - this.#keyrings.map( - async ({ keyring }) => (await keyring.getAccounts()).length === 0, - ), - ); - - const emptied = this.#keyrings.filter( - (entry, index) => - !this.#isPrimaryKeyring(entry, this.#keyrings) && - wasNonEmpty.has(entry.keyring) && - isNowEmpty[index], - ); - - if (emptied.length > 0) { - const removed = new Set(emptied); - this.#keyrings = this.#keyrings.filter((entry) => !removed.has(entry)); - await Promise.all( - emptied.map(({ keyring, keyringV2 }) => - this.#destroyKeyring(keyring, keyringV2), - ), - ); - } - - return result; - } - - /** - * Remove all managed keyrings, destroying all their - * instances in memory. - */ - async #clearKeyrings(): Promise { - this.#assertControllerMutexIsLocked(); - for (const { keyring, keyringV2 } of this.#keyrings) { - await this.#destroyKeyring(keyring, keyringV2); - } - this.#keyrings = []; - this.#unsupportedKeyrings = []; - } - - /** - * Restore a Keyring from a provided serialized payload. - * On success, returns the resulting keyring instance. - * - * @param serialized - The serialized keyring. - * @returns The deserialized keyring or undefined if the keyring type is unsupported. - */ - async #restoreKeyring(serialized: SerializedKeyring): Promise< - | (KeyringEntry & { - hasChanged: boolean; - }) - | undefined - > { - this.#assertControllerMutexIsLocked(); - - try { - const { type, data, metadata: serializedMetadata } = serialized; - - // Track if we need to trigger a vault update. - let hasChanged = false; - - // If metadata is missing, assume the data is from an installation before we had - // keyring metadata. - let metadata = serializedMetadata; - if (!metadata) { - hasChanged = true; - metadata = getDefaultKeyringMetadata(); - } - - const oldState = JSON.stringify(data); - const { keyring, keyringV2 } = await this.#createKeyring( - type, - data, - metadata, - ); - const newState = JSON.stringify(await keyring.serialize()); - hasChanged ||= oldState !== newState; - - await this.#assertNoDuplicateAccounts([keyring]); - - // The keyring is added to the keyrings array only if it's successfully restored - // and the metadata is successfully added to the controller - this.#keyrings.push({ - keyring, - keyringV2, - metadata, - }); - - return { keyring, keyringV2, metadata, hasChanged }; - } catch (error) { - console.error(error); - this.#unsupportedKeyrings.push(serialized); - return undefined; - } - } - - /** - * Destroy Keyring - * - * Some keyrings support a method called `destroy`, that destroys the - * keyring along with removing all its event listeners and, in some cases, - * clears the keyring bridge iframe from the DOM. - * - * @param keyring - The keyring to destroy. - * @param keyringV2 - The keyring v2 to destroy (if any). - */ - async #destroyKeyring( - keyring: EthKeyring, - keyringV2?: KeyringV2, - ): Promise { - await keyring.destroy?.(); - if (keyringV2) { - await keyringV2.destroy?.(); - } - } - - /** - * Assert that there are no duplicate accounts in the keyrings. - * - * @param additionalKeyrings - Additional keyrings to include in the check. - * @throws If there are duplicate accounts. - */ - async #assertNoDuplicateAccounts( - additionalKeyrings: EthKeyring[] = [], - ): Promise { - const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings); - - if (new Set(accounts).size !== accounts.length) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.DuplicatedAccount, - ); - } - } - - /** - * Set the `isUnlocked` to true and notify listeners - * through the messenger. - * - * @fires KeyringController:unlock - */ - #setUnlocked(): void { - this.#assertControllerMutexIsLocked(); - - this.update((state) => { - state.isUnlocked = true; - }); - this.messenger.publish(`${name}:unlock`); - } - - /** - * Assert that the controller is unlocked. - * - * @throws If the controller is locked. - */ - #assertIsUnlocked(): void { - if (!this.state.isUnlocked) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.ControllerLocked, - ); - } - } - - /** - * Execute the given function after acquiring the controller lock - * and save the vault to state after it (only if needed), or rollback to their - * previous state in case of error. - * - * @param callback - The function to execute. - * @returns The result of the function. - */ - async #persistOrRollback( - callback: MutuallyExclusiveCallback, - ): Promise { - return this.#withRollback(async ({ releaseLock }) => { - const oldState = JSON.stringify(await this.#getSessionState()); - const callbackResult = await callback({ releaseLock }); - const newState = JSON.stringify(await this.#getSessionState()); - - // State is committed only if the operation is successful and need to trigger a vault update. - if (oldState !== newState) { - await this.#updateVault(); - } - - return callbackResult; - }); - } - - /** - * Execute the given function after acquiring the controller lock - * and rollback keyrings and password states in case of error. - * - * @param callback - The function to execute atomically. - * @returns The result of the function. - */ - async #withRollback( - callback: MutuallyExclusiveCallback, - ): Promise { - return this.#withControllerLock(async ({ releaseLock }) => { - const currentSerializedKeyrings = await this.#getSerializedKeyrings(); - const currentEncryptionKey = cloneDeep(this.#encryptionKey); - - try { - return await callback({ releaseLock }); - } catch (error) { - // Keyrings and encryption credentials are restored to their previous state - this.#encryptionKey = currentEncryptionKey; - await this.#restoreSerializedKeyrings(currentSerializedKeyrings); - - throw error; - } - }); - } - - /** - * Assert that the controller mutex is locked. - * - * @throws If the controller mutex is not locked. - */ - #assertControllerMutexIsLocked(): void { - if (!this.#controllerOperationMutex.isLocked()) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.ControllerLockRequired, - ); - } - } - - /** - * Check whether the given keyring entry is the primary keyring. - * - * The primary keyring is the first HD keyring in the given list. Both the - * position (index 0) and the keyring type are checked so that the definition - * of "primary" lives in one place and does not rely on positional index - * alone, which could misidentify the primary keyring in the event of a bug. - * - * @param entry - The keyring entry to check. - * @param keyrings - The list of keyring entries `entry` belongs to. - * @returns Whether the entry is the primary keyring. - */ - #isPrimaryKeyring(entry: KeyringEntry, keyrings: KeyringEntry[]): boolean { - return ( - keyrings[0] === entry && - entry.keyring.type === (KeyringTypes.hd as string) - ); - } - - /** - * Assert that the given keyring entry is not the primary HD keyring. - * - * @param entry - The keyring entry to check. - * @param keyrings - The current list of keyring entries. - * @throws If the entry is the primary keyring. - */ - #assertNotRemovingPrimaryKeyring( - entry: KeyringEntry, - keyrings: KeyringEntry[], - ): void { - if (this.#isPrimaryKeyring(entry, keyrings)) { - throw new KeyringControllerError( - KeyringControllerErrorMessage.CannotRemovePrimaryKeyring, - ); - } - } - - /** - * Lock the controller mutex before executing the given function, - * and release it after the function is resolved or after an - * error is thrown. - * - * This wrapper ensures that each mutable operation that interacts with the - * controller and that changes its state is executed in a mutually exclusive way, - * preventing unsafe concurrent access that could lead to unpredictable behavior. - * - * @param callback - The function to execute while the controller mutex is locked. - * @returns The result of the function. - */ - async #withControllerLock( - callback: MutuallyExclusiveCallback, - ): Promise { - return withLock(this.#controllerOperationMutex, callback); - } - - /** - * Lock the vault mutex before executing the given function, - * and release it after the function is resolved or after an - * error is thrown. - * - * This ensures that each operation that interacts with the vault - * is executed in a mutually exclusive way. - * - * @param callback - The function to execute while the vault mutex is locked. - * @returns The result of the function. - */ - async #withVaultLock( - callback: MutuallyExclusiveCallback, - ): Promise { - this.#assertControllerMutexIsLocked(); - - return withLock(this.#vaultOperationMutex, callback); - } -} - -/** - * Lock the given mutex before executing the given function, - * and release it after the function is resolved or after an - * error is thrown. - * - * @param mutex - The mutex to lock. - * @param callback - The function to execute while the mutex is locked. - * @returns The result of the function. - */ -async function withLock( - mutex: Mutex, - callback: MutuallyExclusiveCallback, -): Promise { - const releaseLock = await mutex.acquire(); - - try { - return await callback({ releaseLock }); - } finally { - releaseLock(); - } -} - -/** - * Generate a new keyring metadata object. - * - * @returns Keyring metadata. - */ -function getDefaultKeyringMetadata(): KeyringMetadata { - return { id: ulid(), name: '' }; -} - -export default KeyringController; +import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; +import { isValidPrivate, getBinarySize } from '@ethereumjs/util'; +import { BaseController } from '@metamask/base-controller'; +import type * as encryptorUtils from '@metamask/browser-passworder'; +import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { HdKeyring as HdKeyringV2 } from '@metamask/eth-hd-keyring/v2'; +import { normalize as ethNormalize } from '@metamask/eth-sig-util'; +import SimpleKeyring from '@metamask/eth-simple-keyring'; +import { SimpleKeyring as SimpleKeyringV2 } from '@metamask/eth-simple-keyring/v2'; +import type { + KeyringExecutionContext, + EthBaseTransaction, + EthBaseUserOperation, + EthUserOperation, + EthUserOperationPatch, + KeyringAccount, +} from '@metamask/keyring-api'; +import type { + Keyring as KeyringV2, + KeyringType, +} from '@metamask/keyring-api/v2'; +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { Keyring, KeyringClass } from '@metamask/keyring-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; +import { + add0x, + assertIsStrictHexString, + bytesToHex, + hasProperty, + hexToBytes, + isObject, + isStrictHexString, + isValidHexAddress, + isValidJson, + remove0x, +} from '@metamask/utils'; +import { Mutex } from 'async-mutex'; +import type { MutexInterface } from 'async-mutex'; +import * as ethereumjsWallet from 'ethereumjs-wallet'; +import type { Patch } from 'immer'; +import { cloneDeep } from 'lodash-es'; +// When generating a ULID within the same millisecond, monotonicFactory provides some guarantees regarding sort order. +import { ulid } from 'ulid'; + +import { KeyringControllerErrorMessage } from './constants.js'; +import { KeyringControllerError } from './errors.js'; +import type { KeyringControllerMethodActions } from './KeyringController-method-action-types.js'; +import type { + Eip7702AuthorizationParams, + Credentials, + PersonalMessageParams, + TypedMessageParams, +} from './types.js'; + +/** + * `ethereumjs-wallet` is CommonJS, and Node cannot reliably detect its named + * exports, so importing `thirdparty` directly fails at run time. It also + * declares a TypeScript-style default export, which means `module.exports` is + * reached through `default` under Node's ESM interop but is the namespace + * itself once `esModuleInterop` has unwrapped it. Both shapes are resolved + * here so the imports work whichever applies. + */ +/* istanbul ignore next: only one branch is reachable per module system */ +const walletModule = (typeof ethereumjsWallet.default === 'object' && +ethereumjsWallet.default + ? ethereumjsWallet.default + : ethereumjsWallet) as unknown as { + default: typeof ethereumjsWallet.default; + thirdparty: typeof ethereumjsWallet.thirdparty; +}; + +const Wallet = walletModule.default; +const importers = walletModule.thirdparty; + +const name = 'KeyringController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'signMessage', + 'signEip7702Authorization', + 'signPersonalMessage', + 'signTransaction', + 'signTypedMessage', + 'decryptMessage', + 'getEncryptionPublicKey', + 'getAccounts', + 'getKeyringsByType', + 'getKeyringForAccount', + 'persistAllKeyrings', + 'prepareUserOperation', + 'patchUserOperation', + 'signUserOperation', + 'addNewAccount', + 'withController', + 'withKeyring', + 'withKeyringUnsafe', + 'withKeyringV2', + 'withKeyringV2Unsafe', + 'addNewKeyring', + 'createNewVaultAndKeychain', + 'createNewVaultAndRestore', + 'removeAccount', + 'isUnlocked', + 'exportSeedPhrase', + 'changePassword', + 'exportAccount', + 'exportEncryptionKey', + 'getAccountKeyringType', + 'importAccountWithStrategy', + 'setLocked', + 'submitEncryptionKey', + 'submitPassword', + 'verifyPassword', +] as const; + +/** + * Available keyring types + * + * @deprecated Use `KeyringType` from `@metamask/keyring-api/v2` instead. This enum will be removed + * in a future release once V2 is fully adopted. Only use it if the keyring you are trying to access + * has no V2 builder available yet. + */ +export enum KeyringTypes { + // Changing this would be a breaking change, and not worth the effort at this + // time, so we disable the linting rule for this block. + /* eslint-disable @typescript-eslint/naming-convention */ + simple = 'Simple Key Pair', + hd = 'HD Key Tree', + qr = 'QR Hardware Wallet Device', + trezor = 'Trezor Hardware', + oneKey = 'OneKey Hardware', + ledger = 'Ledger Hardware', + lattice = 'Lattice Hardware', + snap = 'Snap Keyring', + money = 'Money Keyring', + /* eslint-enable @typescript-eslint/naming-convention */ +} + +/** + * Custody keyring types are a special case, as they are not a single type + * but they all start with the prefix "Custody". + * + * @param keyringType - The type of the keyring. + * @returns Whether the keyring type is a custody keyring. + */ +export const isCustodyKeyring = (keyringType: string): boolean => { + return keyringType.startsWith('Custody'); +}; + +/** + * The KeyringController state + */ +export type KeyringControllerState = { + /** + * Encrypted array of serialized keyrings data. + */ + vault?: string; + /** + * Whether the vault has been decrypted successfully and + * keyrings contained within are deserialized and available. + */ + isUnlocked: boolean; + /** + * Representations of managed keyrings. + */ + keyrings: KeyringObject[]; + /** + * The encryption key derived from the password and used to encrypt + * the vault. This is only stored if the `cacheEncryptionKey` option + * is enabled. + */ + encryptionKey?: string; + /** + * The salt used to derive the encryption key from the password. + */ + encryptionSalt?: string; +}; + +export type KeyringControllerMemState = Omit< + KeyringControllerState, + 'vault' | 'encryptionKey' | 'encryptionSalt' +>; + +export type KeyringControllerGetStateAction = { + type: `${typeof name}:getState`; + handler: () => KeyringControllerState; +}; + +export type KeyringControllerStateChangeEvent = { + type: `${typeof name}:stateChange`; + payload: [KeyringControllerState, Patch[]]; +}; + +export type KeyringControllerAccountRemovedEvent = { + type: `${typeof name}:accountRemoved`; + payload: [string]; +}; + +export type KeyringControllerLockEvent = { + type: `${typeof name}:lock`; + payload: []; +}; + +export type KeyringControllerUnlockEvent = { + type: `${typeof name}:unlock`; + payload: []; +}; + +export type KeyringControllerActions = + | KeyringControllerGetStateAction + | KeyringControllerMethodActions; + +export type KeyringControllerEvents = + | KeyringControllerStateChangeEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | KeyringControllerAccountRemovedEvent; + +export type KeyringControllerMessenger = Messenger< + typeof name, + KeyringControllerActions, + KeyringControllerEvents +>; + +export type KeyringControllerOptions< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + keyringBuilders?: { (): EthKeyring; type: string }[]; + keyringV2Builders?: KeyringV2Builder[]; + messenger: KeyringControllerMessenger; + state?: { vault?: string; keyringsMetadata?: KeyringMetadata[] }; + encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >; +}; + +/** + * A keyring object representation. + */ +export type KeyringObject = { + /** + * Accounts associated with the keyring. + */ + accounts: string[]; + /** + * Keyring type. + */ + type: string; + /** + * Additional data associated with the keyring. + */ + metadata: KeyringMetadata; +}; + +/** + * Additional information related to a keyring. + */ +export type KeyringMetadata = { + /** + * Keyring ID + */ + id: string; + /** + * Keyring name + */ + name: string; +}; + +/** + * A keyring entry, including the keyring instance (+ v2 instance) and its metadata. + */ +export type KeyringEntry = { + /** + * The keyring instance. + */ + keyring: EthKeyring; + + /** + * The keyring V2 instance, if available. + */ + keyringV2?: KeyringV2; + + /** + * The keyring metadata. + */ + metadata: KeyringMetadata; +}; + +/** + * A restricted view of the {@link KeyringController} exposed to the callback + * passed to {@link KeyringController.withController}. + * + * It provides a read-only live view of all keyrings and the ability to stage + * keyring additions and removals atomically within a single transaction. + */ +export type RestrictedController = { + /** + * Read-only live view of all keyrings in the current transaction (original + * keyrings plus any added, minus any removed so far in this callback). + */ + readonly keyrings: readonly KeyringEntry[]; + /** + * Create a new keyring of the given type and stage it for commit. The new + * entry is immediately visible in {@link RestrictedController.keyrings}. + * + * @param type - The type of keyring to create. + * @param opts - Optional data to pass to the keyring builder. + * @returns The newly created `{ keyring, metadata }` entry. + */ + addNewKeyring(type: string, opts?: unknown): Promise; + /** + * Stage the keyring with the given id for removal. The keyring is + * immediately removed from {@link RestrictedController.keyrings}. + * + * @param id - The id of the keyring to remove. + */ + removeKeyring(id: string): Promise; +}; + +/** + * A strategy for importing an account + */ +export enum AccountImportStrategy { + // Changing this would be a breaking change, and not worth the effort at this + // time, so we disable the linting rule for this block. + /* eslint-disable @typescript-eslint/naming-convention */ + privateKey = 'privateKey', + json = 'json', + /* eslint-enable @typescript-eslint/naming-convention */ +} + +/** + * The `signTypedMessage` version + * + * @see https://docs.metamask.io/guide/signing-data.html + */ +export enum SignTypedDataVersion { + V1 = 'V1', + V3 = 'V3', + V4 = 'V4', +} + +/** + * A serialized keyring object. + */ +export type SerializedKeyring = { + type: string; + data: Json; + metadata?: KeyringMetadata; +}; + +/** + * Cached encryption key used to encrypt/decrypt the vault. + */ +type CachedEncryptionKey = { + /** + * The serialized encryption key. + */ + serialized: string; + /** + * The salt used to derive the encryption key. + */ + salt: string; +}; + +/** + * State/data that can be updated during a `withKeyring` operation. + */ +type SessionState = { + keyrings: SerializedKeyring[]; + encryptionKey?: CachedEncryptionKey; +}; + +export type EncryptionResultConstraint = { + salt?: string; + keyMetadata?: SupportedKeyMetadata; +}; + +export type DefaultEncryptionResult = { + data: string; + iv: string; + salt?: string; + keyMetadata?: SupportedKeyMetadata; +}; + +/** + * An encryptor interface that supports encrypting and decrypting + * serializable data with a password, and exporting and importing keys. + */ +export type Encryptor< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationParams = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + /** + * Encrypts the given object with the given password. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @returns The encrypted string. + */ + encrypt: (password: string, object: Json) => Promise; + /** + * Decrypts the given encrypted string with the given password. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decrypt: (password: string, encryptedString: string) => Promise; + /** + * Optional vault migration helper. Checks if the provided vault is up to date + * with the desired encryption algorithm. + * + * @param vault - The encrypted string to check. + * @param targetDerivationParams - The desired target derivation params. + * @returns The updated encrypted string. + */ + isVaultUpdated?: ( + vault: string, + targetDerivationParams?: encryptorUtils.KeyDerivationOptions, + ) => boolean; + /** + * Encrypts the given object with the given encryption key. + * + * @param key - The encryption key to encrypt with. + * @param object - The object to encrypt. + * @returns The encryption result. + */ + encryptWithKey: ( + key: EncryptionKey, + object: Json, + ) => Promise; + /** + * Encrypts the given object with the given password, and returns the + * encryption result and the serialized key string. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @param salt - The optional salt to use for encryption. + * @returns The encrypted string and the serialized key string. + */ + encryptWithDetail: ( + password: string, + object: Json, + salt?: string, + ) => Promise; + /** + * Decrypts the given encrypted string with the given encryption key. + * + * @param key - The encryption key to decrypt with. + * @param encryptedObject - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decryptWithKey: ( + key: EncryptionKey, + encryptedObject: EncryptionResult, + ) => Promise; + /** + * Decrypts the given encrypted string with the given password, and returns + * the decrypted object and the salt and serialized key string used for + * encryption. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object and the salt and serialized key string used for + * encryption. + */ + decryptWithDetail: ( + password: string, + encryptedString: string, + ) => Promise; + /** + * Generates an encryption key from a serialized key. + * + * @param key - The serialized key string. + * @returns The encryption key. + */ + importKey: (key: string) => Promise; + /** + * Exports the encryption key as a string. + * + * @param key - The encryption key to export. + * @returns The serialized key string. + */ + exportKey: (key: EncryptionKey) => Promise; + /** + * Derives an encryption key from a password. + * + * @param password - The password to derive the key from. + * @param salt - The salt to use for key derivation. + * @param exportable - Whether the key should be exportable or not. + * @param options - Optional key derivation options. + * @returns The derived encryption key. + */ + keyFromPassword: ( + password: string, + salt: string, + exportable?: boolean, + keyDerivationOptions?: SupportedKeyDerivationParams, + ) => Promise; + /** + * Generates a random salt for key derivation. + */ + generateSalt: typeof encryptorUtils.generateSalt; +}; + +/** + * Keyring selector used for `withKeyring`. + */ +export type KeyringSelector = + | { + type: string; + index?: number; + } + | { + address: Hex; + } + | { + id: string; + } + | { + /** + * A predicate function used to select a keyring. The first keyring for + * which this function returns `true` will be selected. + * + * NOTE: The caller must not mutate the keyring instance passed to this + * function. Mutations bypass the controller's state management + * safeguards and will lead to inconsistent state. The instance is not + * frozen for performance reasons, but treating it as read-only is a + * firm requirement — any mutation is a bug in the caller. + */ + filter: + | ((keyring: EthKeyring, metadata: KeyringMetadata) => boolean) + // Variant of the `filter` function that also acts as a type + // guard, allowing callers to narrow the keyring type within the + // callback. + | (( + keyring: EthKeyring, + metadata: KeyringMetadata, + ) => keyring is SelectedKeyring); + }; + +/** + * Keyring selector used for `withKeyringV2` (see {@link KeyringController#withKeyringV2} and {@link KeyringSelector}). + */ +export type KeyringSelectorV2 = + | { + type: `${KeyringType}`; + index?: number; + } + | { + address: KeyringAccount['address']; + } + | { + id: KeyringMetadata['id']; + } + | { + /** Similar to {@link KeyringSelector.filter} but for `KeyringV2` instances. */ + filter: + | ((keyring: KeyringV2, metadata: KeyringMetadata) => boolean) + | (( + keyring: KeyringV2, + metadata: KeyringMetadata, + ) => keyring is SelectedKeyring); + }; + +/** + * Keyring builder. + */ +export type KeyringBuilder = { + (): Keyring; + type: string; +}; + +/** + * A builder that wraps a legacy `Keyring` into a `KeyringV2` adapter. + * + * The controller calls the builder once when the V1 keyring is created + * or restored; the resulting wrapper is cached for the keyring's lifetime. + */ +export type KeyringV2Builder = { + (keyring: Keyring, metadata: KeyringMetadata): KeyringV2; + type: string; +}; + +/** + * A function executed within a mutually exclusive lock, with + * a mutex releaser in its option bag. + * + * @param releaseLock - A function to release the lock. + */ +type MutuallyExclusiveCallback = ({ + releaseLock, +}: { + releaseLock: MutexInterface.Releaser; +}) => Promise; + +/** + * Get builder function for `Keyring` + * + * Returns a builder function for `Keyring` with a `type` property. + * + * @param KeyringConstructor - The Keyring class for the builder. + * @returns A builder function for the given Keyring. + */ +export function keyringBuilderFactory( + KeyringConstructor: KeyringClass, +): KeyringBuilder { + const builder: KeyringBuilder = (): Keyring => new KeyringConstructor(); + + builder.type = KeyringConstructor.type; + + return builder; +} + +const defaultKeyringBuilders = [ + // todo: keyring types are mismatched, this should be fixed in they keyrings themselves + // @ts-expect-error keyring types are mismatched + keyringBuilderFactory(SimpleKeyring), + keyringBuilderFactory(HdKeyring), +]; + +const hdKeyringV2Builder: KeyringV2Builder = Object.assign( + (keyring: Keyring, metadata: KeyringMetadata): KeyringV2 => + new HdKeyringV2({ + legacyKeyring: keyring as HdKeyring, + entropySource: metadata.id, + }), + { type: KeyringTypes.hd as string }, +); + +const simpleKeyringV2Builder: KeyringV2Builder = Object.assign( + (keyring: Keyring): KeyringV2 => + new SimpleKeyringV2({ + // @ts-expect-error TODO: `Keyring` here comes from `@metamask/keyring-utils`, + // which still depends on `@metamask/utils@^11`, while this package now + // depends on the workspace copy at v12. That leaves two distinct identities + // for the same type, so the cast no longer overlaps. Remove this once the + // keyring packages depend on v12. + legacyKeyring: keyring as SimpleKeyring, + }), + { type: KeyringTypes.simple as string }, +); + +const defaultKeyringV2Builders: KeyringV2Builder[] = [ + simpleKeyringV2Builder, + hdKeyringV2Builder, +]; + +export const getDefaultKeyringState = (): KeyringControllerState => { + return { + isUnlocked: false, + keyrings: [], + }; +}; + +/** + * Assert that the given keyring has an exportable + * mnemonic. + * + * @param keyring - The keyring to check + * @throws When the keyring does not have a mnemonic + */ +function assertHasUint8ArrayMnemonic( + keyring: EthKeyring, +): asserts keyring is EthKeyring & { mnemonic: Uint8Array } { + if ( + !( + hasProperty(keyring, 'mnemonic') && keyring.mnemonic instanceof Uint8Array + ) + ) { + throw new KeyringControllerError("Can't get mnemonic bytes from keyring"); + } +} + +/** + * Assert that the provided password is a valid non-empty string. + * + * @param password - The password to check. + * @throws If the password is not a valid string. + */ +function assertIsValidPassword(password: unknown): asserts password is string { + if (typeof password !== 'string') { + throw new KeyringControllerError( + KeyringControllerErrorMessage.WrongPasswordType, + ); + } + + if (!password?.length) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.InvalidEmptyPassword, + ); + } +} + +/** + * Assert that the provided encryption key is a valid non-empty string. + * + * @param encryptionKey - The encryption key to check. + * @throws If the encryption key is not a valid string. + */ +function assertIsEncryptionKeySet( + encryptionKey: string | undefined, +): asserts encryptionKey is string { + if (!encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.EncryptionKeyNotSet, + ); + } +} + +/** + * Parses the persisted encrypted-vault string from controller state. + * + * The vault string lives in persistent storage, which can be corrupted or + * tampered with, so a raw `JSON.parse` here would throw an untyped + * `SyntaxError` (or a `TypeError` on property access) instead of the + * documented `VaultError`. The parse is wrapped so every corruption path + * surfaces as `VaultError`, which callers and tests already handle. + * + * @param vault - The raw vault string from controller state. + * @returns The parsed vault object. + * @throws If the vault is missing or is not valid JSON. + */ +function parseVaultState(vault: string): { salt?: string } { + let parsed: unknown; + try { + parsed = JSON.parse(vault); + } catch { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + if (typeof parsed !== 'object' || parsed === null) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + return parsed as { salt?: string }; +} + +/** + * Checks if the provided value is a serialized keyrings array. + * + * @param array - The value to check. + * @returns True if the value is a serialized keyrings array. + */ +function isSerializedKeyringsArray( + array: unknown, +): array is SerializedKeyring[] { + return ( + typeof array === 'object' && + Array.isArray(array) && + array.every((value) => value.type && isValidJson(value.data)) + ); +} + +/** + * Display For Keyring + * + * Is used for adding the current keyrings to the state object. + * + * @param keyringWithMetadata - The keyring and its metadata. + * @param keyringWithMetadata.keyring - The keyring to display. + * @param keyringWithMetadata.metadata - The metadata of the keyring. + * @returns A keyring display object, with type and accounts properties. + */ +async function displayForKeyring({ + keyring, + metadata, +}: KeyringEntry): Promise { + const accounts = await keyring.getAccounts(); + + return { + type: keyring.type, + // Cast to `string[]` here is safe here because `accounts` has no nullish + // values, and `normalize` returns `string` unless given a nullish value + accounts: accounts.map(normalize) as string[], + metadata, + }; +} + +/** + * Check if address is an ethereum address + * + * @param address - An address. + * @returns Returns true if the address is an ethereum one, false otherwise. + */ +function isEthAddress(address: string): boolean { + // We first check if it's a matching `Hex` string, so that is narrows down + // `address` as an `Hex` type, allowing us to use `isValidHexAddress` + return ( + // NOTE: This function only checks for lowercased strings + isStrictHexString(address.toLowerCase()) && + // This checks for lowercased addresses and checksum addresses too + isValidHexAddress(address as Hex) + ); +} + +/** + * Normalize ethereum or non-EVM address. + * + * @param address - Ethereum or non-EVM address. + * @returns The normalized address. + */ +function normalize(address: string): string | undefined { + // Since the `KeyringController` is only dealing with address, we have + // no other way to get the associated account type with this address. So we + // are down to check the actual address format for now + // TODO: Find a better way to not have those runtime checks based on the + // address value! + return isEthAddress(address) ? ethNormalize(address) : address; +} + +/** + * Controller responsible for establishing and managing user identity. + * + * This class is a wrapper around the `eth-keyring-controller` package. The + * `eth-keyring-controller` manages the "vault", which is an encrypted store of private keys, and + * it manages the wallet "lock" state. This wrapper class has convenience methods for interacting + * with the internal keyring controller and handling certain complex operations that involve the + * keyrings. + */ +export class KeyringController< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> extends BaseController< + typeof name, + KeyringControllerState, + KeyringControllerMessenger +> { + readonly #controllerOperationMutex = new Mutex(); + + readonly #vaultOperationMutex = new Mutex(); + + readonly #keyringBuilders: { (): EthKeyring; type: string }[]; + + readonly #keyringV2Builders: KeyringV2Builder[]; + + readonly #encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >; + + #keyrings: KeyringEntry[]; + + #unsupportedKeyrings: SerializedKeyring[]; + + #encryptionKey?: CachedEncryptionKey; + + /** + * Creates a KeyringController instance. + * + * @param options - Initial options used to configure this controller + * @param options.encryptor - An optional object for defining encryption schemes. + * @param options.keyringBuilders - Set a new name for account. + * @param options.cacheEncryptionKey - Whether to cache or not encryption key. + * @param options.messenger - A restricted messenger. + * @param options.state - Initial state to set on this controller. + */ + constructor( + options: KeyringControllerOptions< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >, + ) { + const { encryptor, keyringBuilders, keyringV2Builders, messenger, state } = + options; + + super({ + name, + metadata: { + vault: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + isUnlocked: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, + keyrings: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + encryptionKey: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + encryptionSalt: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + }, + messenger, + state: { + ...getDefaultKeyringState(), + ...state, + }, + }); + + this.#keyringBuilders = keyringBuilders + ? keyringBuilders.concat(defaultKeyringBuilders) + : defaultKeyringBuilders; + + this.#keyringV2Builders = keyringV2Builders + ? keyringV2Builders.concat(defaultKeyringV2Builders) + : defaultKeyringV2Builders; + + this.#encryptor = encryptor; + this.#keyrings = []; + this.#unsupportedKeyrings = []; + + this.#registerMessageHandlers(); + } + + /** + * Adds a new account to the default (first) HD seed phrase keyring. + * + * @param accountCount - Number of accounts before adding a new one, used to + * make the method idempotent. + * @returns Promise resolving to the added account address. + */ + async addNewAccount(accountCount?: number): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; + if (!primaryKeyring) { + throw new KeyringControllerError('No HD keyring found'); + } + const oldAccounts = await primaryKeyring.getAccounts(); + + if (accountCount && oldAccounts.length !== accountCount) { + if (accountCount > oldAccounts.length) { + throw new KeyringControllerError('Account out of sequence'); + } + // we return the account already existing at index `accountCount` + const existingAccount = oldAccounts[accountCount]; + + if (!existingAccount) { + throw new KeyringControllerError( + `Can't find account at index ${accountCount}`, + ); + } + + return existingAccount; + } + + const [addedAccountAddress] = await primaryKeyring.addAccounts(1); + await this.#verifySeedPhrase(); + + return addedAccountAddress; + }); + } + + /** + * Adds a new account to the specified keyring. + * + * @param keyring - Keyring to add the account to. + * @param accountCount - Number of accounts before adding a new one, used to make the method idempotent. + * @returns Promise resolving to the added account address + */ + async addNewAccountForKeyring( + keyring: EthKeyring, + accountCount?: number, + ): Promise { + // READ THIS CAREFULLY: + // We still uses `Hex` here, since we are not using this method when creating + // and account using a "Snap Keyring". This function assume the `keyring` is + // ethereum compatible, but "Snap Keyring" might not be. + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const oldAccounts = await this.#getAccountsFromKeyrings(); + + if (accountCount && oldAccounts.length !== accountCount) { + if (accountCount > oldAccounts.length) { + throw new KeyringControllerError('Account out of sequence'); + } + + const existingAccount = oldAccounts[accountCount]; + assertIsStrictHexString(existingAccount); + + return existingAccount; + } + + await keyring.addAccounts(1); + + const addedAccountAddress = (await this.#getAccountsFromKeyrings()).find( + (selectedAddress) => !oldAccounts.includes(selectedAddress), + ); + assertIsStrictHexString(addedAccountAddress); + + return addedAccountAddress; + }); + } + + /** + * Effectively the same as creating a new keychain then populating it + * using the given seed phrase. + * + * @param password - Password to unlock keychain. + * @param seed - A BIP39-compliant seed phrase as Uint8Array, + * either as a string or an array of UTF-8 bytes that represent the string. + * @returns Promise resolving when the operation ends successfully. + */ + async createNewVaultAndRestore( + password: string, + seed: Uint8Array, + ): Promise { + return this.#persistOrRollback(async () => { + assertIsValidPassword(password); + + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, + opts: { + mnemonic: seed, + numberOfAccounts: 1, + }, + }); + }); + } + + /** + * Create a new vault and primary keyring. + * + * This only works if keyrings are empty. If there is a pre-existing unlocked vault, calling this will have no effect. + * If there is a pre-existing locked vault, it will be replaced. + * + * @param password - Password to unlock the new vault. + * @returns Promise resolving when the operation ends successfully. + */ + async createNewVaultAndKeychain(password: string): Promise { + return this.#persistOrRollback(async () => { + const accounts = await this.#getAccountsFromKeyrings(); + if (!accounts.length) { + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, + }); + } + }); + } + + /** + * Adds a new keyring of the given `type`. + * + * @param type - Keyring type name. + * @param opts - Keyring options. + * @throws If a builder for the given `type` does not exist. + * @returns Promise resolving to the new keyring metadata. + */ + async addNewKeyring( + type: KeyringTypes | string, + opts?: unknown, + ): Promise { + this.#assertIsUnlocked(); + + return this.#getKeyringMetadata( + await this.#persistOrRollback(async () => this.#newKeyring(type, opts)), + ); + } + + /** + * Method to verify a given password validity. Throws an + * error if the password is invalid. + * + * @param password - Password of the keyring. + */ + async verifyPassword(password: string): Promise { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + await this.#encryptor.decrypt(password, this.state.vault); + } + + /** + * Method to verify a given encryption key validity. Throws an error if the + * encryption key is invalid, i.e. it cannot decrypt the vault. + * + * @param encryptionKey - Serialized vault encryption key. + * @param encryptionSalt - Optional salt to verify against the vault. When + * omitted, the salt serialized alongside the vault is used. + */ + async #verifyEncryptionKey( + encryptionKey: string, + encryptionSalt?: string, + ): Promise { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + + const parsedEncryptedVault = parseVaultState(this.state.vault); + const salt = encryptionSalt ?? parsedEncryptedVault.salt; + + if (parsedEncryptedVault.salt !== salt) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ExpiredCredentials, + ); + } + + const key = await this.#encryptor.importKey(encryptionKey); + await this.#encryptor.decryptWithKey(key, parsedEncryptedVault); + } + + /** + * Verifies export credentials by checking either the wallet password or the + * vault encryption key. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + */ + async #verifyCredentials(credentials: Credentials): Promise { + // eslint-disable-next-line no-restricted-syntax + if ('password' in credentials) { + await this.verifyPassword(credentials.password); + } else { + await this.#verifyEncryptionKey( + credentials.encryptionKey, + credentials.encryptionSalt, + ); + } + } + + /** + * Returns the status of the vault. + * + * @returns Boolean returning true if the vault is unlocked. + */ + isUnlocked(): boolean { + return this.state.isUnlocked; + } + + /** + * Gets the seed phrase of the HD keyring. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + * @param keyringId - The id of the keyring. + * @returns Promise resolving to the seed phrase. + */ + async exportSeedPhrase( + credentials: Credentials, + keyringId?: string, + ): Promise { + this.#assertIsUnlocked(); + + await this.#verifyCredentials(credentials); + + const selectedKeyring = this.#getKeyringByIdOrDefault(keyringId); + if (!selectedKeyring) { + throw new KeyringControllerError('Keyring not found'); + } + assertHasUint8ArrayMnemonic(selectedKeyring); + + return selectedKeyring.mnemonic; + } + + /** + * Gets the private key from the keyring controlling an address. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + * @param address - Address to export. + * @returns Promise resolving to the private key for an address. + */ + async exportAccount( + credentials: Credentials, + address: string, + ): Promise { + this.#assertIsUnlocked(); + + await this.#verifyCredentials(credentials); + + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.exportAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedExportAccount, + ); + } + + return await keyring.exportAccount(normalize(address) as Hex); + } + + /** + * Returns the public addresses of all accounts from every keyring. + * + * @returns A promise resolving to an array of addresses. + */ + async getAccounts(): Promise { + this.#assertIsUnlocked(); + return this.state.keyrings.reduce( + (accounts, keyring) => accounts.concat(keyring.accounts), + [], + ); + } + + /** + * Get encryption public key. + * + * @param account - An account address. + * @param opts - Additional encryption options. + * @throws If the `account` does not exist or does not support the `getEncryptionPublicKey` method + * @returns Promise resolving to encyption public key of the `account` if one exists. + */ + async getEncryptionPublicKey( + account: string, + opts?: Record, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(account) as Hex; + const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; + if (!keyring.getEncryptionPublicKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedGetEncryptionPublicKey, + ); + } + + return await keyring.getEncryptionPublicKey(address, opts); + } + + /** + * Attempts to decrypt the provided message parameters. + * + * @param messageParams - The decryption message parameters. + * @param messageParams.from - The address of the account you want to use to decrypt the message. + * @param messageParams.data - The encrypted data that you want to decrypt. + * @returns The raw decryption result. + */ + async decryptMessage(messageParams: { + from: string; + data: Eip1024EncryptedData; + }): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.decryptMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedDecryptMessage, + ); + } + + return keyring.decryptMessage(address, messageParams.data); + } + + /** + * Returns the currently initialized keyring that manages + * the specified `address` if one exists. + * + * @deprecated Use of this method is discouraged as actions executed directly on + * keyrings are not being reflected in the KeyringController state and not + * persisted in the vault. Use `withKeyring` instead. + * @param account - An account address. + * @returns Promise resolving to keyring of the `account` if one exists. + */ + async getKeyringForAccount(account: string): Promise { + this.#assertIsUnlocked(); + const keyring = await this.#getKeyringForAccount(account); + if (keyring) { + return keyring; + } + + if (this.#keyrings.length === 0) { + throw new KeyringControllerError(KeyringControllerErrorMessage.NoKeyring); + } + + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + async #getKeyringForAccount( + account: string, + ): Promise { + this.#assertIsUnlocked(); + const entry = await this.#getKeyringEntryForAccount(account); + return entry?.keyring; + } + + async #getKeyringEntryForAccount( + account: string, + ): Promise { + this.#assertIsUnlocked(); + const keyringIndex = await this.#findKeyringIndexForAccount(account); + if (keyringIndex > -1) { + return this.#keyrings[keyringIndex]; + } + return undefined; + } + + async #findKeyringIndexForAccount(account: string): Promise { + this.#assertIsUnlocked(); + const address = account.toLowerCase(); + const accountsPerKeyring = await Promise.all( + this.#keyrings.map(({ keyring }) => keyring.getAccounts()), + ); + return accountsPerKeyring.findIndex((accounts) => + accounts.map((a) => a.toLowerCase()).includes(address), + ); + } + + /** + * Returns all keyrings of the given type. + * + * @deprecated Use of this method is discouraged as actions executed directly on + * keyrings are not being reflected in the KeyringController state and not + * persisted in the vault. Use `withKeyring` instead. + * @param type - Keyring type name. + * @returns An array of keyrings of the given type. + */ + getKeyringsByType(type: KeyringTypes | string): unknown[] { + this.#assertIsUnlocked(); + return this.#getKeyringEntriesByType({ v2: false, type }).map( + ({ keyring }) => keyring, + ); + } + + #getKeyringEntriesByType({ + v2, + type, + }: + | { + v2: false; + type: KeyringTypes | string; + } + | { + v2: true; + type: `${KeyringType}`; + }): KeyringEntry[] { + this.#assertIsUnlocked(); + return this.#keyrings.filter(({ keyring, keyringV2 }) => + v2 ? keyringV2?.type === type : keyring.type === type, + ); + } + + /** + * Persist all serialized keyrings in the vault. + * + * @deprecated This method is being phased out in favor of `withKeyring`. + * @returns Promise resolving with `true` value when the + * operation completes. + */ + async persistAllKeyrings(): Promise { + return this.#withRollback(async () => { + this.#assertIsUnlocked(); + + await this.#updateVault(); + return true; + }); + } + + /** + * Imports an account with the specified import strategy. + * + * @param strategy - Import strategy name. + * @param args - Array of arguments to pass to the underlying stategy. + * @throws Will throw when passed an unrecognized strategy. + * @returns Promise resolving to the imported account address. + */ + async importAccountWithStrategy( + strategy: AccountImportStrategy, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any[], + ): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { + let privateKey; + switch (strategy) { + case AccountImportStrategy.privateKey: { + const [importedKey] = args; + if (!importedKey) { + throw new KeyringControllerError('Cannot import an empty key.'); + } + const prefixed = add0x(importedKey); + + let bufferedPrivateKey; + try { + bufferedPrivateKey = hexToBytes(prefixed); + } catch { + throw new KeyringControllerError( + 'Cannot import invalid private key.', + ); + } + + if ( + !isValidPrivate(bufferedPrivateKey) || + // ensures that the key is 64 bytes long + getBinarySize(prefixed) !== 64 + '0x'.length + ) { + throw new KeyringControllerError( + 'Cannot import invalid private key.', + ); + } + + privateKey = remove0x(prefixed); + break; + } + case AccountImportStrategy.json: { + let wallet; + const [input, password] = args; + try { + wallet = importers.fromEtherWallet(input, password); + } catch { + // @ts-expect-error: Wallet.fromV3 does not exist? + wallet = wallet ?? (await Wallet.fromV3(input, password, true)); + } + privateKey = bytesToHex(new Uint8Array(wallet.getPrivateKey())); + break; + } + default: + throw new KeyringControllerError( + `Unexpected import strategy: '${String(strategy)}'`, + ); + } + const newKeyring = await this.#newKeyring(KeyringTypes.simple, [ + privateKey, + ]); + const accounts = await newKeyring.getAccounts(); + return accounts[0]; + }); + } + + /** + * Removes an account from keyring state. + * + * @param address - Address of the account to remove. + * @fires KeyringController:accountRemoved + * @returns Promise resolving when the account is removed. + */ + async removeAccount(address: string): Promise { + this.#assertIsUnlocked(); + + await this.#persistOrRollback(async () => { + const keyringIndex = await this.#findKeyringIndexForAccount(address); + + if (keyringIndex === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoKeyring, + ); + } + + const { keyring, keyringV2 } = this.#keyrings[keyringIndex]; + + const isPrimaryKeyring = keyringIndex === 0; + const shouldRemoveKeyring = (await keyring.getAccounts()).length === 1; + + // Primary keyring should never be removed, so we need to keep at least one account in it + if (isPrimaryKeyring && shouldRemoveKeyring) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, + ); + } + + // Not all the keyrings support this, so we have to check + if (!keyring.removeAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedRemoveAccount, + ); + } + + // FIXME #1: We do cast to `Hex` to make the type checker happy here, and + // because `Keyring.removeAccount` requires address to be `Hex`. + // Those types would need to be updated for a full non-EVM support. + // + // FIXME #2: The `removeAccount` method of snaps keyring is async. We have + // to update the interface of the other keyrings to be async as well. + // eslint-disable-next-line @typescript-eslint/await-thenable + await keyring.removeAccount(address as Hex); + + if (shouldRemoveKeyring) { + this.#keyrings.splice(keyringIndex, 1); + await this.#destroyKeyring(keyring, keyringV2); + } + }); + + this.messenger.publish(`${name}:accountRemoved`, address); + } + + /** + * Deallocates all secrets and locks the wallet. + * + * @returns Promise resolving when the operation completes. + */ + async setLocked(): Promise { + this.#assertIsUnlocked(); + + return this.#withRollback(async () => { + this.#encryptionKey = undefined; + await this.#clearKeyrings(); + + this.update((state) => { + state.isUnlocked = false; + state.keyrings = []; + delete state.encryptionKey; + delete state.encryptionSalt; + }); + + this.messenger.publish(`${name}:lock`); + }); + } + + /** + * Signs message by calling down into a specific keyring. + * + * @param messageParams - PersonalMessageParams object to sign. + * @returns Promise resolving to a signed message string. + */ + async signMessage(messageParams: PersonalMessageParams): Promise { + this.#assertIsUnlocked(); + + if (!messageParams.data) { + throw new KeyringControllerError("Can't sign an empty message"); + } + + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignMessage, + ); + } + + return await keyring.signMessage(address, messageParams.data); + } + + /** + * Signs EIP-7702 Authorization message by calling down into a specific keyring. + * + * @param params - EIP7702AuthorizationParams object to sign. + * @returns Promise resolving to an EIP-7702 Authorization signature. + * @throws Will throw UnsupportedSignEIP7702Authorization if the keyring does not support signing EIP-7702 Authorization messages. + */ + async signEip7702Authorization( + params: Eip7702AuthorizationParams, + ): Promise { + const from = ethNormalize(params.from) as Hex; + + const keyring = (await this.getKeyringForAccount(from)) as EthKeyring; + + if (!keyring.signEip7702Authorization) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignEip7702Authorization, + ); + } + + const { chainId, nonce } = params; + const contractAddress = ethNormalize(params.contractAddress) as + | Hex + | undefined; + + if (contractAddress === undefined) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingEip7702AuthorizationContractAddress, + ); + } + + return await keyring.signEip7702Authorization(from, [ + chainId, + contractAddress, + nonce, + ]); + } + + /** + * Signs personal message by calling down into a specific keyring. + * + * @param messageParams - PersonalMessageParams object to sign. + * @returns Promise resolving to a signed message string. + */ + async signPersonalMessage( + messageParams: PersonalMessageParams, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signPersonalMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignPersonalMessage, + ); + } + + const normalizedData = normalize(messageParams.data) as Hex; + + return await keyring.signPersonalMessage(address, normalizedData); + } + + /** + * Signs typed message by calling down into a specific keyring. + * + * @param messageParams - TypedMessageParams object to sign. + * @param version - Compatibility version EIP712. + * @throws Will throw when passed an unrecognized version. + * @returns Promise resolving to a signed message string or an error if any. + */ + async signTypedMessage( + messageParams: TypedMessageParams, + version: SignTypedDataVersion, + ): Promise { + this.#assertIsUnlocked(); + + try { + if ( + ![ + SignTypedDataVersion.V1, + SignTypedDataVersion.V3, + SignTypedDataVersion.V4, + ].includes(version) + ) { + throw new KeyringControllerError( + `Unexpected signTypedMessage version: '${version}'`, + ); + } + + // Cast to `Hex` here is safe here because `messageParams.from` is not nullish. + // `normalize` returns `Hex` unless given a nullish value. + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signTypedData) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignTypedMessage, + ); + } + + return await keyring.signTypedData( + address, + version !== SignTypedDataVersion.V1 && + typeof messageParams.data === 'string' + ? JSON.parse(messageParams.data) + : messageParams.data, + { version }, + ); + } catch (error) { + const errorMessage = + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + throw new KeyringControllerError( + `Keyring Controller signTypedMessage: ${errorMessage}`, + error instanceof Error ? error : undefined, + ); + } + } + + /** + * Signs a transaction by calling down into a specific keyring. + * + * @param transaction - Transaction object to sign. Must be a `ethereumjs-tx` transaction instance. + * @param from - Address to sign from, should be in keychain. + * @param opts - An optional options object. + * @returns Promise resolving to a signed transaction string. + */ + async signTransaction( + transaction: TypedTransaction, + from: string, + opts?: Record, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signTransaction) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignTransaction, + ); + } + + return await keyring.signTransaction(address, transaction, opts); + } + + /** + * Convert a base transaction to a base UserOperation. + * + * @param from - Address of the sender. + * @param transactions - Base transactions to include in the UserOperation. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A pseudo-UserOperation that can be used to construct a real. + */ + async prepareUserOperation( + from: string, + transactions: EthBaseTransaction[], + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + + if (!keyring.prepareUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedPrepareUserOperation, + ); + } + + return await keyring.prepareUserOperation( + address, + transactions, + executionContext, + ); + } + + /** + * Patches properties of a UserOperation. Currently, only the + * `paymasterAndData` can be patched. + * + * @param from - Address of the sender. + * @param userOp - UserOperation to patch. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A patch to apply to the UserOperation. + */ + async patchUserOperation( + from: string, + userOp: EthUserOperation, + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + + if (!keyring.patchUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedPatchUserOperation, + ); + } + + return await keyring.patchUserOperation(address, userOp, executionContext); + } + + /** + * Signs an UserOperation. + * + * @param from - Address of the sender. + * @param userOp - UserOperation to sign. + * @param executionContext - The execution context to use for the UserOperation. + * @returns The signature of the UserOperation. + */ + async signUserOperation( + from: string, + userOp: EthUserOperation, + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + + if (!keyring.signUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignUserOperation, + ); + } + + return await keyring.signUserOperation(address, userOp, executionContext); + } + + /** + * Changes the password used to encrypt the vault. + * + * @param password - The new password. + * @returns Promise resolving when the operation completes. + */ + changePassword(password: string): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + assertIsValidPassword(password); + await this.#deriveAndSetEncryptionKey(password, { + ignoreExistingVault: true, + }); + }); + } + + /** + * Attempts to decrypt the current vault and load its keyrings, using the + * given encryption key and salt. The optional salt can be used to check for + * consistency with the vault salt. + * + * @param encryptionKey - Key to unlock the keychain. + * @param encryptionSalt - Optional salt to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ + async submitEncryptionKey( + encryptionKey: string, + encryptionSalt?: string, + ): Promise { + const { hasChanged } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings({ + encryptionKey, + encryptionSalt, + }); + this.#setUnlocked(); + return result; + }); + + try { + // if new metadata has been generated during login, we + // can attempt to upgrade the vault. + await this.#withRollback(async () => { + if (hasChanged) { + await this.#updateVault(); + } + }); + } catch (error) { + // We don't want to throw an error if the upgrade fails + // since the controller is already unlocked. + console.error('Failed to update vault during login:', error); + } + } + + /** + * Exports the vault encryption key. + * + * @returns The vault encryption key. + */ + async exportEncryptionKey(): Promise { + this.#assertIsUnlocked(); + + return await this.#withControllerLock(async () => { + assertIsEncryptionKeySet(this.#encryptionKey?.serialized); + return this.#encryptionKey.serialized; + }); + } + + /** + * Attempts to decrypt the current vault and load its keyrings, + * using the given password. + * + * @param password - Password to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ + async submitPassword(password: string): Promise { + const { hasChanged } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings({ password }); + this.#setUnlocked(); + return result; + }); + + try { + // If there are stronger encryption params available, or + // if the keyring state has changed during deserialization, we + // can attempt to upgrade the vault. + await this.#withRollback(async () => { + if (hasChanged || this.#isNewEncryptionAvailable()) { + await this.#deriveAndSetEncryptionKey(password, { + // If the vault is being upgraded, we want to ignore the metadata + // that is already in the vault, so we can effectively + // re-encrypt the vault with the new encryption config. + ignoreExistingVault: true, + }); + await this.#updateVault(); + } + }); + } catch (error) { + // We don't want to throw an error if the upgrade fails + // since the controller is already unlocked. + console.error('Failed to update vault during login:', error); + } + } + + /** + * Verifies the that the seed phrase restores the current keychain's accounts. + * + * @param keyringId - The id of the keyring to verify. + * @returns Promise resolving to the seed phrase as Uint8Array. + */ + async verifySeedPhrase(keyringId?: string): Promise { + this.#assertIsUnlocked(); + + return this.#withControllerLock(async () => + this.#verifySeedPhrase(keyringId), + ); + } + + /** + * Asserts a value is not a specific keyring instance, and throws an error if it is. + * + * @param value The value to check. + * @param keyring The keyring instance to check against. + * @throws If the value is the same instance as the keyring. + * @returns The original value if the check passes. + */ + #assertNoUnsafeDirectKeyringAccess( + value: Value, + keyring: SelectedKeyring, + ): Value { + if (Object.is(value, keyring)) { + // Access to a keyring instance outside of controller safeguards + // should be discouraged, as it can lead to unexpected behavior. + // This error is thrown to prevent consumers using `withKeyring` + // as a way to get a reference to a keyring instance. + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + } + + return value; + } + + /** + * Select a keyring and execute the given operation with + * the selected keyring, as a mutually exclusive atomic + * operation. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the selected keyring. + * @param options - Additional options. + * @param options.createIfMissing - Whether to create a new keyring if the selected one is missing. + * @param options.createWithData - Optional data to use when creating a new keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + * @deprecated This method overload is deprecated. Use `withKeyring` without options instead. + */ + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, + // eslint-disable-next-line @typescript-eslint/unified-signatures + options: + | { createIfMissing?: false } + | { createIfMissing: true; createWithData?: unknown }, + ): Promise; + + /** + * Select a keyring and execute the given operation with + * the selected keyring, as a mutually exclusive atomic + * operation. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the selected keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, + ): Promise; + + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + options: + | { createIfMissing?: false } + | { createIfMissing: true; createWithData?: unknown } = { + createIfMissing: false, + }, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + let entry: KeyringEntry | undefined = await this.#selectKeyringEntry({ + v2: false, + selector, + }); + + if (!entry && 'type' in selector && options.createIfMissing) { + const newKeyring = (await this.#newKeyring( + selector.type, + options.createWithData, + )) as SelectedKeyring; + entry = this.#keyrings.find(({ keyring }) => keyring === newKeyring); + } + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + const { metadata } = entry; + const keyring = entry.keyring as SelectedKeyring; + + return this.#assertNoUnsafeDirectKeyringAccess( + await this.#cleanUpEmptiedKeyringsAfter(async () => + operation({ keyring, metadata }), + ), + keyring, + ); + }); + } + + /** + * Select a keyring and execute the given operation with the selected + * keyring, **without** acquiring the controller's mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading the `mnemonic` from an `HdKeyring`: the mnemonic is set during + * `deserialize()` and never mutated afterwards, so it can safely be read + * without holding the lock. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyring`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the selected keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringUnsafe< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + const entry = await this.#selectKeyringEntry({ v2: false, selector }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + const { metadata } = entry; + const keyring = entry.keyring as SelectedKeyring; + + // Even if this method is "unsafe", we still want to prevent returning + // the keyring directly. + return this.#assertNoUnsafeDirectKeyringAccess( + await operation({ keyring, metadata }), + keyring, + ); + } + + /** + * Select a keyring using its `KeyringV2` adapter, and execute + * the given operation with the wrapped keyring as a mutually + * exclusive atomic operation. + * + * The cached `KeyringV2` adapter is retrieved from the keyring + * entry. + * + * A `KeyringV2Builder` for the selected keyring's type must exist + * (either as a default or registered via the `keyringV2Builders` + * constructor option); otherwise an error is thrown. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringV2< + SelectedKeyring extends KeyringV2 = KeyringV2, + CallbackResult = void, + >( + selector: KeyringSelectorV2, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const entry = await this.#selectKeyringEntry({ + v2: true, + selector, + }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (!entry.keyringV2) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); + } + + const { metadata } = entry; + const keyring = entry.keyringV2 as SelectedKeyring; + + return this.#assertNoUnsafeDirectKeyringAccess( + await this.#cleanUpEmptiedKeyringsAfter(async () => + operation({ + keyring, + metadata, + }), + ), + keyring, + ); + }); + } + + /** + * Select a keyring, wrap it in a `KeyringV2` adapter, and execute + * the given read-only operation **without** acquiring the controller's + * mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading immutable fields from a `KeyringV2` adapter: data that is set + * during initialization and never mutated afterwards. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyringV2`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected V2 keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringV2Unsafe< + SelectedKeyring extends KeyringV2 = KeyringV2, + CallbackResult = void, + >( + selector: KeyringSelectorV2, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + const entry = await this.#selectKeyringEntry({ + v2: true, + selector, + }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (!entry.keyringV2) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); + } + + const { metadata } = entry; + const keyring = entry.keyringV2 as SelectedKeyring; + + // Even if this method is "unsafe", we still want to prevent returning + // the keyring directly. + return this.#assertNoUnsafeDirectKeyringAccess( + await operation({ keyring, metadata }), + keyring, + ); + } + + /** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withController( + operation: ( + restrictedController: RestrictedController, + ) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + // Track created and removed keyrings during the operation execution. + const createdEntries = new Set(); + const removedEntries = new Set(); + + // Copy of the current keyrings that is mutated during the operation execution. + const restrictedEntries = [...this.#keyrings]; + + // The restricted controller proxies the current keyrings and allows staging + // mutations that are only applied to the real keyrings if the operation + // completes successfully. This allows us to have a single source of truth + // for the keyrings during the operation execution, and to automatically + // roll back any changes if an error is thrown. + const restrictedController: RestrictedController = { + // We freeze the array to prevent direct mutations, but the keyring instances + // themselves are not frozen, allowing safe read-only access. + get keyrings() { + return Object.freeze([...restrictedEntries]); + }, + + // Method to create a new keyring and adds it to the restricted entries. + addNewKeyring: async (type: string, opts?: unknown) => { + const entry = await this.#createKeyring(type, opts); + + restrictedEntries.push(entry); + createdEntries.add(entry); + + return entry; + }, + + // Method to remove a keyring from the restricted entries. + removeKeyring: async (id: string) => { + const index = restrictedEntries.findIndex( + (entry) => entry.metadata.id === id, + ); + if (index === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + this.#assertNotRemovingPrimaryKeyring( + restrictedEntries[index], + restrictedEntries, + ); + + const [removed] = restrictedEntries.splice(index, 1) as [ + KeyringEntry, + ]; + removedEntries.add(removed); + }, + }; + + const destroyKeyrings = async ( + entries: Iterable, + ): Promise => { + await Promise.all( + [...entries].map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), + ); + }; + + let result: CallbackResult; + try { + result = await operation(restrictedController); + } catch (error) { + await destroyKeyrings(createdEntries); + + throw error; + } + + await destroyKeyrings(removedEntries); + + // We update the real keyrings only after the operation completes successfully, so that + // they will be persisted in the vault. + this.#keyrings = restrictedEntries; + + // As usual, we want to prevent returning direct references to keyring instances, so we check + // the result for any unsafe direct access before returning. + for (const { keyring, keyringV2 } of [ + ...this.#keyrings, + // We also check for keyrings that got removed during the operation, since the result could + // still have references to them. + ...removedEntries, + ]) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + if (keyringV2) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); + } + } + + return result; + }); + } + + /** + * Gets the type of the keyring that manages the specified account. + * + * @param account - The account address to look up. + * @returns A promise that resolves to the type of the keyring managing the account. + */ + async getAccountKeyringType(account: string): Promise { + this.#assertIsUnlocked(); + + const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; + return keyring.type; + } + + /** + * Constructor helper for registering this controller's messeger + * actions. + */ + #registerMessageHandlers(): void { + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Select a keyring entry using a selector without acquiring the controller lock. + * + * @param options - Selection options. + * @param options.v2 - Tag to indicate whether the selector is for a V2 keyring. + * @param options.selector - Keyring selector object. + * @returns The selected keyring entry, or `undefined` if no match is found. + * @template SelectedKeyring - The expected type of the selected keyring. + * @template SelectedKeyringV2 - The expected type of the selected keyring (v2). + */ + async #selectKeyringEntry< + SelectedKeyring extends EthKeyring, + SelectedKeyringV2 extends KeyringV2, + >({ + v2, + selector, + }: // Use distinct union tags to ensure proper type narrowing of the selector object. + | { + v2: false; + selector: KeyringSelector; + } + | { + v2: true; + selector: KeyringSelectorV2; + }): Promise { + let entry: KeyringEntry | undefined; + + if ('address' in selector) { + entry = await this.#getKeyringEntryForAccount(selector.address); + } else if ('type' in selector) { + const entries = v2 + ? this.#getKeyringEntriesByType({ v2: true, type: selector.type }) + : this.#getKeyringEntriesByType({ v2: false, type: selector.type }); + entry = entries[selector.index ?? 0]; + } else if ('id' in selector) { + entry = this.#getKeyringEntryById(selector.id); + } else if ('filter' in selector) { + entry = this.#keyrings.find(({ keyring, keyringV2, metadata }) => { + // If v2, then we'll use the v2 selector which expects a `KeyringV2` instance. + if (v2) { + // However, some keyrings do not have a v2 wrapper, so we just skip them. + if (!keyringV2) { + return false; + } + + return selector.filter(keyringV2, metadata); + } + + return selector.filter(keyring, metadata); + }); + } + + return entry; + } + + /** + * Get the keyring by id. + * + * @param keyringId - The id of the keyring. + * @returns The keyring. + */ + #getKeyringById(keyringId: string): EthKeyring | undefined { + return this.#getKeyringEntryById(keyringId)?.keyring; + } + + #getKeyringEntryById(keyringId: string): KeyringEntry | undefined { + return this.#keyrings.find(({ metadata }) => metadata.id === keyringId); + } + + /** + * Get the keyring by id or return the first keyring if the id is not found. + * + * @param keyringId - The id of the keyring. + * @returns The keyring. + */ + #getKeyringByIdOrDefault(keyringId?: string): EthKeyring | undefined { + if (!keyringId) { + return this.#keyrings[0]?.keyring; + } + + return this.#getKeyringById(keyringId); + } + + /** + * Get the metadata for the specified keyring. + * + * @param keyring - The keyring instance to get the metadata for. + * @returns The keyring metadata. + */ + #getKeyringMetadata(keyring: unknown): KeyringMetadata { + const keyringWithMetadata = this.#keyrings.find( + (candidate) => candidate.keyring === keyring, + ); + if (!keyringWithMetadata) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return keyringWithMetadata.metadata; + } + + /** + * Get the keyring builder for the given `type`. + * + * @param type - The type of keyring to get the builder for. + * @returns The keyring builder, or undefined if none exists. + */ + #getKeyringBuilderForType( + type: string, + ): { (): EthKeyring; type: string } | undefined { + return this.#keyringBuilders.find( + (keyringBuilder) => keyringBuilder.type === type, + ); + } + + /** + * Get the V2 keyring builder for the given `type`. + * + * @param type - The type of keyring to get the builder for. + * @returns The V2 keyring builder, or undefined if none exists. + */ + #getKeyringV2BuilderForType(type: string): KeyringV2Builder | undefined { + return this.#keyringV2Builders.find((builder) => builder.type === type); + } + + /** + * Create new vault with an initial keyring + * + * Destroys any old encrypted storage, + * creates a new encrypted store with the given password, + * creates a new wallet with 1 account. + * + * @fires KeyringController:unlock + * @param password - The password to encrypt the vault with. + * @param keyring - A object containing the params to instantiate a new keyring. + * @param keyring.type - The keyring type. + * @param keyring.opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves to the state. + */ + async #createNewVaultWithKeyring( + password: string, + keyring: { + type: string; + opts?: unknown; + }, + ): Promise { + this.#assertControllerMutexIsLocked(); + + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); + } + + this.update((state) => { + delete state.encryptionKey; + delete state.encryptionSalt; + }); + + await this.#deriveAndSetEncryptionKey(password, { + ignoreExistingVault: true, + }); + + await this.#clearKeyrings(); + await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts); + this.#setUnlocked(); + } + + /** + * Derive the vault encryption key from the provided password, and + * assign it to the instance variable for later use with cryptographic + * functions. + * + * When the controller has a vault in its state, the key is derived + * using the salt from the vault. If the vault is empty, a new salt + * is generated and used to derive the key. + * + * If `options.ignoreExistingVault` is set to `true`, the existing + * vault is completely ignored: the new key won't be able to decrypt + * the existing vault, and should be used to re-encrypt it. + * + * @param password - The password to use for decryption or derivation. + * @param options - Options for the key derivation. + * @param options.ignoreExistingVault - Whether to ignore the existing vault salt and key metadata + */ + async #deriveAndSetEncryptionKey( + password: string, + options: { ignoreExistingVault: boolean } = { + ignoreExistingVault: false, + }, + ): Promise { + this.#assertControllerMutexIsLocked(); + const { vault } = this.state; + + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); + } + + let serializedEncryptionKey: string, salt: string; + if (vault && !options.ignoreExistingVault) { + // The `decryptWithDetail` method is being used here instead of + // `keyFromPassword` + `exportKey` to let the encryptor handle + // any legacy encryption formats and metadata that might be + // present (or absent) in the vault. + const { exportedKeyString, salt: existingSalt } = + await this.#encryptor.decryptWithDetail(password, vault); + serializedEncryptionKey = exportedKeyString; + salt = existingSalt; + } else { + salt = this.#encryptor.generateSalt(); + serializedEncryptionKey = await this.#encryptor.exportKey( + await this.#encryptor.keyFromPassword(password, salt, true), + ); + } + + this.#encryptionKey = { + salt, + serialized: serializedEncryptionKey, + }; + } + + /** + * Set the the `#encryptionKey` instance variable. + * This method is used when the user provides an encryption key and salt + * to unlock the keychain, instead of using a password. + * + * @param encryptionKey - The encryption key to use. + * @param keyDerivationSalt - The salt to use for the encryption key. + */ + #setEncryptionKey(encryptionKey: string, keyDerivationSalt: string): void { + this.#assertControllerMutexIsLocked(); + + if ( + typeof encryptionKey !== 'string' || + typeof keyDerivationSalt !== 'string' + ) { + throw new TypeError(KeyringControllerErrorMessage.WrongEncryptionKeyType); + } + + const { vault } = this.state; + if (vault && parseVaultState(vault).salt !== keyDerivationSalt) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ExpiredCredentials, + ); + } + + this.#encryptionKey = { + salt: keyDerivationSalt, + serialized: encryptionKey, + }; + } + + /** + * Internal non-exclusive method to verify the seed phrase. + * + * @param keyringId - The id of the keyring to verify the seed phrase for. + * @returns A promise resolving to the seed phrase as Uint8Array. + */ + async #verifySeedPhrase(keyringId?: string): Promise { + this.#assertControllerMutexIsLocked(); + + const keyring = this.#getKeyringByIdOrDefault(keyringId); + + if (!keyring) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (keyring.type !== (KeyringTypes.hd as string)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedVerifySeedPhrase, + ); + } + + assertHasUint8ArrayMnemonic(keyring); + + const seedWords = keyring.mnemonic; + const accounts = await keyring.getAccounts(); + /* istanbul ignore if */ + if (accounts.length === 0) { + throw new KeyringControllerError('Cannot verify an empty keyring.'); + } + + // The HD Keyring Builder is a default keyring builder + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const hdKeyringBuilder = this.#getKeyringBuilderForType(KeyringTypes.hd)!; + + const hdKeyring = hdKeyringBuilder(); + // @ts-expect-error @metamask/eth-hd-keyring correctly handles + // Uint8Array seed phrases in the `deserialize` method. + await hdKeyring.deserialize({ + mnemonic: seedWords, + numberOfAccounts: accounts.length, + }); + const testAccounts = await hdKeyring.getAccounts(); + /* istanbul ignore if */ + if (testAccounts.length !== accounts.length) { + throw new KeyringControllerError( + 'Seed phrase imported incorrect number of accounts.', + ); + } + + testAccounts.forEach((account: string, i: number) => { + /* istanbul ignore if */ + if (account.toLowerCase() !== accounts[i].toLowerCase()) { + throw new KeyringControllerError( + 'Seed phrase imported different accounts.', + ); + } + }); + + return seedWords; + } + + /** + * Get the updated array of each keyring's type and + * accounts list. + * + * @returns A promise resolving to the updated keyrings array. + */ + async #getUpdatedKeyrings(): Promise { + return Promise.all(this.#keyrings.map(displayForKeyring)); + } + + /** + * Serialize the current array of keyring instances, + * including unsupported keyrings by default. + * + * @param options - Method options. + * @param options.includeUnsupported - Whether to include unsupported keyrings. + * @returns The serialized keyrings. + */ + async #getSerializedKeyrings( + { includeUnsupported }: { includeUnsupported: boolean } = { + includeUnsupported: true, + }, + ): Promise { + const serializedKeyrings: SerializedKeyring[] = await Promise.all( + this.#keyrings.map(async ({ keyring, metadata }) => { + return { + type: keyring.type, + data: await keyring.serialize(), + metadata, + }; + }), + ); + + if (includeUnsupported) { + serializedKeyrings.push(...this.#unsupportedKeyrings); + } + + return serializedKeyrings; + } + + /** + * Get a snapshot of session data held by instance variables. + * + * @returns An object with serialized keyrings, keyrings metadata, + * and the user password. + */ + async #getSessionState(): Promise { + return { + keyrings: await this.#getSerializedKeyrings(), + encryptionKey: this.#encryptionKey, + }; + } + + /** + * Restore a serialized keyrings array. + * + * @param serializedKeyrings - The serialized keyrings array. + * @returns The restored keyrings. + */ + async #restoreSerializedKeyrings( + serializedKeyrings: SerializedKeyring[], + ): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + hasChanged: boolean; + }> { + await this.#clearKeyrings(); + const keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[] = []; + let hasChanged = false; + + for (const serializedKeyring of serializedKeyrings) { + const result = await this.#restoreKeyring(serializedKeyring); + if (result) { + const { keyring, metadata } = result; + keyrings.push({ keyring, metadata }); + if (result.hasChanged) { + hasChanged = true; + } + } + } + + return { keyrings, hasChanged }; + } + + /** + * Unlock Keyrings, decrypting the vault and deserializing all + * keyrings contained in it, using a password or an encryption key with salt. + * + * @param credentials - The credentials to unlock the keyrings. + * @returns A promise resolving to the deserialized keyrings array. + */ + async #unlockKeyrings(credentials: Credentials): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + hasChanged: boolean; + }> { + return this.#withVaultLock(async () => { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + const parsedEncryptedVault = parseVaultState(this.state.vault); + + if ('password' in credentials) { + await this.#deriveAndSetEncryptionKey(credentials.password); + } else { + this.#setEncryptionKey( + credentials.encryptionKey, + credentials.encryptionSalt ?? parsedEncryptedVault.salt, + ); + } + + const encryptionKey = this.#encryptionKey?.serialized; + if (!encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingCredentials, + ); + } + + const key = await this.#encryptor.importKey(encryptionKey); + const vault = await this.#encryptor.decryptWithKey( + key, + parsedEncryptedVault, + ); + + if (!isSerializedKeyringsArray(vault)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultDataError, + ); + } + + const { keyrings, hasChanged } = + await this.#restoreSerializedKeyrings(vault); + + const updatedKeyrings = await this.#getUpdatedKeyrings(); + + this.update((state) => { + state.keyrings = updatedKeyrings; + state.encryptionKey = encryptionKey; + state.encryptionSalt = this.#encryptionKey?.salt; + }); + + return { keyrings, hasChanged }; + }); + } + + /** + * Update the vault with the current keyrings. + * + * @returns A promise resolving to `true` if the operation is successful. + */ + #updateVault(): Promise { + return this.#withVaultLock(async () => { + // Ensure no duplicate accounts are persisted. + await this.#assertNoDuplicateAccounts(); + + if (!this.#encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingCredentials, + ); + } + + const serializedKeyrings = await this.#getSerializedKeyrings(); + + if ( + !serializedKeyrings.some( + (keyring) => keyring.type === (KeyringTypes.hd as string), + ) + ) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoHdKeyring, + ); + } + + const key = await this.#encryptor.importKey( + this.#encryptionKey.serialized, + ); + const encryptedVault = await this.#encryptor.encryptWithKey( + key, + serializedKeyrings, + ); + // We need to include the salt used to derive + // the encryption key, to be able to derive it + // from password again. + encryptedVault.salt = this.#encryptionKey.salt; + const updatedState: Partial = { + vault: JSON.stringify(encryptedVault), + encryptionKey: this.#encryptionKey.serialized, + encryptionSalt: this.#encryptionKey.salt, + }; + + const updatedKeyrings = await this.#getUpdatedKeyrings(); + + this.update((state) => { + state.vault = updatedState.vault; + state.keyrings = updatedKeyrings; + state.encryptionKey = updatedState.encryptionKey; + state.encryptionSalt = updatedState.encryptionSalt; + }); + + return true; + }); + } + + /** + * Check if there are new encryption parameters available. + * + * @returns A promise resolving to `void`. + */ + #isNewEncryptionAvailable(): boolean { + const { vault } = this.state; + + if (!vault || !this.#encryptor.isVaultUpdated) { + return false; + } + + return !this.#encryptor.isVaultUpdated(vault); + } + + /** + * Retrieves all the accounts from keyrings instances + * that are currently in memory. + * + * @param additionalKeyrings - Additional keyrings to include in the search. + * @returns A promise resolving to an array of accounts. + */ + async #getAccountsFromKeyrings( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const keyrings = this.#keyrings.map(({ keyring }) => keyring); + + const keyringArrays = await Promise.all( + [...keyrings, ...additionalKeyrings].map(async (keyring) => + keyring.getAccounts(), + ), + ); + const addresses = keyringArrays.reduce((res, arr) => { + return res.concat(arr); + }, []); + + // Cast to `string[]` here is safe here because `addresses` has no nullish + // values, and `normalize` returns `string` unless given a nullish value + return addresses.map(normalize) as string[]; + } + + /** + * Create a new keyring, ensuring that the first account is + * also created. + * + * @param type - Keyring type to instantiate. + * @param opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves if the operation is successful. + */ + async #createKeyringWithFirstAccount( + type: string, + opts?: unknown, + ): Promise { + this.#assertControllerMutexIsLocked(); + + const keyring = await this.#newKeyring(type, opts); + + const [firstAccount] = await keyring.getAccounts(); + if (!firstAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoFirstAccount, + ); + } + return firstAccount; + } + + /** + * Instantiate, initialize and return a new keyring of the given `type`, + * using the given `opts`. The keyring is built using the keyring builder + * registered for the given `type`. + * + * The internal keyring and keyring metadata arrays are updated with the new + * keyring as well. + * + * @param type - The type of keyring to add. + * @param data - Keyring initialization options. + * @returns The new keyring. + * @throws If the keyring includes duplicated accounts. + */ + async #newKeyring(type: string, data?: unknown): Promise { + const { keyring, keyringV2, metadata } = await this.#createKeyring( + type, + data, + ); + + this.#keyrings.push({ keyring, keyringV2, metadata }); + + return keyring; + } + + /** + * Instantiate, initialize and return a keyring of the given `type` using the + * given `opts`. The keyring is built using the keyring builder registered + * for the given `type`. + * + * The keyring might be new, or it might be restored from the vault. This + * function should only be called from `#newKeyring` or `#restoreKeyring`, + * for the "new" and "restore" cases respectively. + * + * The internal keyring and keyring metadata arrays are *not* updated, the + * caller is expected to update them. + * + * @param type - The type of keyring to add. + * @param data - Keyring initialization options. + * @param metadata - Keyring metadata if available. + * @returns The new keyring. + * @throws If the keyring includes duplicated accounts. + */ + async #createKeyring( + type: string, + data?: unknown, + metadata?: KeyringMetadata, + ): Promise { + this.#assertControllerMutexIsLocked(); + + const keyringMetadata = metadata ?? getDefaultKeyringMetadata(); + + const keyringBuilder = this.#getKeyringBuilderForType(type); + if (!keyringBuilder) { + throw new KeyringControllerError( + `${KeyringControllerErrorMessage.NoKeyringBuilder}. Keyring type: ${type}`, + ); + } + + const keyring = keyringBuilder(); + if (data) { + // @ts-expect-error Enforce data type after updating clients + await keyring.deserialize(data); + } + + if (keyring.init) { + await keyring.init(); + } + + if ( + type === (KeyringTypes.hd as string) && + (!isObject(data) || !data.mnemonic) + ) { + if (!keyring.generateRandomMnemonic) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedGenerateRandomMnemonic, + ); + } + + // NOTE: Not all keyrings implement this method in a asynchronous-way. Using `await` for + // non-thenable will still be valid (despite not being really useful). It allows us to cover both + // cases and allow retro-compatibility too. + await keyring.generateRandomMnemonic(); + await keyring.addAccounts(1); + } + + // We now create the keyring V2 wrappers and store them in memory. + const keyringBuilderV2 = this.#getKeyringV2BuilderForType(type); + let keyringV2: KeyringV2 | undefined; + if (keyringBuilderV2) { + keyringV2 = keyringBuilderV2(keyring, keyringMetadata); + } + + return { keyring, keyringV2, metadata: keyringMetadata }; + } + + /** + * Run the given operation and afterwards clean up any keyring whose + * account list transitioned from non-empty to empty during the operation. + * + * This mirrors the cleanup behavior of {@link KeyringController.removeAccount} + * for code paths where the consumer mutates a keyring directly via + * {@link KeyringController.withKeyring} or + * {@link KeyringController.withKeyringV2}: if the consumer drains the last + * account from a keyring, the now-empty keyring is removed from + * {@link KeyringController.#keyrings} and destroyed before persistence runs. + * + * Pre-existing empty keyrings (e.g. those created intentionally via + * {@link KeyringController.addNewKeyring} without subsequent account + * creation) are left alone, as are keyrings created within the operation + * itself (they are not part of the pre-operation snapshot). The primary + * keyring (see {@link KeyringController.#isPrimaryKeyring}) is also preserved + * unconditionally to keep `removeAccount`'s primary-keyring invariant intact. + * + * @param operation - The operation to execute. + * @returns The result of the operation. + * @template Result - The type of the value resolved by the operation. + */ + async #cleanUpEmptiedKeyringsAfter( + operation: () => Promise, + ): Promise { + // Only the primary keyring exists, which is never auto-removed, so there + // is nothing to clean up regardless of what the operation does. + if (this.#keyrings.length <= 1) { + return operation(); + } + + const wasNonEmpty = new WeakSet(); + await Promise.all( + this.#keyrings.map(async ({ keyring }) => { + if ((await keyring.getAccounts()).length > 0) { + wasNonEmpty.add(keyring); + } + }), + ); + + const result = await operation(); + + const isNowEmpty = await Promise.all( + this.#keyrings.map( + async ({ keyring }) => (await keyring.getAccounts()).length === 0, + ), + ); + + const emptied = this.#keyrings.filter( + (entry, index) => + !this.#isPrimaryKeyring(entry, this.#keyrings) && + wasNonEmpty.has(entry.keyring) && + isNowEmpty[index], + ); + + if (emptied.length > 0) { + const removed = new Set(emptied); + this.#keyrings = this.#keyrings.filter((entry) => !removed.has(entry)); + await Promise.all( + emptied.map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), + ); + } + + return result; + } + + /** + * Remove all managed keyrings, destroying all their + * instances in memory. + */ + async #clearKeyrings(): Promise { + this.#assertControllerMutexIsLocked(); + for (const { keyring, keyringV2 } of this.#keyrings) { + await this.#destroyKeyring(keyring, keyringV2); + } + this.#keyrings = []; + this.#unsupportedKeyrings = []; + } + + /** + * Restore a Keyring from a provided serialized payload. + * On success, returns the resulting keyring instance. + * + * @param serialized - The serialized keyring. + * @returns The deserialized keyring or undefined if the keyring type is unsupported. + */ + async #restoreKeyring(serialized: SerializedKeyring): Promise< + | (KeyringEntry & { + hasChanged: boolean; + }) + | undefined + > { + this.#assertControllerMutexIsLocked(); + + try { + const { type, data, metadata: serializedMetadata } = serialized; + + // Track if we need to trigger a vault update. + let hasChanged = false; + + // If metadata is missing, assume the data is from an installation before we had + // keyring metadata. + let metadata = serializedMetadata; + if (!metadata) { + hasChanged = true; + metadata = getDefaultKeyringMetadata(); + } + + const oldState = JSON.stringify(data); + const { keyring, keyringV2 } = await this.#createKeyring( + type, + data, + metadata, + ); + const newState = JSON.stringify(await keyring.serialize()); + hasChanged ||= oldState !== newState; + + await this.#assertNoDuplicateAccounts([keyring]); + + // The keyring is added to the keyrings array only if it's successfully restored + // and the metadata is successfully added to the controller + this.#keyrings.push({ + keyring, + keyringV2, + metadata, + }); + + return { keyring, keyringV2, metadata, hasChanged }; + } catch (error) { + console.error(error); + this.#unsupportedKeyrings.push(serialized); + return undefined; + } + } + + /** + * Destroy Keyring + * + * Some keyrings support a method called `destroy`, that destroys the + * keyring along with removing all its event listeners and, in some cases, + * clears the keyring bridge iframe from the DOM. + * + * @param keyring - The keyring to destroy. + * @param keyringV2 - The keyring v2 to destroy (if any). + */ + async #destroyKeyring( + keyring: EthKeyring, + keyringV2?: KeyringV2, + ): Promise { + await keyring.destroy?.(); + if (keyringV2) { + await keyringV2.destroy?.(); + } + } + + /** + * Assert that there are no duplicate accounts in the keyrings. + * + * @param additionalKeyrings - Additional keyrings to include in the check. + * @throws If there are duplicate accounts. + */ + async #assertNoDuplicateAccounts( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings); + + if (new Set(accounts).size !== accounts.length) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.DuplicatedAccount, + ); + } + } + + /** + * Set the `isUnlocked` to true and notify listeners + * through the messenger. + * + * @fires KeyringController:unlock + */ + #setUnlocked(): void { + this.#assertControllerMutexIsLocked(); + + this.update((state) => { + state.isUnlocked = true; + }); + this.messenger.publish(`${name}:unlock`); + } + + /** + * Assert that the controller is unlocked. + * + * @throws If the controller is locked. + */ + #assertIsUnlocked(): void { + if (!this.state.isUnlocked) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLocked, + ); + } + } + + /** + * Execute the given function after acquiring the controller lock + * and save the vault to state after it (only if needed), or rollback to their + * previous state in case of error. + * + * @param callback - The function to execute. + * @returns The result of the function. + */ + async #persistOrRollback( + callback: MutuallyExclusiveCallback, + ): Promise { + return this.#withRollback(async ({ releaseLock }) => { + const oldState = JSON.stringify(await this.#getSessionState()); + const callbackResult = await callback({ releaseLock }); + const newState = JSON.stringify(await this.#getSessionState()); + + // State is committed only if the operation is successful and need to trigger a vault update. + if (oldState !== newState) { + await this.#updateVault(); + } + + return callbackResult; + }); + } + + /** + * Execute the given function after acquiring the controller lock + * and rollback keyrings and password states in case of error. + * + * @param callback - The function to execute atomically. + * @returns The result of the function. + */ + async #withRollback( + callback: MutuallyExclusiveCallback, + ): Promise { + return this.#withControllerLock(async ({ releaseLock }) => { + const currentSerializedKeyrings = await this.#getSerializedKeyrings(); + const currentEncryptionKey = cloneDeep(this.#encryptionKey); + + try { + return await callback({ releaseLock }); + } catch (error) { + // Keyrings and encryption credentials are restored to their previous state + this.#encryptionKey = currentEncryptionKey; + await this.#restoreSerializedKeyrings(currentSerializedKeyrings); + + throw error; + } + }); + } + + /** + * Assert that the controller mutex is locked. + * + * @throws If the controller mutex is not locked. + */ + #assertControllerMutexIsLocked(): void { + if (!this.#controllerOperationMutex.isLocked()) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLockRequired, + ); + } + } + + /** + * Check whether the given keyring entry is the primary keyring. + * + * The primary keyring is the first HD keyring in the given list. Both the + * position (index 0) and the keyring type are checked so that the definition + * of "primary" lives in one place and does not rely on positional index + * alone, which could misidentify the primary keyring in the event of a bug. + * + * @param entry - The keyring entry to check. + * @param keyrings - The list of keyring entries `entry` belongs to. + * @returns Whether the entry is the primary keyring. + */ + #isPrimaryKeyring(entry: KeyringEntry, keyrings: KeyringEntry[]): boolean { + return ( + keyrings[0] === entry && + entry.keyring.type === (KeyringTypes.hd as string) + ); + } + + /** + * Assert that the given keyring entry is not the primary HD keyring. + * + * @param entry - The keyring entry to check. + * @param keyrings - The current list of keyring entries. + * @throws If the entry is the primary keyring. + */ + #assertNotRemovingPrimaryKeyring( + entry: KeyringEntry, + keyrings: KeyringEntry[], + ): void { + if (this.#isPrimaryKeyring(entry, keyrings)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.CannotRemovePrimaryKeyring, + ); + } + } + + /** + * Lock the controller mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This wrapper ensures that each mutable operation that interacts with the + * controller and that changes its state is executed in a mutually exclusive way, + * preventing unsafe concurrent access that could lead to unpredictable behavior. + * + * @param callback - The function to execute while the controller mutex is locked. + * @returns The result of the function. + */ + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return withLock(this.#controllerOperationMutex, callback); + } + + /** + * Lock the vault mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This ensures that each operation that interacts with the vault + * is executed in a mutually exclusive way. + * + * @param callback - The function to execute while the vault mutex is locked. + * @returns The result of the function. + */ + async #withVaultLock( + callback: MutuallyExclusiveCallback, + ): Promise { + this.#assertControllerMutexIsLocked(); + + return withLock(this.#vaultOperationMutex, callback); + } +} + +/** + * Lock the given mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * @param mutex - The mutex to lock. + * @param callback - The function to execute while the mutex is locked. + * @returns The result of the function. + */ +async function withLock( + mutex: Mutex, + callback: MutuallyExclusiveCallback, +): Promise { + const releaseLock = await mutex.acquire(); + + try { + return await callback({ releaseLock }); + } finally { + releaseLock(); + } +} + +/** + * Generate a new keyring metadata object. + * + * @returns Keyring metadata. + */ +function getDefaultKeyringMetadata(): KeyringMetadata { + return { id: ulid(), name: '' }; +} + +export default KeyringController; diff --git a/packages/signature-controller/src/SignatureController.ts b/packages/signature-controller/src/SignatureController.ts index d5f2de6961e..562f546ebe9 100644 --- a/packages/signature-controller/src/SignatureController.ts +++ b/packages/signature-controller/src/SignatureController.ts @@ -1,1099 +1,1106 @@ -import type { AccountsControllerGetStateAction } from '@metamask/accounts-controller'; -import type { - ApprovalControllerAddRequestAction, - AcceptResultCallbacks, - AddResult, -} from '@metamask/approval-controller'; -import { BaseController } from '@metamask/base-controller'; -import type { - ControllerGetStateAction, - ControllerStateChangeEvent, -} from '@metamask/base-controller'; -import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; -import { - ApprovalType, - detectSIWE, - ORIGIN_METAMASK, -} from '@metamask/controller-utils'; -import type { - GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction, - DecodedPermission, -} from '@metamask/gator-permissions-controller'; -import type { - KeyringControllerSignMessageAction, - KeyringControllerSignPersonalMessageAction, - KeyringControllerSignTypedMessageAction, -} from '@metamask/keyring-controller'; -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import { - SigningMethod, - LogType, - SigningStage, -} from '@metamask/logging-controller'; -import type { LoggingControllerAddAction } from '@metamask/logging-controller'; -import type { Messenger } from '@metamask/messenger'; -import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; -import type { Hex, Json } from '@metamask/utils'; -// This package purposefully relies on Node's EventEmitter module. -// eslint-disable-next-line import-x/no-nodejs-modules -import EventEmitter from 'events'; -import { v1 as random } from 'uuid'; - -import { projectLogger as log } from './logger.js'; -import type { SignatureControllerMethodActions } from './SignatureController-method-action-types.js'; -import { SignatureRequestStatus, SignatureRequestType } from './types.js'; -import type { - MessageParamsPersonal, - MessageParamsTyped, - OriginalRequest, - SignatureRequest, - MessageParams, - TypedSigningOptions, - LegacyStateMessage, - StateSIWEMessage, - MessageParamsTypedData, -} from './types.js'; -import { DECODING_API_ERRORS, decodeSignature } from './utils/decoding-api.js'; -import { - decodePermissionFromRequest, - isDelegationRequest, - validateExecutionPermissionMetadata, -} from './utils/delegations.js'; -import { - normalizePersonalMessageParams, - normalizeTypedMessageParams, -} from './utils/normalize.js'; -import { - validatePersonalSignatureRequest, - validateTypedSignatureRequest, -} from './utils/validation.js'; - -const controllerName = 'SignatureController'; - -const MESSENGER_EXPOSED_METHODS = [ - 'clearUnapproved', - 'newUnsignedPersonalMessage', - 'newUnsignedTypedMessage', - 'rejectUnapproved', - 'resetState', - 'setDeferredSignError', - 'setDeferredSignSuccess', - 'setMessageMetadata', - 'setPersonalMessageInProgress', - 'setTypedMessageInProgress', -] as const; - -const stateMetadata = { - signatureRequests: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - unapprovedPersonalMsgs: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - unapprovedTypedMessages: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - unapprovedPersonalMsgCount: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, - unapprovedTypedMessagesCount: { - includeInStateLogs: true, - persist: false, - includeInDebugSnapshot: false, - usedInUi: true, - }, -}; - -const getDefaultState = () => ({ - signatureRequests: {}, - unapprovedPersonalMsgs: {}, - unapprovedTypedMessages: {}, - unapprovedPersonalMsgCount: 0, - unapprovedTypedMessagesCount: 0, -}); - -/** List of statuses that will not be updated and trigger the finished event. */ -const FINAL_STATUSES: SignatureRequestStatus[] = [ - SignatureRequestStatus.Signed, - SignatureRequestStatus.Rejected, - SignatureRequestStatus.Errored, -]; - -export type SignatureControllerState = { - /** - * Map of all signature requests including all types and statuses, keyed by ID. - */ - signatureRequests: Record; - - /** - * Map of personal messages with the unapproved status, keyed by ID. - * - * @deprecated - Use `signatureRequests` instead. - */ - unapprovedPersonalMsgs: Record; - - /** - * Map of typed messages with the unapproved status, keyed by ID. - * - * @deprecated - Use `signatureRequests` instead. - */ - unapprovedTypedMessages: Record; - - /** - * Number of unapproved personal messages. - * - * @deprecated - Use `signatureRequests` instead. - */ - unapprovedPersonalMsgCount: number; - - /** - * Number of unapproved typed messages. - * - * @deprecated - Use `signatureRequests` instead. - */ - unapprovedTypedMessagesCount: number; -}; - -type AllowedActions = - | AccountsControllerGetStateAction - | ApprovalControllerAddRequestAction - | LoggingControllerAddAction - | GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction - | NetworkControllerGetNetworkClientByIdAction - | KeyringControllerSignMessageAction - | KeyringControllerSignPersonalMessageAction - | KeyringControllerSignTypedMessageAction; - -export type GetSignatureState = ControllerGetStateAction< - typeof controllerName, - SignatureControllerState ->; - -export type SignatureStateChange = ControllerStateChangeEvent< - typeof controllerName, - SignatureControllerState ->; - -export type SignatureControllerActions = - | GetSignatureState - | SignatureControllerMethodActions; - -export type SignatureControllerEvents = SignatureStateChange; - -export type SignatureControllerMessenger = Messenger< - typeof controllerName, - SignatureControllerActions | AllowedActions, - SignatureControllerEvents ->; - -export type SignatureControllerOptions = { - /** - * Restricted messenger required by the signature controller. - */ - messenger: SignatureControllerMessenger; - - /** - * @deprecated No longer in use. - */ - securityProviderRequest?: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requestData: any, - methodName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Promise; - - /** - * URL of API to retrieve decoding data for typed requests. - */ - decodingApiUrl?: string; - - /** - * Function to check if decoding signature request is enabled - */ - isDecodeSignatureRequestEnabled?: () => boolean; - - /** - * Initial state of the controller. - */ - state?: SignatureControllerState; - - /** - * Callback to record the duration of code. - */ - trace?: TraceCallback; -}; - -/** - * Controller for creating signing requests requiring user approval. - */ -export class SignatureController extends BaseController< - typeof controllerName, - SignatureControllerState, - SignatureControllerMessenger -> { - hub: EventEmitter; - - readonly #decodingApiUrl?: string; - - readonly #isDecodeSignatureRequestEnabled?: () => boolean; - - readonly #trace: TraceCallback; - - /** - * Construct a Sign controller. - * - * @param options - The controller options. - * @param options.decodingApiUrl - Api used to get decoded data for permits. - * @param options.isDecodeSignatureRequestEnabled - Function to check is decoding signature request is enabled. - * @param options.messenger - The restricted messenger for the sign controller. - * @param options.state - Initial state to set on this controller. - * @param options.trace - Callback to generate trace information. - */ - constructor({ - decodingApiUrl, - isDecodeSignatureRequestEnabled, - messenger, - state, - trace, - }: SignatureControllerOptions) { - super({ - name: controllerName, - metadata: stateMetadata, - messenger, - state: { - ...getDefaultState(), - ...state, - }, - }); - - this.hub = new EventEmitter(); - - this.messenger.registerMethodActionHandlers( - this, - MESSENGER_EXPOSED_METHODS, - ); - - this.#trace = trace ?? (((_request, fn) => fn?.()) as TraceCallback); - this.#decodingApiUrl = decodingApiUrl; - this.#isDecodeSignatureRequestEnabled = isDecodeSignatureRequestEnabled; - } - - /** - * A getter for the number of 'unapproved' PersonalMessages in this.messages. - * - * @deprecated Use `signatureRequests` state instead. - * @returns The number of 'unapproved' PersonalMessages in this.messages - */ - get unapprovedPersonalMessagesCount(): number { - return this.state.unapprovedPersonalMsgCount; - } - - /** - * A getter for the number of 'unapproved' TypedMessages in this.messages. - * - * @deprecated Use `signatureRequests` state instead. - * @returns The number of 'unapproved' TypedMessages in this.messages - */ - get unapprovedTypedMessagesCount(): number { - return this.state.unapprovedTypedMessagesCount; - } - - /** - * A getter for returning all messages. - * - * @deprecated Use `signatureRequests` state instead. - * @returns The object containing all messages. - */ - get messages(): { [id: string]: SignatureRequest } { - return this.state.signatureRequests; - } - - /** - * Reset the controller state to the initial state. - */ - resetState() { - this.#updateState((state) => { - Object.assign(state, getDefaultState()); - }); - } - - /** - * Reject all unapproved messages of any type. - * - * @param reason - A message to indicate why. - */ - rejectUnapproved(reason?: string) { - const unapprovedSignatureRequests = Object.values( - this.state.signatureRequests, - ).filter( - (metadata) => - (metadata.status as SignatureRequestStatus) === - SignatureRequestStatus.Unapproved, - ); - - for (const metadata of unapprovedSignatureRequests) { - this.#rejectSignatureRequest(metadata.id, reason); - } - } - - /** - * Clears all unapproved messages from memory. - */ - clearUnapproved() { - this.#updateState((state) => { - Object.values(state.signatureRequests) - .filter( - (metadata) => - (metadata.status as SignatureRequestStatus) === - SignatureRequestStatus.Unapproved, - ) - .forEach((metadata) => delete state.signatureRequests[metadata.id]); - }); - } - - /** - * Called when a dApp uses the personal_sign method. - * We currently provide personal_sign mostly for legacy dApps. - * - * @param messageParams - The params of the message to sign and return to the dApp. - * @param request - The original request, containing the origin. - * @param options - An options bag for the method. - * @param options.traceContext - The parent context for any new traces. - * @returns Promise resolving to the raw signature hash generated from the signature request. - */ - async newUnsignedPersonalMessage( - messageParams: MessageParamsPersonal, - request: OriginalRequest, - options: { traceContext?: TraceContext } = {}, - ): Promise { - validatePersonalSignatureRequest(messageParams); - - const normalizedMessageParams = - normalizePersonalMessageParams(messageParams); - - normalizedMessageParams.siwe = detectSIWE( - messageParams, - ) as StateSIWEMessage; - - return this.#processSignatureRequest({ - messageParams: normalizedMessageParams, - request, - type: SignatureRequestType.PersonalSign, - approvalType: ApprovalType.PersonalSign, - traceContext: options.traceContext, - }); - } - - /** - * Called when a dapp uses the eth_signTypedData method, per EIP-712. - * - * @param messageParams - The params of the message to sign and return to the dApp. - * @param request - The original request, containing the origin. - * @param versionString - The version of the signTypedData request. - * @param signingOptions - Options for signing the typed message. - * @param options - An options bag for the method. - * @param options.traceContext - The parent context for any new traces. - * @returns Promise resolving to the raw signature hash generated from the signature request. - */ - async newUnsignedTypedMessage( - messageParams: MessageParamsTyped, - request: OriginalRequest, - versionString: string, - signingOptions?: TypedSigningOptions, - options: { traceContext?: TraceContext } = {}, - ): Promise { - const chainId = this.#getChainId(request); - const internalAccounts = this.#getInternalAccounts(); - - const version = versionString as SignTypedDataVersion; - - const decodedPermission = this.#tryGetDecodedPermissionIfDelegation({ - messageParams, - version, - request, - }); - - validateTypedSignatureRequest({ - currentChainId: chainId, - internalAccounts, - messageData: messageParams, - request, - version, - decodedPermission, - }); - - const normalizedMessageParams = normalizeTypedMessageParams( - messageParams, - version, - ); - - return this.#processSignatureRequest({ - approvalType: ApprovalType.EthSignTypedData, - messageParams: normalizedMessageParams, - request, - signingOptions, - traceContext: options.traceContext, - type: SignatureRequestType.TypedSign, - version, - decodedPermission, - }); - } - - /** - * Attempts to decoded a permission if the request is a delegation request. - * - * @param args - The arguments for the method. - * @param args.messageParams - The message parameters. - * @param args.version - The version of the signTypedData request. - * @param args.request - The original request. - * - * @returns The decoded permission if the request is a delegation request. - */ - #tryGetDecodedPermissionIfDelegation({ - messageParams, - version, - request, - }: { - messageParams: MessageParamsTyped; - version: SignTypedDataVersion; - request: OriginalRequest; - }): DecodedPermission | undefined { - let data: MessageParamsTypedData; - try { - data = this.#parseTypedData(messageParams, version) - .data as MessageParamsTypedData; - } catch (error) { - log('Failed to parse typed data', error); - return undefined; - } - - const isRequestDelegationRequest = isDelegationRequest(data); - - if ( - !isRequestDelegationRequest || - !request.origin || - version !== SignTypedDataVersion.V4 - ) { - return undefined; - } - - let decodedPermission: DecodedPermission | undefined; - - try { - validateExecutionPermissionMetadata(data); - - decodedPermission = decodePermissionFromRequest({ - origin: request.origin, - data, - messenger: this.messenger, - }); - } catch (error) { - // we ignore this error, because it simply means the request could not be - // decoded into a permission in which case we will not set a - // decodedPermission on the metadata, and may fail validation if the - // request is invalid. - log('Failed to decode permission', (error as Error).message); - } - - return decodedPermission; - } - - /** - * Provide a signature for a pending signature request that used `deferSetAsSigned`. - * Changes the status of the signature request to `signed`. - * - * @param signatureRequestId - The ID of the signature request. - * @param signature - The signature to provide. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setDeferredSignSuccess(signatureRequestId: string, signature: any) { - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.rawSig = signature; - draftMetadata.status = - SignatureRequestStatus.Signed as SignatureRequestStatus; - }); - } - - /** - * Set custom metadata on a signature request. - * - * @param signatureRequestId - The ID of the signature request. - * @param metadata - The custom metadata to set. - */ - setMessageMetadata(signatureRequestId: string, metadata: Json) { - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.metadata = metadata; - }); - } - - /** - * Reject a pending signature request that used `deferSetAsSigned`. - * Changes the status of the signature request to `rejected`. - * - * @param signatureRequestId - The ID of the signature request. - */ - setDeferredSignError(signatureRequestId: string) { - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.status = SignatureRequestStatus.Rejected; - }); - } - - /** - * Set the status of a signature request to 'inProgress'. - * - * @param signatureRequestId - The ID of the signature request. - */ - setTypedMessageInProgress(signatureRequestId: string) { - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.status = SignatureRequestStatus.InProgress; - }); - } - - /** - * Set the status of a signature request to 'inProgress'. - * - * @param signatureRequestId - The ID of the signature request. - */ - setPersonalMessageInProgress(signatureRequestId: string) { - this.setTypedMessageInProgress(signatureRequestId); - } - - #parseTypedData( - messageParams: MessageParamsTyped, - version?: SignTypedDataVersion, - ): MessageParamsTyped { - if ( - ![SignTypedDataVersion.V3, SignTypedDataVersion.V4].includes( - version as SignTypedDataVersion, - ) || - typeof messageParams.data !== 'string' - ) { - return messageParams; - } - - return { - ...messageParams, - data: JSON.parse(messageParams.data), - }; - } - - async #processSignatureRequest({ - chainId: optionChainId, - messageParams, - request, - type, - approvalType, - version, - signingOptions, - traceContext, - decodedPermission, - }: { - chainId?: Hex; - messageParams: MessageParams; - request: OriginalRequest; - type: SignatureRequestType; - approvalType: ApprovalType; - version?: SignTypedDataVersion; - signingOptions?: TypedSigningOptions; - traceContext?: TraceContext; - decodedPermission?: DecodedPermission; - }): Promise { - log('Processing signature request', { - messageParams, - request, - type, - version, - }); - - const chainId = optionChainId ?? this.#getChainId(request); - - this.#addLog(type, version, SigningStage.Proposed, messageParams); - - const metadata = this.#addMetadata({ - chainId, - messageParams, - request, - signingOptions, - type, - version, - decodedPermission, - }); - - let resultCallbacks: AcceptResultCallbacks | undefined; - let approveOrSignError: unknown; - - const finalMetadataPromise = this.#waitForFinished(metadata.id); - this.#decodePermitSignatureRequest(metadata.id, request, chainId); - - try { - resultCallbacks = await this.#processApproval({ - approvalType, - metadata, - request, - traceContext, - }); - - await this.#approveAndSignRequest(metadata, traceContext); - } catch (error) { - log('Signature request failed', (error as Error).message); - approveOrSignError = error; - } - - const finalMetadata = await finalMetadataPromise; - - const { - error, - id, - messageParams: finalMessageParams, - rawSig: signature, - } = finalMetadata; - - switch (finalMetadata.status) { - case SignatureRequestStatus.Signed: - log('Signature request finished', { id, signature }); - this.#addLog(type, version, SigningStage.Signed, finalMessageParams); - resultCallbacks?.success(signature); - return finalMetadata.rawSig as string; - - case SignatureRequestStatus.Rejected: - /* istanbul ignore next */ - const rejectedError = (approveOrSignError ?? - new Error( - `MetaMask ${type} Signature: User denied message signature.`, - )) as Error; - - resultCallbacks?.error(rejectedError); - throw rejectedError; - - case SignatureRequestStatus.Errored: - /* istanbul ignore next */ - const erroredError = (approveOrSignError ?? - new Error(`MetaMask ${type} Signature: ${error as string}`)) as Error; - - resultCallbacks?.error(erroredError); - throw erroredError; - - /* istanbul ignore next */ - default: - throw new Error( - `MetaMask ${type} Signature: Unknown problem: ${JSON.stringify( - finalMessageParams, - )}`, - ); - } - } - - #addMetadata({ - chainId, - messageParams, - request, - signingOptions, - type, - version, - decodedPermission, - }: { - chainId: Hex; - messageParams: MessageParams; - request?: OriginalRequest; - signingOptions?: TypedSigningOptions; - type: SignatureRequestType; - version?: SignTypedDataVersion; - decodedPermission?: DecodedPermission; - }): SignatureRequest { - const id = random(); - const origin = request?.origin ?? messageParams.origin; - const requestId = request?.id; - const securityAlertResponse = request?.securityAlertResponse; - const networkClientId = request?.networkClientId; - - const finalMessageParams = { - ...messageParams, - metamaskId: id, - origin, - requestId, - version, - }; - - const metadata = { - chainId, - id, - messageParams: finalMessageParams, - networkClientId, - securityAlertResponse, - signingOptions, - status: SignatureRequestStatus.Unapproved, - time: Date.now(), - type, - version, - decodedPermission, - } as SignatureRequest; - - this.#updateState((state) => { - state.signatureRequests[metadata.id] = metadata; - }); - - log('Added signature request', metadata); - - this.hub.emit('unapprovedMessage', { - messageParams, - metamaskId: metadata.id, - }); - - return metadata; - } - - async #processApproval({ - approvalType, - metadata, - request, - traceContext, - }: { - approvalType: ApprovalType; - metadata: SignatureRequest; - request?: OriginalRequest; - traceContext?: TraceContext; - }): Promise { - const { id, messageParams, type, version } = metadata; - - try { - const acceptResult = await this.#trace( - { name: 'Await Approval', parentContext: traceContext }, - (context) => - this.#requestApproval(metadata, approvalType, { - traceContext: context, - actionId: request?.id?.toString(), - }), - ); - - return acceptResult.resultCallbacks; - } catch (error) { - log('User rejected request', { id, error }); - - this.#addLog(type, version, SigningStage.Rejected, messageParams); - this.#rejectSignatureRequest(id); - - throw error; - } - } - - async #approveAndSignRequest( - metadata: SignatureRequest, - traceContext?: TraceContext, - ) { - const { id } = metadata; - - this.#updateMetadata(id, (draftMetadata) => { - draftMetadata.status = SignatureRequestStatus.Approved; - }); - - await this.#trace({ name: 'Sign', parentContext: traceContext }, () => - this.#signRequest(metadata), - ); - } - - async #signRequest(metadata: SignatureRequest) { - const { id, messageParams, signingOptions, type } = metadata; - - try { - let signature: string; - - switch (type) { - case SignatureRequestType.PersonalSign: - signature = await this.messenger.call( - 'KeyringController:signPersonalMessage', - // Keyring controller temporarily using message manager types. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - messageParams as any, - ); - break; - - case SignatureRequestType.TypedSign: - const finalRequest = signingOptions?.parseJsonData - ? this.#parseTypedData(messageParams, metadata.version) - : messageParams; - - signature = await this.messenger.call( - 'KeyringController:signTypedMessage', - finalRequest, - metadata.version as SignTypedDataVersion, - ); - break; - - /* istanbul ignore next */ - default: - throw new Error(`Unknown signature request type: ${type as string}`); - } - - this.hub.emit(`${type}:signed`, { signature, messageId: id }); - - if (messageParams.deferSetAsSigned) { - return; - } - - this.#updateMetadata(id, (draftMetadata) => { - draftMetadata.rawSig = signature; - draftMetadata.status = SignatureRequestStatus.Signed; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - if (type === SignatureRequestType.TypedSign) { - this.#errorSignatureRequest(id, error.message); - } else { - this.#rejectSignatureRequest(id); - } - - this.hub.emit(`${id}:signError`, { error }); - - throw error; - } - } - - #errorSignatureRequest(id: string, error: string) { - this.#updateMetadata(id, (draftMetadata) => { - draftMetadata.status = SignatureRequestStatus.Errored; - draftMetadata.error = error; - }); - } - - #rejectSignatureRequest(signatureRequestId: string, reason?: string) { - if (reason) { - const metadata = this.state.signatureRequests[signatureRequestId]; - this.hub.emit('cancelWithReason', { metadata, reason }); - } - - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.status = SignatureRequestStatus.Rejected; - }); - } - - async #waitForFinished(id: string): Promise { - return new Promise((resolve) => { - this.hub.once(`${id}:finished`, (metadata: SignatureRequest) => { - resolve(metadata); - }); - }); - } - - async #requestApproval( - metadata: SignatureRequest, - type: ApprovalType, - { - traceContext, - actionId, - }: { traceContext?: TraceContext; actionId?: string }, - ): Promise { - const { id, messageParams } = metadata; - const origin = messageParams.origin || ORIGIN_METAMASK; - - await this.#trace({ - name: 'Notification Display', - id: actionId, - parentContext: traceContext, - }); - - return (await this.messenger.call( - 'ApprovalController:addRequest', - { - id, - origin, - type, - requestData: { ...messageParams }, - expectsResult: true, - }, - true, - )) as Promise; - } - - #updateMetadata( - id: string, - callback: (metadata: SignatureRequest) => void, - ): SignatureRequest { - let statusChanged = false; - - const { nextState } = this.#updateState((state) => { - const metadata = state.signatureRequests[id]; - - if (!metadata) { - throw new Error(`Signature request with id ${id} not found`); - } - - const originalStatus = metadata.status; - - callback(metadata); - - statusChanged = metadata.status !== originalStatus; - }); - - const updatedMetadata = nextState.signatureRequests[id]; - - if ( - statusChanged && - FINAL_STATUSES.includes(updatedMetadata.status as SignatureRequestStatus) - ) { - this.hub.emit(`${id}:finished`, updatedMetadata); - } - - return updatedMetadata; - } - - #updateState(callback: (state: SignatureControllerState) => void) { - return this.update((state) => { - callback(state as unknown as SignatureControllerState); - - const unapprovedRequests = Object.values(state.signatureRequests).filter( - (request) => request.status === SignatureRequestStatus.Unapproved, - ) as unknown as SignatureRequest[]; - - const personalSignMessages = this.#generateLegacyState( - unapprovedRequests, - SignatureRequestType.PersonalSign, - ); - - const typedSignMessages = this.#generateLegacyState( - unapprovedRequests, - SignatureRequestType.TypedSign, - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state.unapprovedPersonalMsgs = personalSignMessages as any; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state.unapprovedTypedMessages = typedSignMessages as any; - - state.unapprovedPersonalMsgCount = - Object.values(personalSignMessages).length; - - state.unapprovedTypedMessagesCount = - Object.values(typedSignMessages).length; - }); - } - - #generateLegacyState( - signatureRequests: SignatureRequest[], - type: SignatureRequestType, - ): Record { - return signatureRequests - .filter((request) => request.type === type) - .reduce>( - (acc, request) => ({ - ...acc, - [request.id]: { ...request, msgParams: request.messageParams }, - }), - {}, - ); - } - - #addLog( - signatureRequestType: SignatureRequestType, - version: SignTypedDataVersion | undefined, - stage: SigningStage, - signingData: MessageParams, - ): void { - const signingMethod = this.#getSignTypeForLogger( - signatureRequestType, - version, - ); - - this.messenger.call('LoggingController:add', { - type: LogType.EthSignLog, - data: { - signingMethod, - stage, - signingData, - }, - }); - } - - #getSignTypeForLogger( - requestType: SignatureRequestType, - version?: SignTypedDataVersion, - ): SigningMethod { - if (requestType === SignatureRequestType.PersonalSign) { - return SigningMethod.PersonalSign; - } - - if ( - requestType === SignatureRequestType.TypedSign && - version === SignTypedDataVersion.V3 - ) { - return SigningMethod.EthSignTypedDataV3; - } - - if ( - requestType === SignatureRequestType.TypedSign && - version === SignTypedDataVersion.V4 - ) { - return SigningMethod.EthSignTypedDataV4; - } - - return SigningMethod.EthSignTypedData; - } - - #getChainId(request: OriginalRequest): Hex { - const { networkClientId } = request; - - if (!networkClientId) { - throw new Error('Network client ID not found in request'); - } - - const networkClient = this.messenger.call( - 'NetworkController:getNetworkClientById', - networkClientId, - ); - - return networkClient.configuration.chainId; - } - - #decodePermitSignatureRequest( - signatureRequestId: string, - request: OriginalRequest, - chainId: string, - ) { - if (!this.#isDecodeSignatureRequestEnabled?.() || !this.#decodingApiUrl) { - return; - } - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.decodingLoading = true; - }); - decodeSignature(request, chainId, this.#decodingApiUrl) - .then((decodingData) => - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.decodingData = decodingData; - draftMetadata.decodingLoading = false; - }), - ) - .catch((error) => - this.#updateMetadata(signatureRequestId, (draftMetadata) => { - draftMetadata.decodingData = { - stateChanges: null, - error: { - message: (error as unknown as Error).message, - type: DECODING_API_ERRORS.DECODING_FAILED_WITH_ERROR, - }, - }; - draftMetadata.decodingLoading = false; - }), - ); - } - - #getInternalAccounts(): Hex[] { - const state = this.messenger.call('AccountsController:getState'); - - /* istanbul ignore next */ - return Object.values(state.internalAccounts?.accounts ?? {}) - .filter((account) => account.type === 'eip155:eoa') - .map((account) => account.address as Hex); - } -} +import type { AccountsControllerGetStateAction } from '@metamask/accounts-controller'; +import type { + ApprovalControllerAddRequestAction, + AcceptResultCallbacks, + AddResult, +} from '@metamask/approval-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; +import { + ApprovalType, + detectSIWE, + ORIGIN_METAMASK, +} from '@metamask/controller-utils'; +import type { + GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction, + DecodedPermission, +} from '@metamask/gator-permissions-controller'; +import type { + KeyringControllerSignMessageAction, + KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTypedMessageAction, +} from '@metamask/keyring-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { + SigningMethod, + LogType, + SigningStage, +} from '@metamask/logging-controller'; +import type { LoggingControllerAddAction } from '@metamask/logging-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; +import type { Hex, Json } from '@metamask/utils'; +// This package purposefully relies on Node's EventEmitter module. +// eslint-disable-next-line import-x/no-nodejs-modules +import EventEmitter from 'events'; +import { v1 as random } from 'uuid'; + +import { projectLogger as log } from './logger.js'; +import type { SignatureControllerMethodActions } from './SignatureController-method-action-types.js'; +import { SignatureRequestStatus, SignatureRequestType } from './types.js'; +import type { + MessageParamsPersonal, + MessageParamsTyped, + OriginalRequest, + SignatureRequest, + MessageParams, + TypedSigningOptions, + LegacyStateMessage, + StateSIWEMessage, + MessageParamsTypedData, +} from './types.js'; +import { DECODING_API_ERRORS, decodeSignature } from './utils/decoding-api.js'; +import { + decodePermissionFromRequest, + isDelegationRequest, + validateExecutionPermissionMetadata, +} from './utils/delegations.js'; +import { + normalizePersonalMessageParams, + normalizeTypedMessageParams, +} from './utils/normalize.js'; +import { + validatePersonalSignatureRequest, + validateTypedSignatureRequest, +} from './utils/validation.js'; + +const controllerName = 'SignatureController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'clearUnapproved', + 'newUnsignedPersonalMessage', + 'newUnsignedTypedMessage', + 'rejectUnapproved', + 'resetState', + 'setDeferredSignError', + 'setDeferredSignSuccess', + 'setMessageMetadata', + 'setPersonalMessageInProgress', + 'setTypedMessageInProgress', +] as const; + +const stateMetadata = { + signatureRequests: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + unapprovedPersonalMsgs: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + unapprovedTypedMessages: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + unapprovedPersonalMsgCount: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + unapprovedTypedMessagesCount: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +const getDefaultState = () => ({ + signatureRequests: {}, + unapprovedPersonalMsgs: {}, + unapprovedTypedMessages: {}, + unapprovedPersonalMsgCount: 0, + unapprovedTypedMessagesCount: 0, +}); + +/** List of statuses that will not be updated and trigger the finished event. */ +const FINAL_STATUSES: SignatureRequestStatus[] = [ + SignatureRequestStatus.Signed, + SignatureRequestStatus.Rejected, + SignatureRequestStatus.Errored, +]; + +export type SignatureControllerState = { + /** + * Map of all signature requests including all types and statuses, keyed by ID. + */ + signatureRequests: Record; + + /** + * Map of personal messages with the unapproved status, keyed by ID. + * + * @deprecated - Use `signatureRequests` instead. + */ + unapprovedPersonalMsgs: Record; + + /** + * Map of typed messages with the unapproved status, keyed by ID. + * + * @deprecated - Use `signatureRequests` instead. + */ + unapprovedTypedMessages: Record; + + /** + * Number of unapproved personal messages. + * + * @deprecated - Use `signatureRequests` instead. + */ + unapprovedPersonalMsgCount: number; + + /** + * Number of unapproved typed messages. + * + * @deprecated - Use `signatureRequests` instead. + */ + unapprovedTypedMessagesCount: number; +}; + +type AllowedActions = + | AccountsControllerGetStateAction + | ApprovalControllerAddRequestAction + | LoggingControllerAddAction + | GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction + | NetworkControllerGetNetworkClientByIdAction + | KeyringControllerSignMessageAction + | KeyringControllerSignPersonalMessageAction + | KeyringControllerSignTypedMessageAction; + +export type GetSignatureState = ControllerGetStateAction< + typeof controllerName, + SignatureControllerState +>; + +export type SignatureStateChange = ControllerStateChangeEvent< + typeof controllerName, + SignatureControllerState +>; + +export type SignatureControllerActions = + | GetSignatureState + | SignatureControllerMethodActions; + +export type SignatureControllerEvents = SignatureStateChange; + +export type SignatureControllerMessenger = Messenger< + typeof controllerName, + SignatureControllerActions | AllowedActions, + SignatureControllerEvents +>; + +export type SignatureControllerOptions = { + /** + * Restricted messenger required by the signature controller. + */ + messenger: SignatureControllerMessenger; + + /** + * @deprecated No longer in use. + */ + securityProviderRequest?: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + requestData: any, + methodName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Promise; + + /** + * URL of API to retrieve decoding data for typed requests. + */ + decodingApiUrl?: string; + + /** + * Function to check if decoding signature request is enabled + */ + isDecodeSignatureRequestEnabled?: () => boolean; + + /** + * Initial state of the controller. + */ + state?: SignatureControllerState; + + /** + * Callback to record the duration of code. + */ + trace?: TraceCallback; +}; + +/** + * Controller for creating signing requests requiring user approval. + */ +export class SignatureController extends BaseController< + typeof controllerName, + SignatureControllerState, + SignatureControllerMessenger +> { + hub: EventEmitter; + + readonly #decodingApiUrl?: string; + + readonly #isDecodeSignatureRequestEnabled?: () => boolean; + + readonly #trace: TraceCallback; + + /** + * Construct a Sign controller. + * + * @param options - The controller options. + * @param options.decodingApiUrl - Api used to get decoded data for permits. + * @param options.isDecodeSignatureRequestEnabled - Function to check is decoding signature request is enabled. + * @param options.messenger - The restricted messenger for the sign controller. + * @param options.state - Initial state to set on this controller. + * @param options.trace - Callback to generate trace information. + */ + constructor({ + decodingApiUrl, + isDecodeSignatureRequestEnabled, + messenger, + state, + trace, + }: SignatureControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + ...getDefaultState(), + ...state, + }, + }); + + this.hub = new EventEmitter(); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#trace = trace ?? (((_request, fn) => fn?.()) as TraceCallback); + this.#decodingApiUrl = decodingApiUrl; + this.#isDecodeSignatureRequestEnabled = isDecodeSignatureRequestEnabled; + } + + /** + * A getter for the number of 'unapproved' PersonalMessages in this.messages. + * + * @deprecated Use `signatureRequests` state instead. + * @returns The number of 'unapproved' PersonalMessages in this.messages + */ + get unapprovedPersonalMessagesCount(): number { + return this.state.unapprovedPersonalMsgCount; + } + + /** + * A getter for the number of 'unapproved' TypedMessages in this.messages. + * + * @deprecated Use `signatureRequests` state instead. + * @returns The number of 'unapproved' TypedMessages in this.messages + */ + get unapprovedTypedMessagesCount(): number { + return this.state.unapprovedTypedMessagesCount; + } + + /** + * A getter for returning all messages. + * + * @deprecated Use `signatureRequests` state instead. + * @returns The object containing all messages. + */ + get messages(): { [id: string]: SignatureRequest } { + return this.state.signatureRequests; + } + + /** + * Reset the controller state to the initial state. + */ + resetState() { + this.#updateState((state) => { + Object.assign(state, getDefaultState()); + }); + } + + /** + * Reject all unapproved messages of any type. + * + * @param reason - A message to indicate why. + */ + rejectUnapproved(reason?: string) { + const unapprovedSignatureRequests = Object.values( + this.state.signatureRequests, + ).filter( + (metadata) => + (metadata.status as SignatureRequestStatus) === + SignatureRequestStatus.Unapproved, + ); + + for (const metadata of unapprovedSignatureRequests) { + this.#rejectSignatureRequest(metadata.id, reason); + } + } + + /** + * Clears all unapproved messages from memory. + */ + clearUnapproved() { + this.#updateState((state) => { + Object.values(state.signatureRequests) + .filter( + (metadata) => + (metadata.status as SignatureRequestStatus) === + SignatureRequestStatus.Unapproved, + ) + .forEach((metadata) => delete state.signatureRequests[metadata.id]); + }); + } + + /** + * Called when a dApp uses the personal_sign method. + * We currently provide personal_sign mostly for legacy dApps. + * + * @param messageParams - The params of the message to sign and return to the dApp. + * @param request - The original request, containing the origin. + * @param options - An options bag for the method. + * @param options.traceContext - The parent context for any new traces. + * @returns Promise resolving to the raw signature hash generated from the signature request. + */ + async newUnsignedPersonalMessage( + messageParams: MessageParamsPersonal, + request: OriginalRequest, + options: { traceContext?: TraceContext } = {}, + ): Promise { + validatePersonalSignatureRequest(messageParams); + + const normalizedMessageParams = + normalizePersonalMessageParams(messageParams); + + normalizedMessageParams.siwe = detectSIWE( + messageParams, + ) as StateSIWEMessage; + + return this.#processSignatureRequest({ + messageParams: normalizedMessageParams, + request, + type: SignatureRequestType.PersonalSign, + approvalType: ApprovalType.PersonalSign, + traceContext: options.traceContext, + }); + } + + /** + * Called when a dapp uses the eth_signTypedData method, per EIP-712. + * + * @param messageParams - The params of the message to sign and return to the dApp. + * @param request - The original request, containing the origin. + * @param versionString - The version of the signTypedData request. + * @param signingOptions - Options for signing the typed message. + * @param options - An options bag for the method. + * @param options.traceContext - The parent context for any new traces. + * @returns Promise resolving to the raw signature hash generated from the signature request. + */ + async newUnsignedTypedMessage( + messageParams: MessageParamsTyped, + request: OriginalRequest, + versionString: string, + signingOptions?: TypedSigningOptions, + options: { traceContext?: TraceContext } = {}, + ): Promise { + const chainId = this.#getChainId(request); + const internalAccounts = this.#getInternalAccounts(); + + const version = versionString as SignTypedDataVersion; + + const decodedPermission = this.#tryGetDecodedPermissionIfDelegation({ + messageParams, + version, + request, + }); + + validateTypedSignatureRequest({ + currentChainId: chainId, + internalAccounts, + messageData: messageParams, + request, + version, + decodedPermission, + }); + + const normalizedMessageParams = normalizeTypedMessageParams( + messageParams, + version, + ); + + return this.#processSignatureRequest({ + approvalType: ApprovalType.EthSignTypedData, + messageParams: normalizedMessageParams, + request, + signingOptions, + traceContext: options.traceContext, + type: SignatureRequestType.TypedSign, + version, + decodedPermission, + }); + } + + /** + * Attempts to decoded a permission if the request is a delegation request. + * + * @param args - The arguments for the method. + * @param args.messageParams - The message parameters. + * @param args.version - The version of the signTypedData request. + * @param args.request - The original request. + * + * @returns The decoded permission if the request is a delegation request. + */ + #tryGetDecodedPermissionIfDelegation({ + messageParams, + version, + request, + }: { + messageParams: MessageParamsTyped; + version: SignTypedDataVersion; + request: OriginalRequest; + }): DecodedPermission | undefined { + let data: MessageParamsTypedData; + try { + data = this.#parseTypedData(messageParams, version) + .data as MessageParamsTypedData; + } catch (error) { + log('Failed to parse typed data', error); + return undefined; + } + + const isRequestDelegationRequest = isDelegationRequest(data); + + if ( + !isRequestDelegationRequest || + !request.origin || + version !== SignTypedDataVersion.V4 + ) { + return undefined; + } + + let decodedPermission: DecodedPermission | undefined; + + try { + validateExecutionPermissionMetadata(data); + + decodedPermission = decodePermissionFromRequest({ + origin: request.origin, + data, + messenger: this.messenger, + }); + } catch (error) { + // we ignore this error, because it simply means the request could not be + // decoded into a permission in which case we will not set a + // decodedPermission on the metadata, and may fail validation if the + // request is invalid. + log('Failed to decode permission', (error as Error).message); + } + + return decodedPermission; + } + + /** + * Provide a signature for a pending signature request that used `deferSetAsSigned`. + * Changes the status of the signature request to `signed`. + * + * @param signatureRequestId - The ID of the signature request. + * @param signature - The signature to provide. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setDeferredSignSuccess(signatureRequestId: string, signature: any) { + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.rawSig = signature; + draftMetadata.status = + SignatureRequestStatus.Signed as SignatureRequestStatus; + }); + } + + /** + * Set custom metadata on a signature request. + * + * @param signatureRequestId - The ID of the signature request. + * @param metadata - The custom metadata to set. + */ + setMessageMetadata(signatureRequestId: string, metadata: Json) { + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.metadata = metadata; + }); + } + + /** + * Reject a pending signature request that used `deferSetAsSigned`. + * Changes the status of the signature request to `rejected`. + * + * @param signatureRequestId - The ID of the signature request. + */ + setDeferredSignError(signatureRequestId: string) { + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.status = SignatureRequestStatus.Rejected; + }); + } + + /** + * Set the status of a signature request to 'inProgress'. + * + * @param signatureRequestId - The ID of the signature request. + */ + setTypedMessageInProgress(signatureRequestId: string) { + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.status = SignatureRequestStatus.InProgress; + }); + } + + /** + * Set the status of a signature request to 'inProgress'. + * + * @param signatureRequestId - The ID of the signature request. + */ + setPersonalMessageInProgress(signatureRequestId: string) { + this.setTypedMessageInProgress(signatureRequestId); + } + + #parseTypedData( + messageParams: MessageParamsTyped, + version?: SignTypedDataVersion, + ): MessageParamsTyped { + if ( + ![SignTypedDataVersion.V3, SignTypedDataVersion.V4].includes( + version as SignTypedDataVersion, + ) || + typeof messageParams.data !== 'string' + ) { + return messageParams; + } + + return { + ...messageParams, + data: JSON.parse(messageParams.data), + }; + } + + async #processSignatureRequest({ + chainId: optionChainId, + messageParams, + request, + type, + approvalType, + version, + signingOptions, + traceContext, + decodedPermission, + }: { + chainId?: Hex; + messageParams: MessageParams; + request: OriginalRequest; + type: SignatureRequestType; + approvalType: ApprovalType; + version?: SignTypedDataVersion; + signingOptions?: TypedSigningOptions; + traceContext?: TraceContext; + decodedPermission?: DecodedPermission; + }): Promise { + log('Processing signature request', { + // Never log full message contents: they may embed user data and, on + // some paths, sensitive signing material. The request id and origin + // are sufficient for tracing. + requestId: (request as { id?: string })?.id, + origin: messageParams?.origin ?? (request as { origin?: string })?.origin, + type, + version, + }); + + const chainId = optionChainId ?? this.#getChainId(request); + + this.#addLog(type, version, SigningStage.Proposed, messageParams); + + const metadata = this.#addMetadata({ + chainId, + messageParams, + request, + signingOptions, + type, + version, + decodedPermission, + }); + + let resultCallbacks: AcceptResultCallbacks | undefined; + let approveOrSignError: unknown; + + const finalMetadataPromise = this.#waitForFinished(metadata.id); + this.#decodePermitSignatureRequest(metadata.id, request, chainId); + + try { + resultCallbacks = await this.#processApproval({ + approvalType, + metadata, + request, + traceContext, + }); + + await this.#approveAndSignRequest(metadata, traceContext); + } catch (error) { + log('Signature request failed', (error as Error).message); + approveOrSignError = error; + } + + const finalMetadata = await finalMetadataPromise; + + const { + error, + id, + messageParams: finalMessageParams, + rawSig: signature, + } = finalMetadata; + + switch (finalMetadata.status) { + case SignatureRequestStatus.Signed: + // Never log the signature itself: signatures are authenticators and + // must not end up in persistent logs. + log('Signature request finished', { id }); + this.#addLog(type, version, SigningStage.Signed, finalMessageParams); + resultCallbacks?.success(signature); + return finalMetadata.rawSig as string; + + case SignatureRequestStatus.Rejected: + /* istanbul ignore next */ + const rejectedError = (approveOrSignError ?? + new Error( + `MetaMask ${type} Signature: User denied message signature.`, + )) as Error; + + resultCallbacks?.error(rejectedError); + throw rejectedError; + + case SignatureRequestStatus.Errored: + /* istanbul ignore next */ + const erroredError = (approveOrSignError ?? + new Error(`MetaMask ${type} Signature: ${error as string}`)) as Error; + + resultCallbacks?.error(erroredError); + throw erroredError; + + /* istanbul ignore next */ + default: + throw new Error( + `MetaMask ${type} Signature: Unknown problem: ${JSON.stringify( + finalMessageParams, + )}`, + ); + } + } + + #addMetadata({ + chainId, + messageParams, + request, + signingOptions, + type, + version, + decodedPermission, + }: { + chainId: Hex; + messageParams: MessageParams; + request?: OriginalRequest; + signingOptions?: TypedSigningOptions; + type: SignatureRequestType; + version?: SignTypedDataVersion; + decodedPermission?: DecodedPermission; + }): SignatureRequest { + const id = random(); + const origin = request?.origin ?? messageParams.origin; + const requestId = request?.id; + const securityAlertResponse = request?.securityAlertResponse; + const networkClientId = request?.networkClientId; + + const finalMessageParams = { + ...messageParams, + metamaskId: id, + origin, + requestId, + version, + }; + + const metadata = { + chainId, + id, + messageParams: finalMessageParams, + networkClientId, + securityAlertResponse, + signingOptions, + status: SignatureRequestStatus.Unapproved, + time: Date.now(), + type, + version, + decodedPermission, + } as SignatureRequest; + + this.#updateState((state) => { + state.signatureRequests[metadata.id] = metadata; + }); + + // Never log full request metadata: it embeds the message to be signed. + // The request id and type are sufficient for tracing. + log('Added signature request', { id: metadata.id, type: metadata.type }); + + this.hub.emit('unapprovedMessage', { + messageParams, + metamaskId: metadata.id, + }); + + return metadata; + } + + async #processApproval({ + approvalType, + metadata, + request, + traceContext, + }: { + approvalType: ApprovalType; + metadata: SignatureRequest; + request?: OriginalRequest; + traceContext?: TraceContext; + }): Promise { + const { id, messageParams, type, version } = metadata; + + try { + const acceptResult = await this.#trace( + { name: 'Await Approval', parentContext: traceContext }, + (context) => + this.#requestApproval(metadata, approvalType, { + traceContext: context, + actionId: request?.id?.toString(), + }), + ); + + return acceptResult.resultCallbacks; + } catch (error) { + log('User rejected request', { id, error }); + + this.#addLog(type, version, SigningStage.Rejected, messageParams); + this.#rejectSignatureRequest(id); + + throw error; + } + } + + async #approveAndSignRequest( + metadata: SignatureRequest, + traceContext?: TraceContext, + ) { + const { id } = metadata; + + this.#updateMetadata(id, (draftMetadata) => { + draftMetadata.status = SignatureRequestStatus.Approved; + }); + + await this.#trace({ name: 'Sign', parentContext: traceContext }, () => + this.#signRequest(metadata), + ); + } + + async #signRequest(metadata: SignatureRequest) { + const { id, messageParams, signingOptions, type } = metadata; + + try { + let signature: string; + + switch (type) { + case SignatureRequestType.PersonalSign: + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + // Keyring controller temporarily using message manager types. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messageParams as any, + ); + break; + + case SignatureRequestType.TypedSign: + const finalRequest = signingOptions?.parseJsonData + ? this.#parseTypedData(messageParams, metadata.version) + : messageParams; + + signature = await this.messenger.call( + 'KeyringController:signTypedMessage', + finalRequest, + metadata.version as SignTypedDataVersion, + ); + break; + + /* istanbul ignore next */ + default: + throw new Error(`Unknown signature request type: ${type as string}`); + } + + this.hub.emit(`${type}:signed`, { signature, messageId: id }); + + if (messageParams.deferSetAsSigned) { + return; + } + + this.#updateMetadata(id, (draftMetadata) => { + draftMetadata.rawSig = signature; + draftMetadata.status = SignatureRequestStatus.Signed; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (type === SignatureRequestType.TypedSign) { + this.#errorSignatureRequest(id, error.message); + } else { + this.#rejectSignatureRequest(id); + } + + this.hub.emit(`${id}:signError`, { error }); + + throw error; + } + } + + #errorSignatureRequest(id: string, error: string) { + this.#updateMetadata(id, (draftMetadata) => { + draftMetadata.status = SignatureRequestStatus.Errored; + draftMetadata.error = error; + }); + } + + #rejectSignatureRequest(signatureRequestId: string, reason?: string) { + if (reason) { + const metadata = this.state.signatureRequests[signatureRequestId]; + this.hub.emit('cancelWithReason', { metadata, reason }); + } + + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.status = SignatureRequestStatus.Rejected; + }); + } + + async #waitForFinished(id: string): Promise { + return new Promise((resolve) => { + this.hub.once(`${id}:finished`, (metadata: SignatureRequest) => { + resolve(metadata); + }); + }); + } + + async #requestApproval( + metadata: SignatureRequest, + type: ApprovalType, + { + traceContext, + actionId, + }: { traceContext?: TraceContext; actionId?: string }, + ): Promise { + const { id, messageParams } = metadata; + const origin = messageParams.origin || ORIGIN_METAMASK; + + await this.#trace({ + name: 'Notification Display', + id: actionId, + parentContext: traceContext, + }); + + return (await this.messenger.call( + 'ApprovalController:addRequest', + { + id, + origin, + type, + requestData: { ...messageParams }, + expectsResult: true, + }, + true, + )) as Promise; + } + + #updateMetadata( + id: string, + callback: (metadata: SignatureRequest) => void, + ): SignatureRequest { + let statusChanged = false; + + const { nextState } = this.#updateState((state) => { + const metadata = state.signatureRequests[id]; + + if (!metadata) { + throw new Error(`Signature request with id ${id} not found`); + } + + const originalStatus = metadata.status; + + callback(metadata); + + statusChanged = metadata.status !== originalStatus; + }); + + const updatedMetadata = nextState.signatureRequests[id]; + + if ( + statusChanged && + FINAL_STATUSES.includes(updatedMetadata.status as SignatureRequestStatus) + ) { + this.hub.emit(`${id}:finished`, updatedMetadata); + } + + return updatedMetadata; + } + + #updateState(callback: (state: SignatureControllerState) => void) { + return this.update((state) => { + callback(state as unknown as SignatureControllerState); + + const unapprovedRequests = Object.values(state.signatureRequests).filter( + (request) => request.status === SignatureRequestStatus.Unapproved, + ) as unknown as SignatureRequest[]; + + const personalSignMessages = this.#generateLegacyState( + unapprovedRequests, + SignatureRequestType.PersonalSign, + ); + + const typedSignMessages = this.#generateLegacyState( + unapprovedRequests, + SignatureRequestType.TypedSign, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state.unapprovedPersonalMsgs = personalSignMessages as any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state.unapprovedTypedMessages = typedSignMessages as any; + + state.unapprovedPersonalMsgCount = + Object.values(personalSignMessages).length; + + state.unapprovedTypedMessagesCount = + Object.values(typedSignMessages).length; + }); + } + + #generateLegacyState( + signatureRequests: SignatureRequest[], + type: SignatureRequestType, + ): Record { + return signatureRequests + .filter((request) => request.type === type) + .reduce>( + (acc, request) => ({ + ...acc, + [request.id]: { ...request, msgParams: request.messageParams }, + }), + {}, + ); + } + + #addLog( + signatureRequestType: SignatureRequestType, + version: SignTypedDataVersion | undefined, + stage: SigningStage, + signingData: MessageParams, + ): void { + const signingMethod = this.#getSignTypeForLogger( + signatureRequestType, + version, + ); + + this.messenger.call('LoggingController:add', { + type: LogType.EthSignLog, + data: { + signingMethod, + stage, + signingData, + }, + }); + } + + #getSignTypeForLogger( + requestType: SignatureRequestType, + version?: SignTypedDataVersion, + ): SigningMethod { + if (requestType === SignatureRequestType.PersonalSign) { + return SigningMethod.PersonalSign; + } + + if ( + requestType === SignatureRequestType.TypedSign && + version === SignTypedDataVersion.V3 + ) { + return SigningMethod.EthSignTypedDataV3; + } + + if ( + requestType === SignatureRequestType.TypedSign && + version === SignTypedDataVersion.V4 + ) { + return SigningMethod.EthSignTypedDataV4; + } + + return SigningMethod.EthSignTypedData; + } + + #getChainId(request: OriginalRequest): Hex { + const { networkClientId } = request; + + if (!networkClientId) { + throw new Error('Network client ID not found in request'); + } + + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + return networkClient.configuration.chainId; + } + + #decodePermitSignatureRequest( + signatureRequestId: string, + request: OriginalRequest, + chainId: string, + ) { + if (!this.#isDecodeSignatureRequestEnabled?.() || !this.#decodingApiUrl) { + return; + } + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.decodingLoading = true; + }); + decodeSignature(request, chainId, this.#decodingApiUrl) + .then((decodingData) => + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.decodingData = decodingData; + draftMetadata.decodingLoading = false; + }), + ) + .catch((error) => + this.#updateMetadata(signatureRequestId, (draftMetadata) => { + draftMetadata.decodingData = { + stateChanges: null, + error: { + message: (error as unknown as Error).message, + type: DECODING_API_ERRORS.DECODING_FAILED_WITH_ERROR, + }, + }; + draftMetadata.decodingLoading = false; + }), + ); + } + + #getInternalAccounts(): Hex[] { + const state = this.messenger.call('AccountsController:getState'); + + /* istanbul ignore next */ + return Object.values(state.internalAccounts?.accounts ?? {}) + .filter((account) => account.type === 'eip155:eoa') + .map((account) => account.address as Hex); + } +} diff --git a/packages/signature-controller/src/utils/validation.ts b/packages/signature-controller/src/utils/validation.ts index f4a711bfa80..50114601778 100644 --- a/packages/signature-controller/src/utils/validation.ts +++ b/packages/signature-controller/src/utils/validation.ts @@ -1,294 +1,300 @@ -import { ORIGIN_METAMASK } from '@metamask/approval-controller'; -import { isValidHexAddress } from '@metamask/controller-utils'; -import { - TYPED_MESSAGE_SCHEMA, - typedSignatureHash, -} from '@metamask/eth-sig-util'; -import type { DecodedPermission } from '@metamask/gator-permissions-controller'; -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import type { Json } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; -import { validate } from 'jsonschema'; - -import type { - MessageParamsPersonal, - MessageParamsTyped, - MessageParamsTypedData, - OriginalRequest, -} from '../types.js'; -import { isDelegationRequest } from './delegations.js'; - -export const PRIMARY_TYPE_DELEGATION = 'Delegation'; -export const DELEGATOR_FIELD = 'delegator'; - -/** - * Validate a personal signature request. - * - * @param messageData - The message data to validate. - */ -export function validatePersonalSignatureRequest( - messageData: MessageParamsPersonal, -) { - const { from, data } = messageData; - - validateAddress(from, 'from'); - - if (!data || typeof data !== 'string') { - throw new Error(`Invalid message "data": ${data} must be a valid string.`); - } -} - -/** - * Validate a typed signature request. - * - * @param options - Options bag. - * @param options.currentChainId - The current chain ID. - * @param options.internalAccounts - The addresses of all internal accounts. - * @param options.messageData - The message data to validate. - * @param options.request - The original request. - * @param options.version - The version of the typed signature request. - * @param options.decodedPermission - The decoded permission. - */ -export function validateTypedSignatureRequest({ - currentChainId, - internalAccounts, - messageData, - request, - version, - decodedPermission, -}: { - currentChainId: Hex | undefined; - internalAccounts: Hex[]; - messageData: MessageParamsTyped; - request: OriginalRequest; - version: SignTypedDataVersion; - decodedPermission?: DecodedPermission; -}) { - validateAddress(messageData.from, 'from'); - - if (version === SignTypedDataVersion.V1) { - validateTypedSignatureRequestV1(messageData); - } else { - validateTypedSignatureRequestV3V4({ - currentChainId, - internalAccounts, - messageData, - request, - decodedPermission, - }); - } -} - -/** - * Validate a V1 typed signature request. - * - * @param messageData - The message data to validate. - */ -function validateTypedSignatureRequestV1(messageData: MessageParamsTyped) { - if (!messageData.data || !Array.isArray(messageData.data)) { - throw new Error( - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Invalid message "data": ${messageData.data} must be a valid array.`, - ); - } - - try { - // typedSignatureHash will throw if the data is invalid. - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - typedSignatureHash(messageData.data as any); - } catch (e) { - throw new Error(`Expected EIP712 typed data.`); - } -} - -/** - * Validate a V3 or V4 typed signature request. - * - * @param options - Options bag. - * @param options.currentChainId - The current chain ID. - * @param options.internalAccounts - The addresses of all internal accounts. - * @param options.messageData - The message data to validate. - * @param options.request - The original request. - * @param options.decodedPermission - The decoded permission. - */ -function validateTypedSignatureRequestV3V4({ - currentChainId, - internalAccounts, - messageData, - request, - decodedPermission, -}: { - currentChainId: Hex | undefined; - internalAccounts: Hex[]; - messageData: MessageParamsTyped; - request: OriginalRequest; - decodedPermission?: DecodedPermission; -}) { - if ( - !messageData.data || - Array.isArray(messageData.data) || - (typeof messageData.data !== 'object' && - typeof messageData.data !== 'string') - ) { - throw new Error( - `Invalid message "data": Must be a valid string or object.`, - ); - } - - let data; - if (typeof messageData.data === 'object') { - data = messageData.data; - } else { - try { - data = JSON.parse(messageData.data); - } catch (e) { - throw new Error('Data must be passed as a valid JSON string.'); - } - } - - const validation = validate(data, TYPED_MESSAGE_SCHEMA); - if (validation.errors.length > 0) { - throw new Error( - 'Data must conform to EIP-712 schema. See https://git.io/fNtcx.', - ); - } - - if (!currentChainId) { - throw new Error('Current chainId cannot be null or undefined.'); - } - - let { chainId } = data.domain; - if (chainId) { - if (typeof chainId === 'string') { - chainId = parseInt(chainId, chainId.startsWith('0x') ? 16 : 10); - } - - const activeChainId = parseInt(currentChainId, 16); - if (Number.isNaN(activeChainId)) { - throw new Error( - `Cannot sign messages for chainId "${ - chainId as string - }", because MetaMask is switching networks.`, - ); - } - - if (chainId !== activeChainId) { - throw new Error( - `Provided chainId "${ - chainId as string - }" must match the active chainId "${activeChainId}"`, - ); - } - } - - const origin = request?.origin ?? messageData?.origin; - - validateVerifyingContract({ - data, - internalAccounts, - origin, - }); - - validateDelegation({ - data, - internalAccounts, - origin, - decodedPermission, - }); -} - -/** - * Validate an Ethereum address. - * - * @param address - The address to validate. - * @param propertyName - The name of the property source to use in the error message. - */ -function validateAddress(address: string, propertyName: string) { - if (!address || typeof address !== 'string' || !isValidHexAddress(address)) { - throw new Error( - `Invalid "${propertyName}" address: ${address} must be a valid string.`, - ); - } -} - -/** - * Validate the verifying contract from a typed signature request. - * - * @param options - Options bag. - * @param options.data - The typed data to validate. - * @param options.internalAccounts - The internal accounts. - * @param options.origin - The origin of the request. - */ -function validateVerifyingContract({ - data, - internalAccounts, - origin, -}: { - data: MessageParamsTypedData; - internalAccounts: Hex[]; - origin: string | undefined; -}) { - const verifyingContract = data?.domain?.verifyingContract; - const isExternal = origin && origin !== ORIGIN_METAMASK; - - if ( - verifyingContract && - typeof verifyingContract === 'string' && - isExternal && - internalAccounts.some( - (internalAccount) => - internalAccount.toLowerCase() === verifyingContract.toLowerCase(), - ) - ) { - throw new Error( - `External signature requests cannot use internal accounts as the verifying contract.`, - ); - } -} - -/** - * Validate a delegation signature request. - * - * @param options - Options bag. - * @param options.data - The typed data to validate. - * @param options.internalAccounts - The internal accounts. - * @param options.origin - The origin of the request. - * @param options.decodedPermission - The decoded permission. - */ -function validateDelegation({ - data, - internalAccounts, - origin, - decodedPermission, -}: { - data: MessageParamsTypedData; - internalAccounts: Hex[]; - origin: string | undefined; - decodedPermission?: DecodedPermission; -}) { - if (!isDelegationRequest(data)) { - return; - } - - const hasDecodedPermission = decodedPermission !== undefined; - if (!hasDecodedPermission) { - const isOriginExternal = origin && origin !== ORIGIN_METAMASK; - - const delegatorAddressLowercase = ( - (data.message as Record)?.[DELEGATOR_FIELD] as Hex - )?.toLowerCase(); - - const isSignerInternal = internalAccounts.some( - (internalAccount) => - internalAccount.toLowerCase() === delegatorAddressLowercase, - ); - - if (isOriginExternal && isSignerInternal) { - throw new Error( - `External signature requests cannot sign delegations for internal accounts.`, - ); - } - } -} +import { ORIGIN_METAMASK } from '@metamask/approval-controller'; +import { isValidHexAddress } from '@metamask/controller-utils'; +import { + TYPED_MESSAGE_SCHEMA, + typedSignatureHash, +} from '@metamask/eth-sig-util'; +import type { DecodedPermission } from '@metamask/gator-permissions-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { Json } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { validate } from 'jsonschema'; + +import type { + MessageParamsPersonal, + MessageParamsTyped, + MessageParamsTypedData, + OriginalRequest, +} from '../types.js'; +import { isDelegationRequest } from './delegations.js'; + +export const PRIMARY_TYPE_DELEGATION = 'Delegation'; +export const DELEGATOR_FIELD = 'delegator'; + +/** + * Validate a personal signature request. + * + * @param messageData - The message data to validate. + */ +export function validatePersonalSignatureRequest( + messageData: MessageParamsPersonal, +) { + const { from, data } = messageData; + + validateAddress(from, 'from'); + + if (!data || typeof data !== 'string') { + throw new Error(`Invalid message "data": ${data} must be a valid string.`); + } +} + +/** + * Validate a typed signature request. + * + * @param options - Options bag. + * @param options.currentChainId - The current chain ID. + * @param options.internalAccounts - The addresses of all internal accounts. + * @param options.messageData - The message data to validate. + * @param options.request - The original request. + * @param options.version - The version of the typed signature request. + * @param options.decodedPermission - The decoded permission. + */ +export function validateTypedSignatureRequest({ + currentChainId, + internalAccounts, + messageData, + request, + version, + decodedPermission, +}: { + currentChainId: Hex | undefined; + internalAccounts: Hex[]; + messageData: MessageParamsTyped; + request: OriginalRequest; + version: SignTypedDataVersion; + decodedPermission?: DecodedPermission; +}) { + validateAddress(messageData.from, 'from'); + + if (version === SignTypedDataVersion.V1) { + validateTypedSignatureRequestV1(messageData); + } else { + validateTypedSignatureRequestV3V4({ + currentChainId, + internalAccounts, + messageData, + request, + decodedPermission, + }); + } +} + +/** + * Validate a V1 typed signature request. + * + * @param messageData - The message data to validate. + */ +function validateTypedSignatureRequestV1(messageData: MessageParamsTyped) { + if (!messageData.data || !Array.isArray(messageData.data)) { + throw new Error( + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Invalid message "data": ${messageData.data} must be a valid array.`, + ); + } + + try { + // typedSignatureHash will throw if the data is invalid. + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typedSignatureHash(messageData.data as any); + } catch (e) { + throw new Error(`Expected EIP712 typed data.`); + } +} + +/** + * Validate a V3 or V4 typed signature request. + * + * @param options - Options bag. + * @param options.currentChainId - The current chain ID. + * @param options.internalAccounts - The addresses of all internal accounts. + * @param options.messageData - The message data to validate. + * @param options.request - The original request. + * @param options.decodedPermission - The decoded permission. + */ +function validateTypedSignatureRequestV3V4({ + currentChainId, + internalAccounts, + messageData, + request, + decodedPermission, +}: { + currentChainId: Hex | undefined; + internalAccounts: Hex[]; + messageData: MessageParamsTyped; + request: OriginalRequest; + decodedPermission?: DecodedPermission; +}) { + if ( + !messageData.data || + Array.isArray(messageData.data) || + (typeof messageData.data !== 'object' && + typeof messageData.data !== 'string') + ) { + throw new Error( + `Invalid message "data": Must be a valid string or object.`, + ); + } + + let data; + if (typeof messageData.data === 'object') { + data = messageData.data; + } else { + try { + data = JSON.parse(messageData.data); + } catch (e) { + throw new Error('Data must be passed as a valid JSON string.'); + } + } + + const validation = validate(data, TYPED_MESSAGE_SCHEMA); + if (validation.errors.length > 0) { + throw new Error( + 'Data must conform to EIP-712 schema. See https://git.io/fNtcx.', + ); + } + + if (!currentChainId) { + throw new Error('Current chainId cannot be null or undefined.'); + } + + let { chainId } = data.domain; + if (chainId) { + if (typeof chainId === 'string') { + chainId = parseInt(chainId, chainId.startsWith('0x') ? 16 : 10); + } + + const activeChainId = parseInt(currentChainId, 16); + if (Number.isNaN(activeChainId)) { + throw new Error( + `Cannot sign messages for chainId "${ + chainId as string + }", because MetaMask is switching networks.`, + ); + } + + if (chainId !== activeChainId) { + throw new Error( + `Provided chainId "${ + chainId as string + }" must match the active chainId "${activeChainId}"`, + ); + } + } + + const origin = request?.origin ?? messageData?.origin; + + validateVerifyingContract({ + data, + internalAccounts, + origin, + }); + + validateDelegation({ + data, + internalAccounts, + origin, + decodedPermission, + }); +} + +/** + * Validate an Ethereum address. + * + * @param address - The address to validate. + * @param propertyName - The name of the property source to use in the error message. + */ +function validateAddress(address: string, propertyName: string) { + if (!address || typeof address !== 'string' || !isValidHexAddress(address)) { + throw new Error( + `Invalid "${propertyName}" address: ${address} must be a valid string.`, + ); + } +} + +/** + * Validate the verifying contract from a typed signature request. + * + * @param options - Options bag. + * @param options.data - The typed data to validate. + * @param options.internalAccounts - The internal accounts. + * @param options.origin - The origin of the request. + */ +function validateVerifyingContract({ + data, + internalAccounts, + origin, +}: { + data: MessageParamsTypedData; + internalAccounts: Hex[]; + origin: string | undefined; +}) { + const verifyingContract = data?.domain?.verifyingContract; + // A missing or empty origin must never be treated as internal: only an + // explicit MetaMask origin is trusted, everything else (including absent + // origins on dApp-routed requests) is external and subject to the stricter + // checks below. + const isExternal = origin !== ORIGIN_METAMASK; + + if ( + verifyingContract && + typeof verifyingContract === 'string' && + isExternal && + internalAccounts.some( + (internalAccount) => + internalAccount.toLowerCase() === verifyingContract.toLowerCase(), + ) + ) { + throw new Error( + `External signature requests cannot use internal accounts as the verifying contract.`, + ); + } +} + +/** + * Validate a delegation signature request. + * + * @param options - Options bag. + * @param options.data - The typed data to validate. + * @param options.internalAccounts - The internal accounts. + * @param options.origin - The origin of the request. + * @param options.decodedPermission - The decoded permission. + */ +function validateDelegation({ + data, + internalAccounts, + origin, + decodedPermission, +}: { + data: MessageParamsTypedData; + internalAccounts: Hex[]; + origin: string | undefined; + decodedPermission?: DecodedPermission; +}) { + if (!isDelegationRequest(data)) { + return; + } + + const hasDecodedPermission = decodedPermission !== undefined; + if (!hasDecodedPermission) { + // Same fail-closed origin rule as above: only an explicit MetaMask + // origin counts as internal. + const isOriginExternal = origin !== ORIGIN_METAMASK; + + const delegatorAddressLowercase = ( + (data.message as Record)?.[DELEGATOR_FIELD] as Hex + )?.toLowerCase(); + + const isSignerInternal = internalAccounts.some( + (internalAccount) => + internalAccount.toLowerCase() === delegatorAddressLowercase, + ); + + if (isOriginExternal && isSignerInternal) { + throw new Error( + `External signature requests cannot sign delegations for internal accounts.`, + ); + } + } +}