diff --git a/README.md b/README.md index 4cc1d212b8..7382238648 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/signature-controller`](packages/signature-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) +- [`@metamask/wallet-framework`](packages/wallet-framework) @@ -87,6 +88,7 @@ linkStyle default opacity:0.5 signature_controller(["@metamask/signature-controller"]); transaction_controller(["@metamask/transaction-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); + wallet_framework(["@metamask/wallet-framework"]); accounts_controller --> base_controller; accounts_controller --> keyring_controller; address_book_controller --> base_controller; @@ -161,11 +163,13 @@ linkStyle default opacity:0.5 signature_controller --> keyring_controller; signature_controller --> logging_controller; signature_controller --> message_manager; + transaction_controller --> accounts_controller; transaction_controller --> approval_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; transaction_controller --> gas_fee_controller; transaction_controller --> network_controller; + transaction_controller --> eth_json_rpc_provider; user_operation_controller --> approval_controller; user_operation_controller --> base_controller; user_operation_controller --> controller_utils; @@ -174,6 +178,12 @@ linkStyle default opacity:0.5 user_operation_controller --> network_controller; user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; + wallet_framework --> approval_controller; + wallet_framework --> base_controller; + wallet_framework --> controller_utils; + wallet_framework --> json_rpc_engine; + wallet_framework --> keyring_controller; + wallet_framework --> permission_controller; ``` diff --git a/constraints.pro b/constraints.pro index 84cc63f1b5..41b177307b 100644 --- a/constraints.pro +++ b/constraints.pro @@ -374,6 +374,7 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, 'devDependencies') gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), \+ workspace_has_dependency(WorkspaceCwd, DependencyIdent, _, 'peerDependencies'), + WorkspaceCwd \= 'packages/wallet-framework', is_controller(DependencyIdent), DependencyIdent \= '@metamask/base-controller', DependencyIdent \= '@metamask/eth-keyring-controller', @@ -385,6 +386,7 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRang atom_concat('^', CorrectPeerDependencyVersion, CorrectPeerDependencyRange). gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectPeerDependencyRange, 'peerDependencies') :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, SpecifiedPeerDependencyRange, 'peerDependencies'), + WorkspaceCwd \= 'packages/wallet-framework', is_controller(DependencyIdent), DependencyIdent \= '@metamask/base-controller', DependencyIdent \= '@metamask/eth-keyring-controller', diff --git a/packages/permission-controller/src/PermissionController.ts b/packages/permission-controller/src/PermissionController.ts index 569fba5953..daa0812a05 100644 --- a/packages/permission-controller/src/PermissionController.ts +++ b/packages/permission-controller/src/PermissionController.ts @@ -339,6 +339,18 @@ export type ClearPermissions = { handler: () => void; }; +/** + * Execute a restricted method. + * + * @deprecated The permission middleware is intended to be the only place this is called. This was + * exposed as an action so that it could be used in a single legacy middleware function that needs + * to come before the permission middleware. Do not call this anywhere else. + */ +export type ExecuteRestructedMethod = { + type: `${typeof controllerName}:executeRestrictedMethod`; + handler: GenericPermissionController['executeRestrictedMethod']; +}; + /** * Gets the endowments for the given subject and permission. */ @@ -352,6 +364,7 @@ export type GetEndowments = { */ export type PermissionControllerActions = | ClearPermissions + | ExecuteRestructedMethod | GetEndowments | GetPermissionControllerState | GetSubjects @@ -815,6 +828,12 @@ export class PermissionController< () => this.clearState(), ); + this.messagingSystem.registerActionHandler( + `${controllerName}:executeRestrictedMethod` as const, + (...args: Parameters) => + this.executeRestrictedMethod(...args), + ); + this.messagingSystem.registerActionHandler( `${controllerName}:getEndowments` as const, (origin: string, targetName: string, requestData?: unknown) => diff --git a/packages/wallet-framework/CHANGELOG.md b/packages/wallet-framework/CHANGELOG.md new file mode 100644 index 0000000000..b518709c7b --- /dev/null +++ b/packages/wallet-framework/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-framework/LICENSE b/packages/wallet-framework/LICENSE new file mode 100644 index 0000000000..6f8bff03fc --- /dev/null +++ b/packages/wallet-framework/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/wallet-framework/README.md b/packages/wallet-framework/README.md new file mode 100644 index 0000000000..d86d065b15 --- /dev/null +++ b/packages/wallet-framework/README.md @@ -0,0 +1,15 @@ +# `@metamask/wallet-framework` + +A framework for building MetaMask wallets. + +## Installation + +`yarn add @metamask/wallet-framework` + +or + +`npm install @metamask/wallet-framework` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet-framework/jest.config.js b/packages/wallet-framework/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/wallet-framework/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/wallet-framework/package.json b/packages/wallet-framework/package.json new file mode 100644 index 0000000000..bafb9e8064 --- /dev/null +++ b/packages/wallet-framework/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/wallet-framework", + "version": "0.0.0", + "description": "A framework for building MetaMask wallets", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet-framework#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/types/index.d.ts" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsup --config ../../tsup.config.ts --tsconfig ./tsconfig.build.json --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-framework", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest --reporters=jest-silent-reporter", + "test:clean": "jest --clearCache", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "dependencies": { + "@metamask/approval-controller": "^7.0.0", + "@metamask/base-controller": "^6.0.0", + "@metamask/controller-utils": "^11.0.0", + "@metamask/eth-json-rpc-middleware": "^12.1.1", + "@metamask/json-rpc-engine": "^9.0.0", + "@metamask/keyring-controller": "^17.1.0", + "@metamask/permission-controller": "^10.0.0", + "@metamask/rpc-errors": "^6.2.1", + "@metamask/utils": "^8.3.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~4.9.5" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/wallet-framework/src/WalletFramework.ts b/packages/wallet-framework/src/WalletFramework.ts new file mode 100644 index 0000000000..439183a188 --- /dev/null +++ b/packages/wallet-framework/src/WalletFramework.ts @@ -0,0 +1,537 @@ +import { + ApprovalController, + type ApprovalControllerActions, + type ApprovalControllerEvents, + type ApprovalControllerState, +} from '@metamask/approval-controller'; +import { + type ControllerGetStateAction, + type ControllerMessenger, + type ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { + type TypedMessageParams, + type TypedMessageV1Params, + createWalletMiddleware, +} from '@metamask/eth-json-rpc-middleware'; +import { + JsonRpcEngine, + type JsonRpcMiddleware, + createScaffoldMiddleware, + mergeMiddleware, +} from '@metamask/json-rpc-engine'; +import { + type ExportableKeyEncryptor, + type GenericEncryptor, + KeyringController, + type KeyringControllerActions, + type KeyringControllerEvents, + type KeyringControllerState, + SignTypedDataVersion, +} from '@metamask/keyring-controller'; +import { + type CaveatSpecificationConstraint, + type ExtractPermission, + PermissionController, + type PermissionControllerActions, + type PermissionControllerEvents, + type PermissionControllerState, + type PermissionSpecificationConstraint, + SubjectMetadataController, + type SubjectMetadataControllerActions, + type SubjectMetadataControllerEvents, + SubjectType, + type SubjectMetadataControllerState, + type CaveatSpecificationMap, + type PermissionSpecificationMap, +} from '@metamask/permission-controller'; +import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; +import { + hasProperty, + isObject, + type Hex, + type Json, + type JsonRpcParams, +} from '@metamask/utils'; + +import { createEthAccountsMiddleware } from './middleware/createEthAccountsMiddleware'; +import createOriginMiddleware from './middleware/createOriginMiddleware'; +import { + RestrictedMethods, + defaultUnrestrictedMethods, + getDefaultCaveatSpecifications, + getDefaultPermissionSpecifications, + type DefaultCaveatSpecification, + type DefaultPermissionSpecification, +} from './permissions'; + +/** + * MetaMask wallet state. + */ +export type MetamaskState< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = { + approvalController: ApprovalControllerState; + keyringController: KeyringControllerState; + permissionController: PermissionControllerState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification & DefaultCaveatSpecification + > + >; + subjectMetadataController: SubjectMetadataControllerState; +}; + +export type WalletGetState< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = ControllerGetStateAction< + 'Wallet', + MetamaskState< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > +>; + +export type WalletActions< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = WalletGetState< + ControllerPermissionSpecification, + ControllerCaveatSpecification +>; + +/** + * All wallet actions. + */ +export type AllWalletActions< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = + | ApprovalControllerActions + | KeyringControllerActions + | PermissionControllerActions + | SubjectMetadataControllerActions + | WalletActions< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >; + +export type WalletStateChange< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = ControllerStateChangeEvent< + 'Wallet', + MetamaskState< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > +>; + +export type WalletEvents< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = WalletStateChange< + ControllerPermissionSpecification, + ControllerCaveatSpecification +>; + +/** + * All wallet events. + */ +export type AllWalletEvents< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> = + | ApprovalControllerEvents + | KeyringControllerEvents + | PermissionControllerEvents + | SubjectMetadataControllerEvents + | WalletEvents< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >; + +/** + * A MetaMask wallet. + * + * @template ControllerPermissionSpecification - A union of the types of all + * permission specifications available to the controller. Any referenced caveats + * must be included in the controller's caveat specifications. + * @template ControllerCaveatSpecification - A union of the types of all + * caveat specifications available to the controller. + */ +export class WalletFramework< + ControllerPermissionSpecification extends PermissionSpecificationConstraint & + DefaultPermissionSpecification = DefaultPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint & + DefaultCaveatSpecification = DefaultCaveatSpecification, +> { + #controllerMessenger: ControllerMessenger; + + #controllers: { + approvalController: ApprovalController; + keyringController: KeyringController; + permissionController: PermissionController< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >; + subjectMetadataController: SubjectMetadataController; + }; + + #metamaskMiddleware: JsonRpcMiddleware; + + /** + * Construct a MetaMask wallet. + * + * @param options - Options. + * @param options.cacheEncryptionKey - Whether to or not to cache the vault encryption key + * (requires encryptor to support exporting encryption key) + * @param options.controllerMessenger - An unrestricted global messenger, used as the primary + * message broker for the wallet. + * @param options.encryptor - The vault encryptor. + * @param options.getCaveatSpecifications - Returns specifications for all PermissionController caveats. + * @param options.getPermissionSpecifications - Returns specifications for all + * PermissionController permissions. + * @param options.keyringBuilders - Keyring builder functions for any additional supported + * keyring types. + * @param options.showApprovalRequest - Function for showing an approval request to the user. + * @param options.state - The initial wallet state, broken down by controller. + * @param options.unrestrictedMethods - Methods that are ignored by the permission system. + * @param options.version - The wallet version. + */ + constructor({ + cacheEncryptionKey, + controllerMessenger, + encryptor, + getCaveatSpecifications, + getPermissionSpecifications, + keyringBuilders, + showApprovalRequest, + state = {}, + unrestrictedMethods, + version: walletVersion, + }: + | { + keyringBuilders?: ConstructorParameters< + typeof KeyringController + >[0]['keyringBuilders']; + controllerMessenger: ControllerMessenger< + AllWalletActions, + AllWalletEvents + >; + getCaveatSpecifications: () => CaveatSpecificationMap; + getPermissionSpecifications: () => PermissionSpecificationMap; + showApprovalRequest: ConstructorParameters< + typeof ApprovalController + >[0]['showApprovalRequest']; + state?: Partial< + MetamaskState< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >; + unrestrictedMethods?: string[]; + version: string; + } & ( + | { + cacheEncryptionKey: true; + encryptor?: ExportableKeyEncryptor; + } + | { + cacheEncryptionKey?: false; + encryptor?: GenericEncryptor | ExportableKeyEncryptor; + } + )) { + this.#controllerMessenger = controllerMessenger; + + const keyringControllerMessenger = this.#controllerMessenger.getRestricted({ + allowedActions: [], + allowedEvents: [], + name: 'KeyringController', + }); + + // @ts-expect-error The `cacheEncryptionKey` and `encryptor` parameter types are identical + // between here and the KeyringController, but they're being "collapsed" into a single type + // here that violates the type signature. + // TODO: Simplify these parameter types by making all encryptors use the same interface. + const keyringController = new KeyringController({ + cacheEncryptionKey, + keyringBuilders, + state: state?.keyringController, + encryptor, + messenger: keyringControllerMessenger, + }); + + const approvalController = new ApprovalController({ + messenger: this.#controllerMessenger.getRestricted({ + allowedActions: [], + allowedEvents: [], + name: 'ApprovalController', + }), + showApprovalRequest, + typesExcludedFromRateLimiting: [ + ApprovalType.PersonalSign, + ApprovalType.EthSignTypedData, + ApprovalType.Transaction, + ApprovalType.WatchAsset, + ApprovalType.EthGetEncryptionPublicKey, + ApprovalType.EthDecrypt, + ], + }); + + const subjectMetadataController = new SubjectMetadataController({ + messenger: this.#controllerMessenger.getRestricted({ + allowedActions: [`PermissionController:hasPermissions`], + allowedEvents: [], + name: 'SubjectMetadataController', + }), + state: state?.subjectMetadataController, + subjectCacheLimit: 100, + }); + + const permissionController = new PermissionController< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >({ + messenger: this.#controllerMessenger.getRestricted({ + allowedActions: [ + `ApprovalController:addRequest`, + `ApprovalController:hasRequest`, + `ApprovalController:acceptRequest`, + `ApprovalController:rejectRequest`, + `SubjectMetadataController:getSubjectMetadata`, + ], + allowedEvents: [], + name: 'PermissionController', + }), + state: state?.permissionController, + caveatSpecifications: { + ...getDefaultCaveatSpecifications({ + getAccounts: () => + keyringController.state.keyrings + .map((keyrings) => keyrings.accounts) + .flat() as Hex[], + }), + ...getCaveatSpecifications(), + }, + permissionSpecifications: { + ...getDefaultPermissionSpecifications({ + getAccounts: () => + keyringController.state.keyrings + .map((keyrings) => keyrings.accounts) + .flat() as Hex[], + }), + ...getPermissionSpecifications(), + }, + unrestrictedMethods: unrestrictedMethods || defaultUnrestrictedMethods, + }); + + // This middleware is not origin-specific, it's shared between all origins. + this.#metamaskMiddleware = mergeMiddleware([ + createScaffoldMiddleware({ + // These properties match RPC method names, which follow a different naming convention + /* eslint-disable @typescript-eslint/naming-convention */ + // TODO: Investigate, can we remove this? It looks like it is already not supported, + // not listed as unrestricted. + eth_syncing: false, + // TODO: Add client type to this version + web3_clientVersion: `MetaMask/v${walletVersion}`, + /* eslint-enable @typescript-eslint/naming-convention */ + }), + // @ts-expect-error Wallet middleware types are broken + createWalletMiddleware({ + getAccounts: async ( + // @ts-expect-error origin insn't included in the JsonRpcRequest type, but we add this + // origin property in earlier middleware. + { origin }, + ) => { + if (this.#controllers.keyringController.isUnlocked()) { + try { + const accounts = + await this.#controllers.permissionController.executeRestrictedMethod( + origin, + RestrictedMethods.eth_accounts, + ); + // TODO: Find a way to remove this cast + return accounts as Hex[]; + } catch (error) { + if ( + isObject(error) && + hasProperty(error, 'code') && + error.code === errorCodes.provider.unauthorized + ) { + return []; + } + throw error; + } + } + // Empty array returned when no acounts are authorized + // for backwards compatibility reasons + return []; + }, + processPersonalMessage: (msgParams) => + this.#controllers.keyringController.signPersonalMessage(msgParams), + processTypedMessage: ( + msgParams: TypedMessageV1Params, + _req, + version: string, + ) => { + if (version !== SignTypedDataVersion.V1) { + throw rpcErrors.invalidParams('Invalid version'); + } + return this.#controllers.keyringController.signTypedMessage( + msgParams, + version, + ); + }, + processTypedMessageV3: ( + msgParams: TypedMessageParams, + _req, + version: string, + ) => { + if (version !== SignTypedDataVersion.V3) { + throw rpcErrors.invalidParams('Invalid version'); + } + return this.#controllers.keyringController.signTypedMessage( + msgParams, + version, + ); + }, + processTypedMessageV4: ( + msgParams: TypedMessageParams, + _req, + version: string, + ) => { + if (version !== SignTypedDataVersion.V4) { + throw rpcErrors.invalidParams('Invalid version'); + } + return this.#controllers.keyringController.signTypedMessage( + msgParams, + version, + ); + }, + }), + ]); + + this.#controllers = { + approvalController, + keyringController, + permissionController, + subjectMetadataController, + }; + } + + /** + * Initialize the wallet. + * + * This step is for asynchronous operations that should be performed after the wallet is + * initially constructed. For example, status checks, remote configuration updates, or preemptive + * caching. + * + * Note that this initialziation may not occur right away after wallet construction. For new + * wallet installations, this initialization will not be run until after onboarding. + */ + async initialize(): Promise { + // no-op + } + + // TODO: Consider adding state reset to base controller + + /** + * Reset all transient wallet state. + * + * This method is meant for applications that expect to automatically restart during typical + * operation (e.g. a wallet running in a service worker). Such applications can persist all + * wallet state, including transient state, to ensure caches are not cleared during these routine + * restarts. But after a fresh application start, we still want to have the ability to clear + * transient data that is not intended to be persisted. + * + * This method will erase all transient wallet state, leaving only persistent state. It should be + * called before initialization during a new application session. + */ + resetState(): void { + // no-op + } + + /** + * Create a "provider engine" for the given subject. + * + * A provider engine is a JSON-RPC request handler for provider JSON-RPC requests. It can be + * used to construct a provider, or to implement a wallet API. + * + * @param options - Options + * @param options.origin - The origin of the subject. + * @param options.subjectType - The type of the subject. + * @returns A JSON-RPC provider engine. + */ + createProviderEngine({ + origin, + subjectType, + }: { + origin: string; + subjectType: SubjectType; + }): JsonRpcEngine { + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware({ origin })); + + // Legacy RPC methods that need to be implemented _ahead of_ the permission + // middleware. + engine.push( + createEthAccountsMiddleware({ + messenger: this.#controllerMessenger.getRestricted({ + name: 'createEthAccountsMiddleware', + allowedActions: ['PermissionController:executeRestrictedMethod'], + allowedEvents: [], + }), + }), + ); + + if (subjectType !== SubjectType.Internal) { + engine.push( + this.#controllers.permissionController.createPermissionMiddleware({ + origin, + }), + ); + } + + // method middleware, inclduing requestAccounts + + engine.push(this.#metamaskMiddleware); + + return engine; + } + + // TODO: Add start/resume, pause, stop methods to control all polling/services + + // Additional jotnotes: + // + // Step0: Rename controller messenger, write ADR about services and selectors, refactor guts of ComposableController into compose function + // Step1: Create wallet package + // Step2: Update options to take the root controller messenger, no controllers and no restricted controller + // Step3: Add controllers and services one-by-one, starting with keyring + // Step4: After adding the network controller, add `createProviderEngine` method for RPC pipeline + // Step5: Handle state reset + // + // The wallet API is called through the messenger, same as for a controller + // Actions and events + // We would also expose actions and events from internal controllers/services + // Leave the controller API to the clients +} diff --git a/packages/wallet-framework/src/index.ts b/packages/wallet-framework/src/index.ts new file mode 100644 index 0000000000..6221d24c08 --- /dev/null +++ b/packages/wallet-framework/src/index.ts @@ -0,0 +1 @@ +export { WalletFramework } from './WalletFramework'; diff --git a/packages/wallet-framework/src/middleware/createEthAccountsMiddleware.ts b/packages/wallet-framework/src/middleware/createEthAccountsMiddleware.ts new file mode 100644 index 0000000000..b10dc3f48c --- /dev/null +++ b/packages/wallet-framework/src/middleware/createEthAccountsMiddleware.ts @@ -0,0 +1,84 @@ +import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { + type JsonRpcMiddleware, + createAsyncMiddleware, + type JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import type { ExecuteRestructedMethod } from '@metamask/permission-controller'; +import { errorCodes } from '@metamask/rpc-errors'; +import { + hasProperty, + type Json, + type JsonRpcParams, + type JsonRpcRequest, + type PendingJsonRpcResponse, +} from '@metamask/utils'; +import { RestrictedMethods } from 'src/permissions'; + +/** + * Create middleware for handling `eth_accounts`. + * + * @param args - Arguments + * @param args.messenger - A controller messenger that allows this middleware to ask for the + * permitted accounts. + * @returns The `eth_accounts` middleware function. + */ +export function createEthAccountsMiddleware({ + messenger, +}: { + messenger: RestrictedControllerMessenger< + 'createEthAccountsMiddleware', + ExecuteRestructedMethod, + never, + ExecuteRestructedMethod['type'], + never + >; +}): JsonRpcMiddleware { + return createAsyncMiddleware( + async ( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + ): Promise => { + if (request.method === 'eth_accounts') { + return next(); + } + if (!hasProperty(request, 'origin')) { + throw new Error('Missing origin'); + } else if (typeof request.origin !== 'string') { + throw new Error('Invalid origin type'); + } + try { + const accounts = await messenger.call( + 'PermissionController:executeRestrictedMethod', + request.origin, + RestrictedMethods.eth_accounts, + ); + response.result = accounts; + return undefined; + } catch (error) { + if ( + isErrorWithCode(error) && + error.code === errorCodes.provider.unauthorized + ) { + response.result = []; + return undefined; + } + throw error; + } + }, + ); +} + +/** + * Type guard for determining whether the given value is an error object with a + * `code` property, such as an instance of Error. + * + * TODO: Move this to @metamask/utils. + * + * @param error - The object to check. + * @returns True if `error` has a `code`, false otherwise. + */ +function isErrorWithCode(error: unknown): error is { code: string | number } { + return typeof error === 'object' && error !== null && 'code' in error; +} diff --git a/packages/wallet-framework/src/middleware/createOriginMiddleware.ts b/packages/wallet-framework/src/middleware/createOriginMiddleware.ts new file mode 100644 index 0000000000..5f5cc32507 --- /dev/null +++ b/packages/wallet-framework/src/middleware/createOriginMiddleware.ts @@ -0,0 +1,37 @@ +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + JsonRpcMiddleware, +} from '@metamask/json-rpc-engine'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +/** + * Returns a middleware function that attaches the sender origin to the request. + * + * @param options - The middleware options. + * @param options.origin - The origin of the request sender. + * @returns The middleware funciton. + */ +export default function createOriginMiddleware({ + origin, +}: { + origin: string; +}): JsonRpcMiddleware { + return function originMiddleware( + request: JsonRpcRequest, + _response: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + _end: JsonRpcEngineEndCallback, + ): void { + // @ts-expect-error This violates the request type. + // TODO: Move this to the "context" object, after updating JSON-RPC engine to have a request + // context. + request.origin = origin; + next(); + }; +} diff --git a/packages/wallet-framework/src/permissions.ts b/packages/wallet-framework/src/permissions.ts new file mode 100644 index 0000000000..28e945aaab --- /dev/null +++ b/packages/wallet-framework/src/permissions.ts @@ -0,0 +1,264 @@ +import { + type CaveatSpecificationMap, + type CaveatValidator, + type PermissionConstraint, + type PermissionFactory, + type PermissionOptions, + type PermissionSpecificationMap, + PermissionType, + type RestrictedMethod, + type RestrictedMethodOptions, + constructPermission, +} from '@metamask/permission-controller'; +import { hasProperty, type Hex } from '@metamask/utils'; + +declare const CaveatTypes: { + readonly restrictReturnedAccounts: 'restrictReturnedAccounts'; +}; + +export const RestrictedMethods = { + // These properties match RPC method names, which follow a different naming convention + /* eslint-disable @typescript-eslint/naming-convention */ + eth_accounts: 'eth_accounts', + /* eslint-enable @typescript-eslint/naming-convention */ +} as const; + +type DefaultCaveats = { + restrictReturnedAccounts: { + type: typeof CaveatTypes.restrictReturnedAccounts; + + value: Hex[]; + }; +}; + +export type DefaultCaveatSpecification = { + type: typeof CaveatTypes.restrictReturnedAccounts; + + decorator: ( + method: (args: RestrictedMethodOptions) => Promise, + caveat: DefaultCaveats['restrictReturnedAccounts'], + ) => (args: RestrictedMethodOptions) => Promise; + + merger: (leftValue: Hex[], rightValue: Hex[]) => [] | [Hex[], Hex[]]; + + validator: CaveatValidator; +}; + +/** + * Validates the accounts associated with a caveat. In essence, ensures that + * the accounts value is an array of non-empty strings, and that each string + * corresponds to a PreferencesController identity. + * + * @param caveatAccounts - The accounts associated with the caveat. + * @param getAccounts - Returns a list of all wallet accounts. + */ +function validateCaveatAccounts( + caveatAccounts: unknown, + getAccounts: () => Hex[], +) { + if (!Array.isArray(caveatAccounts) || caveatAccounts.length === 0) { + throw new Error( + `${RestrictedMethods.eth_accounts} error: Expected non-empty array of Ethereum addresses.`, + ); + } + + const accounts = getAccounts(); + caveatAccounts.forEach((address) => { + if (!address || typeof address !== 'string') { + throw new Error( + `${ + RestrictedMethods.eth_accounts + } error: Expected an array of Ethereum addresses. Received: "${ + typeof address === 'string' ? address : `typeof ${typeof address}` + }".`, + ); + } + + if ( + !accounts.some( + (account) => account.toLowerCase() === address.toLowerCase(), + ) + ) { + throw new Error( + `${RestrictedMethods.eth_accounts} error: Received unrecognized address: "${address}".`, + ); + } + }); +} + +/** + * Factory functions for all default caveat types. + */ +export const CaveatFactories = Object.freeze({ + [CaveatTypes.restrictReturnedAccounts]: (accounts: Hex[]) => { + return { type: CaveatTypes.restrictReturnedAccounts, value: accounts }; + }, +}); + +/** + * Get the specifications for all default caveats used by the PermissionController. + * + * @param options - Options. + * @param options.getAccounts - Returns a list of all wallet accounts. + * @returns All default caveat specifications. + */ +export function getDefaultCaveatSpecifications({ + getAccounts, +}: { + getAccounts: () => Hex[]; +}): CaveatSpecificationMap { + return { + [CaveatTypes.restrictReturnedAccounts]: { + type: CaveatTypes.restrictReturnedAccounts, + + decorator: ( + method: (args: RestrictedMethodOptions) => Promise, + caveat: DefaultCaveats['restrictReturnedAccounts'], + ) => { + return async (args: RestrictedMethodOptions) => { + const result = await method(args); + return result.filter((account: Hex) => + caveat.value.includes(account), + ); + }; + }, + + merger: (leftValue: Hex[], rightValue: Hex[]) => { + const newValue = Array.from(new Set([...leftValue, ...rightValue])); + const diff = newValue.filter((value) => !leftValue.includes(value)); + return [newValue, diff]; + }, + + validator: ( + caveat: { + type: typeof CaveatTypes.restrictReturnedAccounts; + value: unknown; + }, + _origin?: string, + _target?: string, + ) => validateCaveatAccounts(caveat.value, getAccounts), + }, + }; +} + +type DefaultPermissions = { + [RestrictedMethods.eth_accounts]: { + caveats: [DefaultCaveats['restrictReturnedAccounts']]; + date: number; + id: string; + invoker: string; + parentCapability: typeof RestrictedMethods.eth_accounts; + }; +}; + +export type DefaultPermissionSpecification = { + permissionType: PermissionType.RestrictedMethod; + targetName: typeof RestrictedMethods.eth_accounts; + allowedCaveats: [typeof CaveatTypes.restrictReturnedAccounts]; + factory: PermissionFactory< + DefaultPermissions[typeof RestrictedMethods.eth_accounts], + Record + >; + methodImplementation: RestrictedMethod<[], Hex[]>; + validator: ( + permission: PermissionConstraint, + origin?: string, + target?: string, + ) => void; +}; + +/** + * Get the specifications for all default permissions used by the PermissionController. + * + * @param options - Options bag. + * @param options.getAccounts - Returns a list of all wallet accounts. + * @returns All default permission specifications. + */ +export function getDefaultPermissionSpecifications({ + getAccounts, +}: { + getAccounts: () => Hex[]; +}): PermissionSpecificationMap { + return { + [RestrictedMethods.eth_accounts]: { + permissionType: PermissionType.RestrictedMethod, + targetName: RestrictedMethods.eth_accounts, + allowedCaveats: [CaveatTypes.restrictReturnedAccounts], + + factory: ( + permissionOptions: PermissionOptions< + DefaultPermissions['eth_accounts'] + >, + requestData?: Record, + ) => { + // This occurs when we use PermissionController.grantPermissions(). + if (requestData === undefined) { + return constructPermission({ + ...permissionOptions, + }); + } + + // The approved accounts will be further validated as part of the caveat. + if (!hasProperty(requestData, 'approvedAccounts')) { + throw new Error( + `${RestrictedMethods.eth_accounts} error: No approved accounts specified.`, + ); + } + const { approvedAccounts } = requestData; + if (!Array.isArray(approvedAccounts)) { + throw new Error( + `${ + RestrictedMethods.eth_accounts + } error: Invalid approved accounts: ${typeof approvedAccounts}`, + ); + } + + return constructPermission({ + ...permissionOptions, + caveats: [ + CaveatFactories[CaveatTypes.restrictReturnedAccounts]( + approvedAccounts, + ), + ], + }); + }, + methodImplementation: async () => { + // TODO: Add sorting by last selected, once AccountsController has been integrated + return getAccounts(); + }, + validator: (permission, _origin, _target) => { + const { caveats } = permission; + if ( + !caveats || + caveats.length !== 1 || + caveats[0].type !== CaveatTypes.restrictReturnedAccounts + ) { + throw new Error( + `${RestrictedMethods.eth_accounts} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictReturnedAccounts}".`, + ); + } + }, + }, + }; +} + +/** + * All default unrestricted methods. + * + * Unrestricted methods are ignored by the permission system, but every + * JSON-RPC request seen by the permission system must correspond to a + * restricted or unrestricted method, or the request will be rejected with a + * "method not found" error. + */ +export const defaultUnrestrictedMethods = Object.freeze([ + 'eth_coinbase', + // TODO: implement this middleware + 'eth_requestAccounts', + 'eth_signTypedData', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'personal_ecRecover', + 'personal_sign', + 'web3_clientVersion', +]); diff --git a/packages/wallet-framework/tsconfig.build.json b/packages/wallet-framework/tsconfig.build.json new file mode 100644 index 0000000000..479d41fe88 --- /dev/null +++ b/packages/wallet-framework/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/types", + "rootDir": "./src" + }, + "references": [ + { "path": "../approval-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../permission-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-framework/tsconfig.json b/packages/wallet-framework/tsconfig.json new file mode 100644 index 0000000000..e2445aebd4 --- /dev/null +++ b/packages/wallet-framework/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../approval-controller" }, + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../json-rpc-engine" }, + { "path": "../keyring-controller" }, + { "path": "../permission-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-framework/typedoc.json b/packages/wallet-framework/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/wallet-framework/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/scripts/update-readme-content.ts b/scripts/update-readme-content.ts index afbf1ceff2..e960739aa3 100755 --- a/scripts/update-readme-content.ts +++ b/scripts/update-readme-content.ts @@ -56,10 +56,7 @@ async function retrieveWorkspaces(): Promise { '--verbose', ]); - return stdout - .split('\n') - .map((line) => JSON.parse(line)) - .slice(1); + return stdout.split('\n').map((line) => JSON.parse(line)); } /** diff --git a/tsconfig.build.json b/tsconfig.build.json index 4e485ea189..9a800439bd 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,7 +34,8 @@ { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, { "path": "./packages/transaction-controller/tsconfig.build.json" }, - { "path": "./packages/user-operation-controller/tsconfig.build.json" } + { "path": "./packages/user-operation-controller/tsconfig.build.json" }, + { "path": "./packages/wallet-framework/tsconfig.build.json" } ], "files": [], "include": [] diff --git a/tsconfig.json b/tsconfig.json index e5c3ab12a8..6c541cab95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,8 @@ { "path": "./packages/selected-network-controller" }, { "path": "./packages/signature-controller" }, { "path": "./packages/transaction-controller" }, - { "path": "./packages/user-operation-controller" } + { "path": "./packages/user-operation-controller" }, + { "path": "./packages/wallet-framework" } ], "files": [], "include": ["./types"] diff --git a/yarn.lock b/yarn.lock index 7d3f5cedb5..8d3b1da7b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3877,6 +3877,30 @@ __metadata: languageName: node linkType: hard +"@metamask/wallet-framework@workspace:packages/wallet-framework": + version: 0.0.0-use.local + resolution: "@metamask/wallet-framework@workspace:packages/wallet-framework" + dependencies: + "@metamask/approval-controller": ^7.0.0 + "@metamask/auto-changelog": ^3.4.4 + "@metamask/base-controller": ^6.0.0 + "@metamask/controller-utils": ^11.0.0 + "@metamask/eth-json-rpc-middleware": ^12.1.1 + "@metamask/json-rpc-engine": ^9.0.0 + "@metamask/keyring-controller": ^17.1.0 + "@metamask/permission-controller": ^10.0.0 + "@metamask/rpc-errors": ^6.2.1 + "@metamask/utils": ^8.3.0 + "@types/jest": ^27.4.1 + deepmerge: ^4.2.2 + jest: ^27.5.1 + ts-jest: ^27.1.4 + typedoc: ^0.24.8 + typedoc-plugin-missing-exports: ^2.0.0 + typescript: ~4.9.5 + languageName: unknown + linkType: soft + "@ngraveio/bc-ur@npm:^1.1.5": version: 1.1.12 resolution: "@ngraveio/bc-ur@npm:1.1.12"