diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 6589493be..9f344f7dc 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -43,6 +43,7 @@ jobs: deps-dev keyring-api keyring-eth-hd + keyring-eth-money keyring-eth-ledger-bridge keyring-eth-simple keyring-eth-trezor diff --git a/README.md b/README.md index d3bba33aa..5c16af74b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/account-api`](packages/account-api) - [`@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-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) @@ -42,6 +43,7 @@ linkStyle default opacity:0.5 keyring_api(["@metamask/keyring-api"]); eth_hd_keyring(["@metamask/eth-hd-keyring"]); eth_ledger_bridge_keyring(["@metamask/eth-ledger-bridge-keyring"]); + eth_money_keyring(["@metamask/eth-money-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); @@ -61,6 +63,7 @@ linkStyle default opacity:0.5 eth_ledger_bridge_keyring --> keyring_api; eth_ledger_bridge_keyring --> keyring_utils; eth_ledger_bridge_keyring --> account_api; + eth_money_keyring --> keyring_eth_hd; eth_qr_keyring --> keyring_api; eth_qr_keyring --> keyring_utils; eth_qr_keyring --> account_api; diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 0d9df7907..ecac7b6b2 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `KeyringType.Money` variant ([#472](https://github.com/MetaMask/accounts/pull/472)) - Add optional `details` field to `Transaction` type ([#445](https://github.com/MetaMask/accounts/pull/445)) - Add `SecurityAlertResponse` enum with values: `benign`, `warning`, `malicious` - Add optional `origin` field (string) to track transaction request source diff --git a/packages/keyring-api/src/api/v2/keyring-type.ts b/packages/keyring-api/src/api/v2/keyring-type.ts index aedd48d94..06ec60620 100644 --- a/packages/keyring-api/src/api/v2/keyring-type.ts +++ b/packages/keyring-api/src/api/v2/keyring-type.ts @@ -43,4 +43,9 @@ export enum KeyringType { * Represents keyring backed by a OneKey hardware wallet. */ OneKey = 'onekey', + + /** + * Represents keyring for money accounts. + */ + Money = 'money', } diff --git a/packages/keyring-api/src/api/v2/keyring.test-d.ts b/packages/keyring-api/src/api/v2/keyring.test-d.ts index 25fb82339..623e43735 100644 --- a/packages/keyring-api/src/api/v2/keyring.test-d.ts +++ b/packages/keyring-api/src/api/v2/keyring.test-d.ts @@ -22,6 +22,7 @@ import type { ImportPrivateKeyFormat } from './private-key'; // Test KeyringType enum expectAssignable(KeyringType.Hd); +expectAssignable(KeyringType.Money); expectAssignable(KeyringType.PrivateKey); expectAssignable(KeyringType.Qr); expectAssignable(KeyringType.Snap); diff --git a/packages/keyring-eth-money/CHANGELOG.md b/packages/keyring-eth-money/CHANGELOG.md new file mode 100644 index 000000000..d9a3dc874 --- /dev/null +++ b/packages/keyring-eth-money/CHANGELOG.md @@ -0,0 +1,17 @@ +# 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 + +- Add initial implementation of `MoneyKeyring` ([#472](https://github.com/MetaMask/accounts/pull/472)) + - Extends `HdKeyring` from `@metamask/eth-hd-keyring`. + - Uses keyring type `"Money Keyring"`. + - Uses derivation path `"m/44'/4392018'/0'/0"`. + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-money/LICENSE b/packages/keyring-eth-money/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-money/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-money/README.md b/packages/keyring-eth-money/README.md new file mode 100644 index 000000000..00e7e2e4e --- /dev/null +++ b/packages/keyring-eth-money/README.md @@ -0,0 +1,38 @@ +# Money Keyring + +An Ethereum keyring that extends [`@metamask/eth-hd-keyring`](../keyring-eth-hd) with a distinct keyring type and derivation path for money accounts. + +Money accounts use a separate HD derivation path to keep funds isolated from the primary HD keyring, while reusing the same seed phrase and signing infrastructure. + +## Installation + +`yarn add @metamask/eth-money-keyring` + +or + +`npm install @metamask/eth-money-keyring` + +## Usage + +```ts +import { MoneyKeyring } from '@metamask/eth-money-keyring'; + +const keyring = new MoneyKeyring(); +``` + +The `MoneyKeyring` class implements the same `Keyring` interface as `HdKeyring` — see the [HD Keyring README](../keyring-eth-hd/README.md) for full API documentation. + +## Contributing + +### Setup + +- Install [Node.js](https://nodejs.org) version 18 + - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. +- Install [Yarn v4](https://yarnpkg.com/getting-started/install) +- Run `yarn install` to install dependencies and run any required post-install scripts + +### Testing and Linting + +Run `yarn test` to run the tests once. + +Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. diff --git a/packages/keyring-eth-money/jest.config.js b/packages/keyring-eth-money/jest.config.js new file mode 100644 index 000000000..7eb53e602 --- /dev/null +++ b/packages/keyring-eth-money/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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, + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], +}); diff --git a/packages/keyring-eth-money/package.json b/packages/keyring-eth-money/package.json new file mode 100644 index 000000000..8f58c9163 --- /dev/null +++ b/packages/keyring-eth-money/package.json @@ -0,0 +1,71 @@ +{ + "name": "@metamask/eth-money-keyring", + "version": "0.0.0", + "description": "A money account keyring that extends the HD keyring with a different keyring type and derivation path.", + "keywords": [ + "ethereum", + "keyring" + ], + "homepage": "https://github.com/MetaMask/accounts#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "license": "ISC", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-money-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-money-keyring", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest", + "test:clean": "jest --clearCache" + }, + "dependencies": { + "@metamask/eth-hd-keyring": "workspace:^" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/eth-sig-util": "^8.2.0", + "@metamask/utils": "^11.1.0", + "@ts-bridge/cli": "^0.6.3", + "@types/jest": "^29.5.12", + "deepmerge": "^4.2.2", + "jest": "^29.5.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/keyring-eth-money/src/index.ts b/packages/keyring-eth-money/src/index.ts new file mode 100644 index 000000000..cfc88c93c --- /dev/null +++ b/packages/keyring-eth-money/src/index.ts @@ -0,0 +1 @@ +export { MoneyKeyring, MONEY_DERIVATION_PATH } from './money-keyring'; diff --git a/packages/keyring-eth-money/src/money-keyring.test.ts b/packages/keyring-eth-money/src/money-keyring.test.ts new file mode 100644 index 000000000..92b2a6ea2 --- /dev/null +++ b/packages/keyring-eth-money/src/money-keyring.test.ts @@ -0,0 +1,210 @@ +import { assert, type Hex } from '@metamask/utils'; + +import { MONEY_DERIVATION_PATH, MoneyKeyring } from './money-keyring'; + +const sampleMnemonic = + 'finish oppose decorate face calm tragic certain desk hour urge dinosaur mango'; + +const getAddressAtIndex = async ( + keyring: MoneyKeyring, + index: number, +): Promise => { + const accounts = await keyring.getAccounts(); + assert(accounts[index], `Account not found at index ${index}`); + return accounts[index]; +}; + +describe('MoneyKeyring', () => { + describe('static properties', () => { + it('has the correct type', () => { + expect(MoneyKeyring.type).toBe('Money Keyring'); + }); + }); + + describe('type', () => { + it('returns the correct value', () => { + const keyring = new MoneyKeyring(); + expect(keyring.type).toBe('Money Keyring'); + expect(keyring.type).toBe(MoneyKeyring.type); + }); + }); + + describe('hdPath', () => { + it('uses the money account derivation path', () => { + const keyring = new MoneyKeyring(); + expect(keyring.hdPath).toBe(MONEY_DERIVATION_PATH); + }); + }); + + describe('address derivation is deterministic', () => { + it.each([ + { + mnemonic: sampleMnemonic, + expectedAddress: '0x13203ef2a0e1fb26bfddcaf86a4a7d08a52d78aa', + }, + { + mnemonic: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + expectedAddress: '0x9396093a74662a2fef84ad7d99155b0fb1658553', + }, + { + mnemonic: + 'letter ethics correct bus asset pipe tourist vapor envelope kangaroo warm dawn', + expectedAddress: '0x4eb3d02b362bb921ce5af3e13dded943af7460ed', + }, + ])( + 'derives the expected address for a given SRP', + async ({ mnemonic, expectedAddress }) => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ mnemonic }); + + const address = await getAddressAtIndex(keyring, 0); + expect(address).toBe(expectedAddress); + }, + ); + }); + + describe('deserialize', () => { + it('derives accounts using the money account hd path', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toBe(MONEY_DERIVATION_PATH); + }); + + it('derives different addresses than the standard HD keyring path', async () => { + const { HdKeyring } = await import('@metamask/eth-hd-keyring'); + + const moneyKeyring = new MoneyKeyring(); + await moneyKeyring.deserialize({ + mnemonic: sampleMnemonic, + }); + const moneyAccounts = await moneyKeyring.getAccounts(); + + const hdKeyring = new HdKeyring(); + await hdKeyring.deserialize({ + mnemonic: sampleMnemonic, + numberOfAccounts: 1, + }); + const hdAccounts = await hdKeyring.getAccounts(); + + expect(moneyAccounts[0]).not.toBe(hdAccounts[0]); + }); + }); + + describe('addAccounts', () => { + it('throws if an account already exists', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + await expect(keyring.addAccounts()).rejects.toThrow( + 'Money keyring already has an account', + ); + }); + + it('adds an account when none exist', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + keyring.removeAccount(await getAddressAtIndex(keyring, 0)); + const empty = await keyring.getAccounts(); + expect(empty).toHaveLength(0); + + await keyring.addAccounts(); + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + }); + + it('re-adds the same account after removal', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const originalAddress = await getAddressAtIndex(keyring, 0); + + keyring.removeAccount(originalAddress); + expect(await keyring.getAccounts()).toHaveLength(0); + + await keyring.addAccounts(); + const restoredAddress = await getAddressAtIndex(keyring, 0); + + expect(restoredAddress).toBe(originalAddress); + }); + }); + + describe('deserialize with invalid payload', () => { + it('ignores an invalid hdPath and uses the money derivation path', async () => { + const keyring = new MoneyKeyring(); + // Force a payload with a wrong hdPath (e.g. standard HD keyring path) + await keyring.deserialize({ + mnemonic: sampleMnemonic, + hdPath: "m/44'/60'/0'/0", + } as Parameters[0]); + + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toBe(MONEY_DERIVATION_PATH); + }); + + it('ignores an invalid numberOfAccounts and always creates exactly one account', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + numberOfAccounts: 5, + } as Parameters[0]); + + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + + const serialized = await keyring.serialize(); + expect(serialized.numberOfAccounts).toBe(1); + }); + + it('ignores both an invalid hdPath and numberOfAccounts', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + hdPath: "m/44'/60'/0'/0", + numberOfAccounts: 10, + } as Parameters[0]); + + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toBe(MONEY_DERIVATION_PATH); + expect(serialized.numberOfAccounts).toBe(1); + }); + }); + + describe('serialize / deserialize round-trip', () => { + it('serializes what it deserializes', async () => { + const keyring = new MoneyKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const accounts = await keyring.getAccounts(); + const serialized = await keyring.serialize(); + + const restored = new MoneyKeyring(); + await restored.deserialize({ + mnemonic: serialized.mnemonic, + }); + + const restoredAccounts = await restored.getAccounts(); + expect(restoredAccounts).toStrictEqual(accounts); + + const restoredSerialized = await restored.serialize(); + expect(restoredSerialized.hdPath).toBe(MONEY_DERIVATION_PATH); + expect(restoredSerialized.numberOfAccounts).toBe(1); + }); + }); +}); diff --git a/packages/keyring-eth-money/src/money-keyring.ts b/packages/keyring-eth-money/src/money-keyring.ts new file mode 100644 index 000000000..9de0e3870 --- /dev/null +++ b/packages/keyring-eth-money/src/money-keyring.ts @@ -0,0 +1,40 @@ +import { + HdKeyring, + type DeserializableHDKeyringState, +} from '@metamask/eth-hd-keyring'; +import type { Hex } from '@metamask/utils'; + +// Based on the coin type created in [this PR](https://github.com/satoshilabs/slips/pull/1983) +export const MONEY_DERIVATION_PATH = `m/44'/4392018'/0'/0`; +const type = 'Money Keyring'; + +export class MoneyKeyring extends HdKeyring { + static override type: string = type; + + override readonly type: string = type; + + override readonly hdPath: string = MONEY_DERIVATION_PATH; + + // This override is required because the deserialize method in the + // MoneyKeyring falls back to it's own static value if no + // option is provided. + override async deserialize( + opts: Partial< + Omit + >, + ): Promise { + return super.deserialize({ + ...opts, + numberOfAccounts: 1, + hdPath: MONEY_DERIVATION_PATH, + }); + } + + override async addAccounts(): Promise { + const existing = await this.getAccounts(); + if (existing.length > 0) { + throw new Error('Money keyring already has an account'); + } + return super.addAccounts(1); + } +} diff --git a/packages/keyring-eth-money/tsconfig.build.json b/packages/keyring-eth-money/tsconfig.build.json new file mode 100644 index 000000000..b6de6895a --- /dev/null +++ b/packages/keyring-eth-money/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "exactOptionalPropertyTypes": false + }, + "references": [ + { + "path": "../keyring-eth-hd/tsconfig.build.json" + } + ], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-eth-money/tsconfig.json b/packages/keyring-eth-money/tsconfig.json new file mode 100644 index 000000000..4dd9c7011 --- /dev/null +++ b/packages/keyring-eth-money/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "exactOptionalPropertyTypes": false, + "target": "es2017" + }, + "references": [{ "path": "../keyring-eth-hd" }], + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index b7caf0488..12c8b4ab1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,7 @@ { "path": "./packages/hw-wallet-sdk/tsconfig.build.json" }, { "path": "./packages/keyring-api/tsconfig.build.json" }, { "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-qr/tsconfig.build.json" }, { "path": "./packages/keyring-eth-simple/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 0ddb96ba1..a1d4b2a3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1678,7 +1678,7 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-hd-keyring@workspace:packages/keyring-eth-hd": +"@metamask/eth-hd-keyring@workspace:^, @metamask/eth-hd-keyring@workspace:packages/keyring-eth-hd": version: 0.0.0-use.local resolution: "@metamask/eth-hd-keyring@workspace:packages/keyring-eth-hd" dependencies: @@ -1746,6 +1746,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eth-money-keyring@workspace:packages/keyring-eth-money": + version: 0.0.0-use.local + resolution: "@metamask/eth-money-keyring@workspace:packages/keyring-eth-money" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eth-hd-keyring": "workspace:^" + "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/utils": "npm:^11.1.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/jest": "npm:^29.5.12" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.5.0" + 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"