From 1356a4eb60761aadccda8cf4017330aa5aa2c57c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 11:04:31 +0200 Subject: [PATCH 1/9] feat: add Ethereum MPC keyring package --- README.md | 3 + packages/keyring-eth-mpc/CHANGELOG.md | 19 +++++ packages/keyring-eth-mpc/LICENSE | 15 ++++ packages/keyring-eth-mpc/README.md | 14 ++++ packages/keyring-eth-mpc/jest.config.js | 32 ++++++++ packages/keyring-eth-mpc/package.json | 81 ++++++++++++++++++++ packages/keyring-eth-mpc/src/index.ts | 1 + packages/keyring-eth-mpc/src/types.ts | 39 ++++++++++ packages/keyring-eth-mpc/tsconfig.build.json | 11 +++ packages/keyring-eth-mpc/tsconfig.json | 13 ++++ tsconfig.build.json | 1 + tsconfig.json | 1 + yarn.lock | 19 +++++ 13 files changed, 249 insertions(+) create mode 100644 packages/keyring-eth-mpc/CHANGELOG.md create mode 100644 packages/keyring-eth-mpc/LICENSE create mode 100644 packages/keyring-eth-mpc/README.md create mode 100644 packages/keyring-eth-mpc/jest.config.js create mode 100644 packages/keyring-eth-mpc/package.json create mode 100644 packages/keyring-eth-mpc/src/index.ts create mode 100644 packages/keyring-eth-mpc/src/types.ts create mode 100644 packages/keyring-eth-mpc/tsconfig.build.json create mode 100644 packages/keyring-eth-mpc/tsconfig.json diff --git a/README.md b/README.md index 4fcb5d59f..8c5a001e5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/eth-hd-keyring`](packages/keyring-eth-hd) - [`@metamask/eth-ledger-bridge-keyring`](packages/keyring-eth-ledger-bridge) - [`@metamask/eth-money-keyring`](packages/keyring-eth-money) +- [`@metamask/eth-mpc-keyring`](packages/keyring-eth-mpc) - [`@metamask/eth-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) @@ -52,6 +53,7 @@ linkStyle default opacity:0.5 eth_hd_keyring(["@metamask/eth-hd-keyring"]); eth_ledger_bridge_keyring(["@metamask/eth-ledger-bridge-keyring"]); eth_money_keyring(["@metamask/eth-money-keyring"]); + eth_mpc_keyring(["@metamask/eth-mpc-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); @@ -79,6 +81,7 @@ linkStyle default opacity:0.5 eth_money_keyring --> keyring_api; eth_money_keyring --> keyring_sdk; eth_money_keyring --> keyring_utils; + eth_mpc_keyring --> keyring_utils; eth_qr_keyring --> keyring_api; eth_qr_keyring --> keyring_sdk; eth_qr_keyring --> keyring_utils; diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md new file mode 100644 index 000000000..b9ed8406b --- /dev/null +++ b/packages/keyring-eth-mpc/CHANGELOG.md @@ -0,0 +1,19 @@ +# 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] + +### Added + +- Initial release of `@metamask/eth-mpc-keyring` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - The V1 MPC keyring implementation is not included yet. +- Add a V2 keyring implementation, available via the `./v2` export ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) + - `MpcKeyring` (V2) implements the unified V2 `Keyring` interface with type `KeyringType.Mpc`. + - Account creation uses custom options: `{ type: 'custom', mode: 'create' | 'import' }`, and the keyring declares `custom.createAccounts` in its capabilities. + - Exposes `rotateKeyShares`, `checkKeyShare`, and `syncKeyShare` maintenance operations. + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-mpc/LICENSE b/packages/keyring-eth-mpc/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-mpc/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md new file mode 100644 index 000000000..dcdad8239 --- /dev/null +++ b/packages/keyring-eth-mpc/README.md @@ -0,0 +1,14 @@ +# MPC Keyring + +A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing, built on top of the [MFA Wallet SDK](https://github.com/MetaMask/mfa-wallet-sdk). + +> [!NOTE] +> The full V1 MPC keyring implementation is not included yet. Implement the `MpcKeyringV1` contract yourself and pass it to the V2 keyring; the official implementation will be provided in a later release, once the MFA Wallet SDK packages are releasable. + +## Installation + +`yarn add @metamask/eth-mpc-keyring` + +or + +`npm install @metamask/eth-mpc-keyring` diff --git a/packages/keyring-eth-mpc/jest.config.js b/packages/keyring-eth-mpc/jest.config.js new file mode 100644 index 000000000..afeeab368 --- /dev/null +++ b/packages/keyring-eth-mpc/jest.config.js @@ -0,0 +1,32 @@ +/* + * 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 array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['./src/tests'], + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/keyring-eth-mpc/package.json b/packages/keyring-eth-mpc/package.json new file mode 100644 index 000000000..6d354e3f5 --- /dev/null +++ b/packages/keyring-eth-mpc/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/eth-mpc-keyring", + "version": "0.0.0", + "description": "A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key management and signing", + "keywords": [ + "ethereum", + "keyring" + ], + "homepage": "https://github.com/MetaMask/accounts/tree/main/packages/keyring-eth-mpc#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-mpc-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-mpc-keyring", + "publish:preview": "yarn npm publish --tag preview", + "test": "yarn test:source && yarn test:types", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test:source": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:types": "../../scripts/tsd-test.sh ./src", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@ethereumjs/tx": "^5.4.0", + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-sig-util": "^9.0.0", + "@metamask/keyring-utils": "^5.0.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": ">=22" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts new file mode 100644 index 000000000..dbf2118dd --- /dev/null +++ b/packages/keyring-eth-mpc/src/index.ts @@ -0,0 +1 @@ +export type { MpcKeyringSetupParams, MpcKeyringV1 } from './types'; diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts new file mode 100644 index 000000000..194ecc0ce --- /dev/null +++ b/packages/keyring-eth-mpc/src/types.ts @@ -0,0 +1,39 @@ +import type { EthKeyring } from '@metamask/keyring-utils'; + +export type MpcKeyringSetupParams = { + mode: 'create' | 'import'; +}; + +/** + * The V1 MPC keyring contract. + * + * The full implementation is not shipped yet: clients provide their own + * implementation, which the V2 `MpcKeyring` (from + * `@metamask/eth-mpc-keyring/v2`) adapts to the unified V2 `Keyring` + * interface. + */ +export type MpcKeyringV1 = EthKeyring & { + /** + * Run key generation or import. + * + * @param mode - The MPC setup mode. + */ + init(mode?: MpcKeyringSetupParams['mode']): Promise; + + /** + * Rotate the MPC key shares. + */ + rotateKeyShares(): Promise; + + /** + * Check the MPC key share state. + * + * @returns Whether the key share is up to date. + */ + checkKeyShare(): Promise; + + /** + * Synchronize the MPC key share. + */ + syncKeyShare(): Promise; +}; diff --git a/packages/keyring-eth-mpc/tsconfig.build.json b/packages/keyring-eth-mpc/tsconfig.build.json new file mode 100644 index 000000000..9e6689dd9 --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src" + }, + "references": [{ "path": "../keyring-utils/tsconfig.build.json" }], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-eth-mpc/tsconfig.json b/packages/keyring-eth-mpc/tsconfig.json new file mode 100644 index 000000000..786b4159e --- /dev/null +++ b/packages/keyring-eth-mpc/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../keyring-utils" + } + ], + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 29029bd74..a9160f780 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "./packages/keyring-eth-hd/tsconfig.build.json" }, { "path": "./packages/keyring-eth-money/tsconfig.build.json" }, { "path": "./packages/keyring-eth-ledger-bridge/tsconfig.build.json" }, + { "path": "./packages/keyring-eth-mpc/tsconfig.build.json" }, { "path": "./packages/keyring-eth-qr/tsconfig.build.json" }, { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index aa0a2a8db..3a7166454 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/keyring-api" }, { "path": "./packages/keyring-eth-hd" }, { "path": "./packages/keyring-eth-ledger-bridge" }, + { "path": "./packages/keyring-eth-mpc" }, { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-internal-api" }, diff --git a/yarn.lock b/yarn.lock index a09280a15..dc6d95a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1986,6 +1986,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-mpc": + version: 0.0.0-use.local + resolution: "@metamask/eth-mpc-keyring@workspace:packages/keyring-eth-mpc" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/eth-sig-util": "npm:^9.0.0" + "@metamask/keyring-utils": "npm:^5.0.0" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr": version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" From 69fb882f4db396e978ee1fa79d3548e9363d71bb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 29 Sep 2026 14:19:26 +0200 Subject: [PATCH 2/9] feat: add MPC keyring v2 support --- README.md | 2 + packages/keyring-api/CHANGELOG.md | 1 + .../keyring-api/src/v2/api/keyring-type.ts | 6 + .../keyring-api/src/v2/api/keyring.test-d.ts | 1 + packages/keyring-eth-mpc/README.md | 21 + packages/keyring-eth-mpc/package.json | 17 +- packages/keyring-eth-mpc/src/v2/index.ts | 5 + .../src/v2/mpc-keyring.test.ts | 534 ++++++++++++++++++ .../keyring-eth-mpc/src/v2/mpc-keyring.ts | 217 +++++++ packages/keyring-eth-mpc/tsconfig.build.json | 12 +- packages/keyring-eth-mpc/tsconfig.json | 6 + packages/keyring-eth-mpc/v2.js | 3 + yarn.lock | 2 + 13 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 packages/keyring-eth-mpc/src/v2/index.ts create mode 100644 packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts create mode 100644 packages/keyring-eth-mpc/src/v2/mpc-keyring.ts create mode 100644 packages/keyring-eth-mpc/v2.js diff --git a/README.md b/README.md index 8c5a001e5..d67e81de4 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ linkStyle default opacity:0.5 eth_money_keyring --> keyring_api; eth_money_keyring --> keyring_sdk; eth_money_keyring --> keyring_utils; + eth_mpc_keyring --> keyring_api; + eth_mpc_keyring --> keyring_sdk; eth_mpc_keyring --> keyring_utils; eth_qr_keyring --> keyring_api; eth_qr_keyring --> keyring_sdk; diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 5e3000b51..0f68abd9a 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Includes an optional `scopes` field, since the scope cannot always be detected from the address alone (e.g. an EVM address is valid on every EVM chain). When omitted, the keyring decides the scopes. - Add `address` capability to `KeyringCapabilities`, declaring whether a keyring supports importing watch-only accounts by address ([#643](https://github.com/MetaMask/accounts/pull/643)) - Add `KeyringType.WatchOnly` keyring type ([#644](https://github.com/MetaMask/accounts/pull/644)) +- Add `KeyringType.Mpc` keyring type ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) ## [24.1.0] diff --git a/packages/keyring-api/src/v2/api/keyring-type.ts b/packages/keyring-api/src/v2/api/keyring-type.ts index 35d7bc42b..473b64420 100644 --- a/packages/keyring-api/src/v2/api/keyring-type.ts +++ b/packages/keyring-api/src/v2/api/keyring-type.ts @@ -54,4 +54,10 @@ export enum KeyringType { * without any signing capability. */ WatchOnly = 'watch-only', + + /** + * Represents a keyring that uses Multi-Party Computation (MPC) for key + * management and signing. + */ + Mpc = 'mpc', } diff --git a/packages/keyring-api/src/v2/api/keyring.test-d.ts b/packages/keyring-api/src/v2/api/keyring.test-d.ts index ee00a3164..2f987582f 100644 --- a/packages/keyring-api/src/v2/api/keyring.test-d.ts +++ b/packages/keyring-api/src/v2/api/keyring.test-d.ts @@ -24,6 +24,7 @@ import type { ImportPrivateKeyFormat } from './private-key'; // Test KeyringType enum expectAssignable(KeyringType.Hd); expectAssignable(KeyringType.Money); +expectAssignable(KeyringType.Mpc); expectAssignable(KeyringType.PrivateKey); expectAssignable(KeyringType.Qr); expectAssignable(KeyringType.Snap); diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md index dcdad8239..a0824c8d8 100644 --- a/packages/keyring-eth-mpc/README.md +++ b/packages/keyring-eth-mpc/README.md @@ -12,3 +12,24 @@ A Keyring for Ethereum accounts that uses Multi-Party Computation (MPC) for key or `npm install @metamask/eth-mpc-keyring` + +## V2 Keyring + +This package also provides a V2 keyring that implements the unified `Keyring` interface from `@metamask/keyring-api/v2`. Import it from `@metamask/eth-mpc-keyring/v2`: + +```ts +import type { MpcKeyringV1 } from '@metamask/eth-mpc-keyring'; +import { MpcKeyring } from '@metamask/eth-mpc-keyring/v2'; + +// Provided by the client: an implementation of the V1 MPC keyring contract. +const legacyKeyring: MpcKeyringV1 = createMpcKeyringV1(); + +const keyring = new MpcKeyring({ legacyKeyring }); + +const [account] = await keyring.createAccounts({ + type: 'custom', + mode: 'create', +}); +``` + +The V2 keyring has type `KeyringType.Mpc` (`'mpc'`), declares `custom.createAccounts` in its capabilities, and accepts `{ type: 'custom', mode }` options in `createAccounts`. diff --git a/packages/keyring-eth-mpc/package.json b/packages/keyring-eth-mpc/package.json index 6d354e3f5..f5976b819 100644 --- a/packages/keyring-eth-mpc/package.json +++ b/packages/keyring-eth-mpc/package.json @@ -16,7 +16,8 @@ "url": "https://github.com/MetaMask/accounts.git" }, "files": [ - "dist/" + "dist/", + "v2.js" ], "sideEffects": false, "main": "./dist/index.cjs", @@ -32,6 +33,16 @@ "default": "./dist/index.cjs" } }, + "./v2": { + "import": { + "types": "./dist/v2/index.d.mts", + "default": "./dist/v2/index.mjs" + }, + "require": { + "types": "./dist/v2/index.d.cts", + "default": "./dist/v2/index.cjs" + } + }, "./package.json": "./package.json" }, "publishConfig": { @@ -55,6 +66,9 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/keyring-api": "^24.1.0", + "@metamask/keyring-sdk": "^3.1.0", + "@metamask/keyring-utils": "^5.0.0", "@metamask/utils": "^11.11.0" }, "devDependencies": { @@ -63,7 +77,6 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", "@metamask/eth-sig-util": "^9.0.0", - "@metamask/keyring-utils": "^5.0.0", "@ts-bridge/cli": "^0.6.3", "@types/jest": "^29.5.12", "deepmerge": "^4.2.2", diff --git a/packages/keyring-eth-mpc/src/v2/index.ts b/packages/keyring-eth-mpc/src/v2/index.ts new file mode 100644 index 000000000..0a8efe443 --- /dev/null +++ b/packages/keyring-eth-mpc/src/v2/index.ts @@ -0,0 +1,5 @@ +export { + MpcKeyring, + type MpcCreateAccountOptions, + type MpcKeyringOptions, +} from './mpc-keyring'; diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts new file mode 100644 index 000000000..8c3ed9819 --- /dev/null +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts @@ -0,0 +1,534 @@ +import type { TypedTxData } from '@ethereumjs/tx'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import { + EthAccountType, + EthMethod, + EthScope, + KeyringAccountEntropyTypeOption, +} from '@metamask/keyring-api'; +import type { KeyringRequest } from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import { EthKeyringMethod } from '@metamask/keyring-sdk/v2'; +import type { AccountId } from '@metamask/keyring-utils'; +import type { Hex, Json } from '@metamask/utils'; + +import type { MpcKeyringV1 } from '../types'; +import { MpcKeyring } from './mpc-keyring'; +import type { MpcCreateAccountOptions } from './mpc-keyring'; + +const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; +const OTHER_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; + +/** + * Expected methods supported by MPC keyring accounts. + */ +const EXPECTED_METHODS = [ + EthMethod.SignTransaction, + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + EthKeyringMethod.SignEip7702Authorization, +]; + +/** + * The legacy MPC keyring methods that the V2 wrapper delegates to, mocked + * for testing. + */ +type MockMpcKeyringV1 = { + type: string; + getAccounts: jest.Mock; + init: jest.Mock; + serialize: jest.Mock; + deserialize: jest.Mock; + addAccounts: jest.Mock; + getAppKeyAddress: jest.Mock; + signTransaction: jest.Mock; + signPersonalMessage: jest.Mock; + signTypedData: jest.Mock; + signEip7702Authorization: jest.Mock; + rotateKeyShares: jest.Mock; + checkKeyShare: jest.Mock; + syncKeyShare: jest.Mock; +}; + +/** + * Create a mock of the legacy MPC keyring with all the methods the V2 + * wrapper delegates to. + * + * @returns The mocked inner keyring. + */ +function createInner(): MockMpcKeyringV1 { + return { + type: 'MPC Keyring', + getAccounts: jest.fn().mockResolvedValue([] as Hex[]), + init: jest.fn().mockResolvedValue(undefined), + serialize: jest.fn().mockResolvedValue({}), + deserialize: jest.fn().mockResolvedValue(undefined), + addAccounts: jest.fn(), + getAppKeyAddress: jest.fn(), + signTransaction: jest.fn(), + signPersonalMessage: jest.fn(), + signTypedData: jest.fn(), + signEip7702Authorization: jest.fn(), + rotateKeyShares: jest.fn().mockResolvedValue(undefined), + checkKeyShare: jest.fn().mockResolvedValue(true), + syncKeyShare: jest.fn().mockResolvedValue(undefined), + }; +} + +/** + * Create a V2 wrapper around a mocked legacy MPC keyring. + * + * @param args - Setup arguments. + * @param args.accounts - The addresses reported by the inner keyring. + * @returns The wrapper and the mocked inner keyring. + */ +function setup({ + accounts = [] as Hex[], +}: { + accounts?: Hex[]; +} = {}): { + wrapper: MpcKeyring; + inner: MockMpcKeyringV1; +} { + const inner = createInner(); + inner.getAccounts.mockResolvedValue(accounts); + + const wrapper = new MpcKeyring({ + legacyKeyring: inner as unknown as MpcKeyringV1, + }); + + return { wrapper, inner }; +} + +/** + * Create a wrapper whose inner keyring already manages the single account, + * and resolve the registered account ID. + * + * @returns The wrapper, the mocked inner keyring, and the account ID. + */ +async function createWrapperWithAccount(): Promise<{ + wrapper: MpcKeyring; + inner: MockMpcKeyringV1; + accountId: AccountId; +}> { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + const [account] = await wrapper.getAccounts(); + const accountId = account?.id ?? ('' as AccountId); + return { wrapper, inner, accountId }; +} + +/** + * Create a minimal `KeyringRequest` for testing. + * + * @param accountId - The account ID to use in the request. + * @param method - The method name for the request. + * @param params - Optional array of parameters for the request. + * @returns A `KeyringRequest` object. + */ +function createMockRequest( + accountId: AccountId, + method: string, + params: Json[] = [], +): KeyringRequest { + return { + id: '00000000-0000-0000-0000-000000000000', + scope: EthScope.Eoa, + account: accountId, + origin: 'http://localhost', + request: { + method, + params, + }, + }; +} + +describe('MpcKeyring (v2 wrapper)', () => { + describe('constructor', () => { + it('creates a wrapper with the correct type and capabilities', () => { + const { wrapper } = setup(); + + expect(wrapper.type).toBe(KeyringType.Mpc); + expect(wrapper.type).toBe('mpc'); + expect(wrapper.capabilities).toStrictEqual({ + scopes: [EthScope.Eoa], + custom: { + createAccounts: true, + }, + }); + }); + }); + + describe('getAccounts', () => { + it('returns an empty list when the inner keyring is not initialized', async () => { + const { wrapper } = setup(); + + expect(await wrapper.getAccounts()).toStrictEqual([]); + }); + + it('returns the single account with the expected structure', async () => { + const { wrapper } = setup({ accounts: [MOCK_ADDRESS] }); + + const accounts = await wrapper.getAccounts(); + + expect(accounts).toHaveLength(1); + const account = accounts[0]; + expect(account?.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + expect(account?.type).toBe(EthAccountType.Eoa); + expect(account?.address).toBe(MOCK_ADDRESS); + expect(account?.scopes).toStrictEqual([EthScope.Eoa]); + expect(account?.methods).toStrictEqual(EXPECTED_METHODS); + expect(account?.options.entropy?.type).toBe( + KeyringAccountEntropyTypeOption.Custom, + ); + }); + + it('returns the same account on repeated calls', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + + const [first] = await wrapper.getAccounts(); + const [second] = await wrapper.getAccounts(); + + expect(second?.id).toBe(first?.id); + expect(inner.getAccounts).toHaveBeenCalledTimes(2); + }); + + it('throws if the inner keyring reports more than one account', async () => { + const { wrapper, inner } = setup(); + inner.getAccounts.mockResolvedValue([MOCK_ADDRESS, OTHER_ADDRESS]); + + await expect(wrapper.getAccounts()).rejects.toThrow( + 'MpcKeyring: supports at most one account', + ); + }); + }); + + describe('createAccounts', () => { + it('runs the create ceremony via the inner keyring', async () => { + const { wrapper, inner } = setup(); + inner.getAccounts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([MOCK_ADDRESS]); + + const accounts = await wrapper.createAccounts({ + type: 'custom', + mode: 'create', + }); + + expect(inner.init).toHaveBeenCalledWith('create'); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(MOCK_ADDRESS); + }); + + it('runs the backup import flow via the inner keyring', async () => { + const { wrapper, inner } = setup(); + inner.getAccounts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([MOCK_ADDRESS]); + + const accounts = await wrapper.createAccounts({ + type: 'custom', + mode: 'import', + }); + + expect(inner.init).toHaveBeenCalledWith('import'); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(MOCK_ADDRESS); + }); + + it('falls back to the setup params stored via deserialize when mode is omitted', async () => { + const { wrapper, inner } = setup(); + inner.getAccounts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([MOCK_ADDRESS]); + + const accounts = await wrapper.createAccounts({ type: 'custom' }); + + expect(inner.init).toHaveBeenCalledWith(undefined); + expect(accounts).toHaveLength(1); + }); + + it('is idempotent when the account already exists', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + + const accounts = await wrapper.createAccounts({ + type: 'custom', + mode: 'create', + }); + + expect(inner.init).not.toHaveBeenCalled(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(MOCK_ADDRESS); + }); + + it('throws when the inner keyring does not produce an account', async () => { + const { wrapper, inner } = setup(); + + await expect( + wrapper.createAccounts({ type: 'custom', mode: 'create' }), + ).rejects.toThrow('MpcKeyring: account creation failed'); + + expect(inner.init).toHaveBeenCalledWith('create'); + }); + + it('rejects unsupported account creation types', async () => { + const { wrapper, inner } = setup(); + + const invalidOptions = { + type: 'bip44:derive-index', + entropySource: 'entropy-source', + groupIndex: 0, + } as unknown as MpcCreateAccountOptions; + + await expect(wrapper.createAccounts(invalidOptions)).rejects.toThrow( + 'MpcKeyring: unsupported account creation type: bip44:derive-index', + ); + expect(inner.init).not.toHaveBeenCalled(); + }); + + it('rejects an invalid mode', async () => { + const { wrapper, inner } = setup(); + + const invalidOptions = { + type: 'custom', + mode: 'bogus', + } as unknown as MpcCreateAccountOptions; + + await expect(wrapper.createAccounts(invalidOptions)).rejects.toThrow( + "MpcKeyring: invalid mode: bogus. Expected 'create' or 'import'.", + ); + expect(inner.init).not.toHaveBeenCalled(); + }); + }); + + describe('getAccount', () => { + it('returns the account by id', async () => { + const { wrapper } = setup({ accounts: [MOCK_ADDRESS] }); + + const [account] = await wrapper.getAccounts(); + + expect( + await wrapper.getAccount(account?.id ?? ('' as AccountId)), + ).toStrictEqual(account); + }); + + it('throws for an unknown account id', async () => { + const { wrapper } = setup({ accounts: [MOCK_ADDRESS] }); + + await expect( + wrapper.getAccount('00000000-0000-0000-0000-000000000000' as AccountId), + ).rejects.toThrow( + 'Account not found for id: 00000000-0000-0000-0000-000000000000', + ); + }); + }); + + describe('deleteAccount', () => { + it('rejects deletion', async () => { + const { wrapper } = setup({ accounts: [MOCK_ADDRESS] }); + + const [account] = await wrapper.getAccounts(); + + await expect( + wrapper.deleteAccount(account?.id ?? ('' as AccountId)), + ).rejects.toThrow('MpcKeyring: deleting accounts is not supported'); + }); + }); + + describe('serialize and deserialize', () => { + it('delegates serialization to the inner keyring', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + inner.serialize.mockResolvedValue({ keyShare: 'mock' }); + + expect(await wrapper.serialize()).toStrictEqual({ + keyShare: 'mock', + }); + expect(inner.serialize).toHaveBeenCalledTimes(1); + }); + + it('delegates deserialization and rebuilds the account registry', async () => { + const { wrapper, inner } = setup(); + inner.getAccounts.mockResolvedValue([MOCK_ADDRESS]); + + await wrapper.deserialize({ keyShare: 'mock' }); + + expect(inner.deserialize).toHaveBeenCalledWith({ keyShare: 'mock' }); + + const accounts = await wrapper.getAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toBe(MOCK_ADDRESS); + + // The registry was rebuilt during deserialization, so `getAccount` + // resolves from the cache without consulting the inner keyring again. + const callCount = inner.getAccounts.mock.calls.length; + expect( + await wrapper.getAccount(accounts[0]?.id ?? ('' as AccountId)), + ).toBeDefined(); + expect(inner.getAccounts).toHaveBeenCalledTimes(callCount); + }); + }); + + describe('MPC maintenance operations', () => { + it('delegates rotateKeyShares to the inner keyring', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + + await wrapper.rotateKeyShares(); + + expect(inner.rotateKeyShares).toHaveBeenCalledTimes(1); + }); + + it('delegates checkKeyShare to the inner keyring', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + inner.checkKeyShare.mockResolvedValue(false); + + expect(await wrapper.checkKeyShare()).toBe(false); + expect(inner.checkKeyShare).toHaveBeenCalledTimes(1); + }); + + it('delegates syncKeyShare to the inner keyring', async () => { + const { wrapper, inner } = setup({ accounts: [MOCK_ADDRESS] }); + + await wrapper.syncKeyShare(); + + expect(inner.syncKeyShare).toHaveBeenCalledTimes(1); + }); + }); + + describe('submitRequest', () => { + it('signs a personal message via the inner keyring', async () => { + const { wrapper, inner, accountId } = await createWrapperWithAccount(); + inner.signPersonalMessage.mockResolvedValue('0xsignature'); + + const result = await wrapper.submitRequest( + createMockRequest(accountId, EthMethod.PersonalSign, ['0x68656c6c6f']), + ); + + expect(result).toBe('0xsignature'); + expect(inner.signPersonalMessage).toHaveBeenCalledWith( + MOCK_ADDRESS, + '0x68656c6c6f', + ); + }); + + it('signs typed data v4 via the inner keyring', async () => { + const { wrapper, inner, accountId } = await createWrapperWithAccount(); + inner.signTypedData.mockResolvedValue('0xsignature'); + + const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ], + Message: [{ name: 'content', type: 'string' }], + }, + domain: { + name: 'Test', + version: '1', + chainId: 1, + }, + primaryType: 'Message', + message: { + content: 'Hello!', + }, + }; + + const result = await wrapper.submitRequest( + createMockRequest(accountId, EthMethod.SignTypedDataV4, [ + MOCK_ADDRESS, + typedData, + ]), + ); + + expect(result).toBe('0xsignature'); + expect(inner.signTypedData).toHaveBeenCalledWith( + MOCK_ADDRESS, + typedData, + { version: SignTypedDataVersion.V4 }, + ); + }); + + it('signs a transaction via the inner keyring', async () => { + const { wrapper, inner, accountId } = await createWrapperWithAccount(); + const signedTx = { serialized: '0xdeadbeef' }; + inner.signTransaction.mockResolvedValue(signedTx); + + const txParams: TypedTxData = { + nonce: '0x00', + gasPrice: '0x09184e72a000', + gasLimit: '0x2710', + to: '0x0000000000000000000000000000000000000001', + value: '0x1000', + }; + + const result = await wrapper.submitRequest( + createMockRequest(accountId, EthMethod.SignTransaction, [ + txParams as unknown as Json, + ]), + ); + + expect(result).toStrictEqual(signedTx); + expect(inner.signTransaction).toHaveBeenCalledWith( + MOCK_ADDRESS, + expect.anything(), + ); + }); + + it('signs an EIP-7702 authorization via the inner keyring', async () => { + const { wrapper, inner, accountId } = await createWrapperWithAccount(); + inner.signEip7702Authorization.mockResolvedValue('0xsignature'); + + const authorization = [ + 1, + '0x0000000000000000000000000000000000000001', + 0, + ]; + + const result = await wrapper.submitRequest( + createMockRequest( + accountId, + EthKeyringMethod.SignEip7702Authorization, + [authorization as unknown as Json], + ), + ); + + expect(result).toBe('0xsignature'); + expect(inner.signEip7702Authorization).toHaveBeenCalledWith( + MOCK_ADDRESS, + authorization, + ); + }); + + it('rejects methods the account cannot handle', async () => { + const { wrapper, inner, accountId } = await createWrapperWithAccount(); + + await expect( + wrapper.submitRequest( + createMockRequest(accountId, EthMethod.Sign, [ + MOCK_ADDRESS, + '0x68656c6c6f', + ]), + ), + ).rejects.toThrow( + `Account ${accountId} cannot handle method: ${EthMethod.Sign}`, + ); + expect(inner.signPersonalMessage).not.toHaveBeenCalled(); + }); + + it('rejects unknown methods', async () => { + const { wrapper, accountId } = await createWrapperWithAccount(); + + await expect( + wrapper.submitRequest( + createMockRequest(accountId, 'unsupported_method'), + ), + ).rejects.toThrow( + `Account ${accountId} cannot handle method: unsupported_method`, + ); + }); + }); +}); diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts new file mode 100644 index 000000000..c430d618b --- /dev/null +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts @@ -0,0 +1,217 @@ +import { + EthAccountType, + EthMethod, + EthScope, + KeyringAccountEntropyTypeOption, +} from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { KeyringCapabilities, Keyring } from '@metamask/keyring-api/v2'; +import { EthKeyringMethod, EthKeyringWrapper } from '@metamask/keyring-sdk/v2'; +import type { AccountId } from '@metamask/keyring-utils'; +import { assert } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MpcKeyringSetupParams, MpcKeyringV1 } from '../types'; + +/** + * Methods supported by MPC keyring EOA accounts. + * MPC keyrings support signing methods, but not encryption or app keys. + */ +const MPC_KEYRING_METHODS = [ + EthMethod.SignTransaction, + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + EthKeyringMethod.SignEip7702Authorization, +]; + +/** + * Capabilities for the MPC keyring. + */ +const mpcKeyringCapabilities: KeyringCapabilities = { + scopes: [EthScope.Eoa], + custom: { + createAccounts: true, + }, +}; + +/** + * Options for creating an account in the MPC keyring. + */ +export type MpcCreateAccountOptions = { + /** + * The type of the options. + */ + type: 'custom'; + + /** + * The MPC setup mode. + */ + mode?: MpcKeyringSetupParams['mode']; +}; + +/** + * Options for constructing the V2 {@link MpcKeyring}. + */ +export type MpcKeyringOptions = { + /** + * The underlying "old" keyring instance that this wrapper adapts. + */ + legacyKeyring: MpcKeyringV1; +}; + +/** + * Concrete {@link Keyring} adapter for a {@link MpcKeyringV1} + * implementation. + * + * This wrapper exposes the accounts and signing capabilities of the legacy + * MPC keyring via the unified V2 interface. + */ +export class MpcKeyring + extends EthKeyringWrapper + implements Keyring +{ + constructor(options: MpcKeyringOptions) { + super({ + type: KeyringType.Mpc, + inner: options.legacyKeyring, + capabilities: mpcKeyringCapabilities, + }); + } + + /** + * Return all accounts managed by this keyring. + * + * @returns The list of managed accounts. + */ + async getAccounts(): Promise { + const addresses = await this.inner.getAccounts(); + + assert(addresses.length <= 1, 'MpcKeyring: supports at most one account'); + + return addresses.map((address) => { + // Check if we already have this account in the registry + const existingId = this.registry.getAccountId(address); + if (existingId) { + const cached = this.registry.get(existingId); + if (cached) { + return cached; + } + } + + return this.#createKeyringAccount(address); + }); + } + + /** + * Create the account according to the provided options. + * + * @param options - Options describing how to create the account. + * @returns A promise that resolves to a list containing the created + * account. + */ + async createAccounts( + options: MpcCreateAccountOptions, + ): Promise { + return this.withLock(async () => { + if (options.type !== 'custom') { + throw new Error( + `MpcKeyring: unsupported account creation type: ${String( + options.type, + )}. Use { type: 'custom', mode: 'create' | 'import' }.`, + ); + } + + const { mode } = options; + if (mode !== undefined && mode !== 'create' && mode !== 'import') { + throw new Error( + `MpcKeyring: invalid mode: ${String( + mode, + )}. Expected 'create' or 'import'.`, + ); + } + + // If the account already exists, creation is idempotent. + const existingAccounts = await this.getAccounts(); + if (existingAccounts.length > 0) { + return existingAccounts; + } + + await this.inner.init(mode); + + const accounts = await this.getAccounts(); + if (accounts.length === 0) { + throw new Error( + "MpcKeyring: account creation failed. Provide a 'mode' ('create' or 'import') or deserialize stored setup params first.", + ); + } + + return accounts; + }); + } + + /** + * Deleting accounts is not supported. + * + * @param _accountId - The account ID to delete. + */ + async deleteAccount(_accountId: AccountId): Promise { + throw new Error('MpcKeyring: deleting accounts is not supported'); + } + + /** + * Rotate the MPC key shares. + * + * @returns Resolves when the rotation is complete. + */ + async rotateKeyShares(): Promise { + return this.inner.rotateKeyShares(); + } + + /** + * Check the MPC key share state. + * + * @returns Whether the key share is up to date. + */ + async checkKeyShare(): Promise { + return this.inner.checkKeyShare(); + } + + /** + * Synchronize the MPC key share. + * + * @returns Resolves when the key share is synchronized. + */ + async syncKeyShare(): Promise { + return this.inner.syncKeyShare(); + } + + /** + * Create a {@link KeyringAccount} for the given address. + * + * @param address - The account address. + * @returns The created account. + */ + #createKeyringAccount(address: Hex): KeyringAccount { + const id = this.registry.register(address); + + const account: KeyringAccount = { + id, + type: EthAccountType.Eoa, + address, + scopes: [...this.capabilities.scopes], + methods: [...MPC_KEYRING_METHODS], + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Custom, + }, + }, + }; + + this.registry.set(account); + + return account; + } +} diff --git a/packages/keyring-eth-mpc/tsconfig.build.json b/packages/keyring-eth-mpc/tsconfig.build.json index 9e6689dd9..050cb1fd6 100644 --- a/packages/keyring-eth-mpc/tsconfig.build.json +++ b/packages/keyring-eth-mpc/tsconfig.build.json @@ -5,7 +5,17 @@ "outDir": "dist", "rootDir": "src" }, - "references": [{ "path": "../keyring-utils/tsconfig.build.json" }], + "references": [ + { + "path": "../keyring-utils/tsconfig.build.json" + }, + { + "path": "../keyring-api/tsconfig.build.json" + }, + { + "path": "../keyring-sdk/tsconfig.build.json" + } + ], "include": ["./src/**/*.ts"], "exclude": ["./src/**/*.test.ts"] } diff --git a/packages/keyring-eth-mpc/tsconfig.json b/packages/keyring-eth-mpc/tsconfig.json index 786b4159e..222170f96 100644 --- a/packages/keyring-eth-mpc/tsconfig.json +++ b/packages/keyring-eth-mpc/tsconfig.json @@ -6,6 +6,12 @@ "references": [ { "path": "../keyring-utils" + }, + { + "path": "../keyring-api" + }, + { + "path": "../keyring-sdk" } ], "include": ["./src"], diff --git a/packages/keyring-eth-mpc/v2.js b/packages/keyring-eth-mpc/v2.js new file mode 100644 index 000000000..faf7abd23 --- /dev/null +++ b/packages/keyring-eth-mpc/v2.js @@ -0,0 +1,3 @@ +// Re-exported for compatibility with Browserify. +// eslint-disable-next-line +module.exports = require('./dist/v2/index.cjs'); diff --git a/yarn.lock b/yarn.lock index dc6d95a69..ed02fdec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1995,6 +1995,8 @@ __metadata: "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/eth-sig-util": "npm:^9.0.0" + "@metamask/keyring-api": "npm:^24.1.0" + "@metamask/keyring-sdk": "npm:^3.1.0" "@metamask/keyring-utils": "npm:^5.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.3" From a2fd2d903d60360124e07e6f34b0d7a8ad6e52fe Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 30 Sep 2026 14:26:44 +0200 Subject: [PATCH 3/9] ci: add keyring-eth-mpc to PR titles --- .github/workflows/validate-pr-title.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 9ef3dbabb..e62e3fd92 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -47,6 +47,7 @@ jobs: keyring-eth-ledger-bridge keyring-eth-simple keyring-eth-trezor + keyring-eth-mpc keyring-internal-api keyring-internal-snap-client keyring-sdk From 38efdfaaf96f0f602e9230b82bb4c4f9c2456f4f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 09:43:54 +0200 Subject: [PATCH 4/9] chore: changelog --- packages/keyring-api/CHANGELOG.md | 2 +- packages/keyring-eth-mpc/CHANGELOG.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 0f68abd9a..b9f013e72 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Includes an optional `scopes` field, since the scope cannot always be detected from the address alone (e.g. an EVM address is valid on every EVM chain). When omitted, the keyring decides the scopes. - Add `address` capability to `KeyringCapabilities`, declaring whether a keyring supports importing watch-only accounts by address ([#643](https://github.com/MetaMask/accounts/pull/643)) - Add `KeyringType.WatchOnly` keyring type ([#644](https://github.com/MetaMask/accounts/pull/644)) -- Add `KeyringType.Mpc` keyring type ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- Add `KeyringType.Mpc` keyring type ([#641](https://github.com/MetaMask/accounts/pull/641)) ## [24.1.0] diff --git a/packages/keyring-eth-mpc/CHANGELOG.md b/packages/keyring-eth-mpc/CHANGELOG.md index b9ed8406b..eca2bf530 100644 --- a/packages/keyring-eth-mpc/CHANGELOG.md +++ b/packages/keyring-eth-mpc/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of `@metamask/eth-mpc-keyring` ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- Initial release of `@metamask/eth-mpc-keyring` ([#641](https://github.com/MetaMask/accounts/pull/641)) - The V1 MPC keyring implementation is not included yet. -- Add a V2 keyring implementation, available via the `./v2` export ([#TODO](https://github.com/MetaMask/accounts/pull/TODO)) +- Add a V2 keyring implementation, available via the `./v2` export ([#641](https://github.com/MetaMask/accounts/pull/641)) - `MpcKeyring` (V2) implements the unified V2 `Keyring` interface with type `KeyringType.Mpc`. - Account creation uses custom options: `{ type: 'custom', mode: 'create' | 'import' }`, and the keyring declares `custom.createAccounts` in its capabilities. - Exposes `rotateKeyShares`, `checkKeyShare`, and `syncKeyShare` maintenance operations. From 62d7f65cd0ad41ac53c0cd32f718c307a72e72ae Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 16:57:58 +0200 Subject: [PATCH 5/9] chore: readme --- packages/keyring-eth-mpc/README.md | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/keyring-eth-mpc/README.md b/packages/keyring-eth-mpc/README.md index a0824c8d8..35a219bfb 100644 --- a/packages/keyring-eth-mpc/README.md +++ b/packages/keyring-eth-mpc/README.md @@ -13,23 +13,6 @@ or `npm install @metamask/eth-mpc-keyring` -## V2 Keyring +## Contributing -This package also provides a V2 keyring that implements the unified `Keyring` interface from `@metamask/keyring-api/v2`. Import it from `@metamask/eth-mpc-keyring/v2`: - -```ts -import type { MpcKeyringV1 } from '@metamask/eth-mpc-keyring'; -import { MpcKeyring } from '@metamask/eth-mpc-keyring/v2'; - -// Provided by the client: an implementation of the V1 MPC keyring contract. -const legacyKeyring: MpcKeyringV1 = createMpcKeyringV1(); - -const keyring = new MpcKeyring({ legacyKeyring }); - -const [account] = await keyring.createAccounts({ - type: 'custom', - mode: 'create', -}); -``` - -The V2 keyring has type `KeyringType.Mpc` (`'mpc'`), declares `custom.createAccounts` in its capabilities, and accepts `{ type: 'custom', mode }` options in `createAccounts`. +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/accounts#readme). From b581a198493adec2896b943c5950e0ff08115afc Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 17:38:30 +0200 Subject: [PATCH 6/9] refactor: rename MpcKeyringV1 -> MpcKeyring --- packages/keyring-eth-mpc/src/index.ts | 2 +- packages/keyring-eth-mpc/src/types.ts | 2 +- packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts | 2 +- packages/keyring-eth-mpc/src/v2/mpc-keyring.ts | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts index dbf2118dd..230c1e4b0 100644 --- a/packages/keyring-eth-mpc/src/index.ts +++ b/packages/keyring-eth-mpc/src/index.ts @@ -1 +1 @@ -export type { MpcKeyringSetupParams, MpcKeyringV1 } from './types'; +export type { MpcKeyringSetupParams, MpcKeyring } from './types'; diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts index 194ecc0ce..1fc726156 100644 --- a/packages/keyring-eth-mpc/src/types.ts +++ b/packages/keyring-eth-mpc/src/types.ts @@ -12,7 +12,7 @@ export type MpcKeyringSetupParams = { * `@metamask/eth-mpc-keyring/v2`) adapts to the unified V2 `Keyring` * interface. */ -export type MpcKeyringV1 = EthKeyring & { +export type MpcKeyring = EthKeyring & { /** * Run key generation or import. * diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts index 8c3ed9819..d5204c57a 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts @@ -12,7 +12,7 @@ import { EthKeyringMethod } from '@metamask/keyring-sdk/v2'; import type { AccountId } from '@metamask/keyring-utils'; import type { Hex, Json } from '@metamask/utils'; -import type { MpcKeyringV1 } from '../types'; +import type { MpcKeyring as MpcKeyringV1 } from '../types'; import { MpcKeyring } from './mpc-keyring'; import type { MpcCreateAccountOptions } from './mpc-keyring'; diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts index c430d618b..fde4f2a07 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts @@ -12,7 +12,10 @@ import type { AccountId } from '@metamask/keyring-utils'; import { assert } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { MpcKeyringSetupParams, MpcKeyringV1 } from '../types'; +import type { + MpcKeyring as MpcKeyringV1, + MpcKeyringSetupParams, +} from '../types'; /** * Methods supported by MPC keyring EOA accounts. From 2120bfc47f34113eff21aa4153990afefa3f077c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 17:53:05 +0200 Subject: [PATCH 7/9] refactor: cosmetic --- packages/keyring-eth-mpc/src/types.ts | 7 +- .../keyring-eth-mpc/src/v2/mpc-keyring.ts | 98 ++++++++++--------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts index 1fc726156..495c99e4b 100644 --- a/packages/keyring-eth-mpc/src/types.ts +++ b/packages/keyring-eth-mpc/src/types.ts @@ -5,12 +5,7 @@ export type MpcKeyringSetupParams = { }; /** - * The V1 MPC keyring contract. - * - * The full implementation is not shipped yet: clients provide their own - * implementation, which the V2 `MpcKeyring` (from - * `@metamask/eth-mpc-keyring/v2`) adapts to the unified V2 `Keyring` - * interface. + * The MPC keyring contract. */ export type MpcKeyring = EthKeyring & { /** diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts index fde4f2a07..e3dad4484 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts @@ -94,18 +94,50 @@ export class MpcKeyring assert(addresses.length <= 1, 'MpcKeyring: supports at most one account'); - return addresses.map((address) => { - // Check if we already have this account in the registry - const existingId = this.registry.getAccountId(address); - if (existingId) { - const cached = this.registry.get(existingId); - if (cached) { - return cached; - } - } + const [address] = addresses; + if (address) { + return [this.#getOrCreateAccount(address)]; + } + return []; + } - return this.#createKeyringAccount(address); - }); + /** + * Build a valid {@link KeyringAccount} from an address. + * + * @param address - The account address. + * @returns The account. + */ + #toAccount(address: Hex): KeyringAccount { + return { + id: this.registry.register(address), + type: EthAccountType.Eoa, + address, + scopes: [...this.capabilities.scopes], + methods: [...MPC_KEYRING_METHODS], + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Custom, + }, + }, + }; + } + + /** + * Get or create the account for the given address. + * + * @param address - The account address. + * @returns The account. + */ + #getOrCreateAccount(address: Hex): KeyringAccount { + const account = this.#toAccount(address); + const existingAccount = this.registry.get(account.id); + + if (existingAccount) { + return existingAccount; + } + + this.registry.set(account); + return account; } /** @@ -136,22 +168,19 @@ export class MpcKeyring ); } - // If the account already exists, creation is idempotent. - const existingAccounts = await this.getAccounts(); - if (existingAccounts.length > 0) { - return existingAccounts; + const accounts = await this.getAccounts(); + if (accounts.length > 0) { + return accounts; } await this.inner.init(mode); - const accounts = await this.getAccounts(); - if (accounts.length === 0) { - throw new Error( - "MpcKeyring: account creation failed. Provide a 'mode' ('create' or 'import') or deserialize stored setup params first.", - ); + const [createdAccount] = await this.getAccounts(); + if (!createdAccount) { + throw new Error('MpcKeyring: account creation failed'); } - return accounts; + return [createdAccount]; }); } @@ -190,31 +219,4 @@ export class MpcKeyring async syncKeyShare(): Promise { return this.inner.syncKeyShare(); } - - /** - * Create a {@link KeyringAccount} for the given address. - * - * @param address - The account address. - * @returns The created account. - */ - #createKeyringAccount(address: Hex): KeyringAccount { - const id = this.registry.register(address); - - const account: KeyringAccount = { - id, - type: EthAccountType.Eoa, - address, - scopes: [...this.capabilities.scopes], - methods: [...MPC_KEYRING_METHODS], - options: { - entropy: { - type: KeyringAccountEntropyTypeOption.Custom, - }, - }, - }; - - this.registry.set(account); - - return account; - } } From 7312de3aac37e1875aeb0e586d91f3282f6ffb40 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 18:32:38 +0200 Subject: [PATCH 8/9] chore: cosmetic --- packages/keyring-eth-mpc/src/types.ts | 12 +++++--- .../src/v2/mpc-keyring.test.ts | 13 ++------- .../keyring-eth-mpc/src/v2/mpc-keyring.ts | 29 +++++++++---------- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/keyring-eth-mpc/src/types.ts b/packages/keyring-eth-mpc/src/types.ts index 495c99e4b..736bde2f3 100644 --- a/packages/keyring-eth-mpc/src/types.ts +++ b/packages/keyring-eth-mpc/src/types.ts @@ -1,8 +1,12 @@ import type { EthKeyring } from '@metamask/keyring-utils'; -export type MpcKeyringSetupParams = { - mode: 'create' | 'import'; -}; +/** The MPC keyring setup modes. */ +export const MpcKeyringSetupMode = { + Create: 'create', + Import: 'import', +} as const; +export type MpcKeyringSetupMode = + `${(typeof MpcKeyringSetupMode)[keyof typeof MpcKeyringSetupMode]}`; /** * The MPC keyring contract. @@ -13,7 +17,7 @@ export type MpcKeyring = EthKeyring & { * * @param mode - The MPC setup mode. */ - init(mode?: MpcKeyringSetupParams['mode']): Promise; + init(mode?: MpcKeyringSetupMode): Promise; /** * Rotate the MPC key shares. diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts index d5204c57a..6ff177460 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.test.ts @@ -13,7 +13,7 @@ import type { AccountId } from '@metamask/keyring-utils'; import type { Hex, Json } from '@metamask/utils'; import type { MpcKeyring as MpcKeyringV1 } from '../types'; -import { MpcKeyring } from './mpc-keyring'; +import { MPC_KEYRING_METHODS, MpcKeyring } from './mpc-keyring'; import type { MpcCreateAccountOptions } from './mpc-keyring'; const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; @@ -22,14 +22,7 @@ const OTHER_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; /** * Expected methods supported by MPC keyring accounts. */ -const EXPECTED_METHODS = [ - EthMethod.SignTransaction, - EthMethod.PersonalSign, - EthMethod.SignTypedDataV1, - EthMethod.SignTypedDataV3, - EthMethod.SignTypedDataV4, - EthKeyringMethod.SignEip7702Authorization, -]; +const EXPECTED_METHODS = MPC_KEYRING_METHODS; /** * The legacy MPC keyring methods that the V2 wrapper delegates to, mocked @@ -298,7 +291,7 @@ describe('MpcKeyring (v2 wrapper)', () => { } as unknown as MpcCreateAccountOptions; await expect(wrapper.createAccounts(invalidOptions)).rejects.toThrow( - "MpcKeyring: invalid mode: bogus. Expected 'create' or 'import'.", + 'MpcKeyring: invalid mode: bogus', ); expect(inner.init).not.toHaveBeenCalled(); }); diff --git a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts index e3dad4484..303710a6e 100644 --- a/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts +++ b/packages/keyring-eth-mpc/src/v2/mpc-keyring.ts @@ -9,19 +9,16 @@ import { KeyringType } from '@metamask/keyring-api/v2'; import type { KeyringCapabilities, Keyring } from '@metamask/keyring-api/v2'; import { EthKeyringMethod, EthKeyringWrapper } from '@metamask/keyring-sdk/v2'; import type { AccountId } from '@metamask/keyring-utils'; -import { assert } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { - MpcKeyring as MpcKeyringV1, - MpcKeyringSetupParams, -} from '../types'; +import { MpcKeyringSetupMode } from '../types'; +import type { MpcKeyring as MpcKeyringV1 } from '../types'; /** * Methods supported by MPC keyring EOA accounts. * MPC keyrings support signing methods, but not encryption or app keys. */ -const MPC_KEYRING_METHODS = [ +export const MPC_KEYRING_METHODS = [ EthMethod.SignTransaction, EthMethod.PersonalSign, EthMethod.SignTypedDataV1, @@ -52,7 +49,7 @@ export type MpcCreateAccountOptions = { /** * The MPC setup mode. */ - mode?: MpcKeyringSetupParams['mode']; + mode?: MpcKeyringSetupMode; }; /** @@ -92,7 +89,9 @@ export class MpcKeyring async getAccounts(): Promise { const addresses = await this.inner.getAccounts(); - assert(addresses.length <= 1, 'MpcKeyring: supports at most one account'); + if (addresses.length > 1) { + throw new Error('MpcKeyring: supports at most one account'); + } const [address] = addresses; if (address) { @@ -155,17 +154,17 @@ export class MpcKeyring throw new Error( `MpcKeyring: unsupported account creation type: ${String( options.type, - )}. Use { type: 'custom', mode: 'create' | 'import' }.`, + )}`, ); } const { mode } = options; - if (mode !== undefined && mode !== 'create' && mode !== 'import') { - throw new Error( - `MpcKeyring: invalid mode: ${String( - mode, - )}. Expected 'create' or 'import'.`, - ); + if ( + mode !== undefined && + mode !== MpcKeyringSetupMode.Create && + mode !== MpcKeyringSetupMode.Import + ) { + throw new Error(`MpcKeyring: invalid mode: ${String(mode)}`); } const accounts = await this.getAccounts(); From 79ece4c620222c96d915d62055da28c1b8396c6d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 1 Oct 2026 18:37:42 +0200 Subject: [PATCH 9/9] chore: fix exports --- packages/keyring-eth-mpc/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/keyring-eth-mpc/src/index.ts b/packages/keyring-eth-mpc/src/index.ts index 230c1e4b0..f78f4c1a6 100644 --- a/packages/keyring-eth-mpc/src/index.ts +++ b/packages/keyring-eth-mpc/src/index.ts @@ -1 +1,2 @@ -export type { MpcKeyringSetupParams, MpcKeyring } from './types'; +export { MpcKeyringSetupMode } from './types'; +export type { MpcKeyring } from './types';