diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 6589493be..7eba36c33 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-cash keyring-eth-ledger-bridge keyring-eth-simple keyring-eth-trezor diff --git a/README.md b/README.md index d3bba33aa..953b13cf8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/account-api`](packages/account-api) +- [`@metamask/eth-cash-keyring`](packages/keyring-eth-cash) - [`@metamask/eth-hd-keyring`](packages/keyring-eth-hd) - [`@metamask/eth-ledger-bridge-keyring`](packages/keyring-eth-ledger-bridge) - [`@metamask/eth-qr-keyring`](packages/keyring-eth-qr) @@ -40,6 +41,7 @@ linkStyle default opacity:0.5 account_api(["@metamask/account-api"]); hw_wallet_sdk(["@metamask/hw-wallet-sdk"]); keyring_api(["@metamask/keyring-api"]); + eth_cash_keyring(["@metamask/eth-cash-keyring"]); eth_hd_keyring(["@metamask/eth-hd-keyring"]); eth_ledger_bridge_keyring(["@metamask/eth-ledger-bridge-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); @@ -54,6 +56,7 @@ linkStyle default opacity:0.5 account_api --> keyring_api; account_api --> keyring_utils; keyring_api --> keyring_utils; + eth_cash_keyring --> keyring_eth_hd; eth_hd_keyring --> keyring_api; eth_hd_keyring --> keyring_utils; eth_hd_keyring --> account_api; diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 0d9df7907..3c5b4d9d3 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.Cash` 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..6a464822d 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 cash accounts. + */ + Cash = 'cash', } 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..47c38dde7 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.Cash); expectAssignable(KeyringType.PrivateKey); expectAssignable(KeyringType.Qr); expectAssignable(KeyringType.Snap); diff --git a/packages/keyring-eth-cash/CHANGELOG.md b/packages/keyring-eth-cash/CHANGELOG.md new file mode 100644 index 000000000..577aad03d --- /dev/null +++ b/packages/keyring-eth-cash/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 `CashKeyring` ([#472](https://github.com/MetaMask/accounts/pull/472)) + - Extends `HdKeyring` from `@metamask/eth-hd-keyring`. + - Uses keyring type `"Cash Keyring"`. + - Uses derivation path `"m/44'/4392018'/0'/0"`. + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-cash/LICENSE b/packages/keyring-eth-cash/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-cash/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-cash/README.md b/packages/keyring-eth-cash/README.md new file mode 100644 index 000000000..52713e8d0 --- /dev/null +++ b/packages/keyring-eth-cash/README.md @@ -0,0 +1,38 @@ +# Cash Keyring + +An Ethereum keyring that extends [`@metamask/eth-hd-keyring`](../keyring-eth-hd) with a distinct keyring type and derivation path for cash accounts. + +Cash 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-cash-keyring` + +or + +`npm install @metamask/eth-cash-keyring` + +## Usage + +```ts +import { CashKeyring } from '@metamask/eth-cash-keyring'; + +const keyring = new CashKeyring(); +``` + +The `CashKeyring` 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-cash/jest.config.js b/packages/keyring-eth-cash/jest.config.js new file mode 100644 index 000000000..7eb53e602 --- /dev/null +++ b/packages/keyring-eth-cash/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-cash/package.json b/packages/keyring-eth-cash/package.json new file mode 100644 index 000000000..c99815a0f --- /dev/null +++ b/packages/keyring-eth-cash/package.json @@ -0,0 +1,71 @@ +{ + "name": "@metamask/eth-cash-keyring", + "version": "0.0.0", + "description": "A cash 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-cash-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-cash-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-cash/src/cash-keyring.test.ts b/packages/keyring-eth-cash/src/cash-keyring.test.ts new file mode 100644 index 000000000..eea4727dd --- /dev/null +++ b/packages/keyring-eth-cash/src/cash-keyring.test.ts @@ -0,0 +1,237 @@ +import { + recoverPersonalSignature, + recoverTypedSignature, + SignTypedDataVersion, + type MessageTypes, + type TypedMessage, +} from '@metamask/eth-sig-util'; +import { normalize } from '@metamask/eth-sig-util'; +import { assert, type Hex } from '@metamask/utils'; + +import { CashKeyring } from './cash-keyring'; + +const sampleMnemonic = + 'finish oppose decorate face calm tragic certain desk hour urge dinosaur mango'; + +const cashHdPath = `m/44'/4392018'/0'/0`; + +const getAddressAtIndex = async ( + keyring: CashKeyring, + index: number, +): Promise => { + const accounts = await keyring.getAccounts(); + assert(accounts[index], `Account not found at index ${index}`); + return accounts[index]; +}; + +describe('CashKeyring', () => { + describe('static properties', () => { + it('has the correct type', () => { + expect(CashKeyring.type).toBe('Cash Keyring'); + }); + }); + + describe('#type', () => { + it('returns the correct value', () => { + const keyring = new CashKeyring(); + expect(keyring.type).toBe('Cash Keyring'); + expect(keyring.type).toBe(CashKeyring.type); + }); + }); + + describe('#hdPath', () => { + it('uses the cash account derivation path', () => { + const keyring = new CashKeyring(); + expect(keyring.hdPath).toBe(cashHdPath); + }); + }); + + describe('#deserialize', () => { + it('derives accounts using the cash account hd path', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toBe(cashHdPath); + }); + + it('derives different addresses than the standard HD keyring path', async () => { + const { HdKeyring } = await import('@metamask/eth-hd-keyring'); + + const cashKeyring = new CashKeyring(); + await cashKeyring.deserialize({ + mnemonic: sampleMnemonic, + }); + const cashAccounts = await cashKeyring.getAccounts(); + + const hdKeyring = new HdKeyring(); + await hdKeyring.deserialize({ + mnemonic: sampleMnemonic, + numberOfAccounts: 1, + }); + const hdAccounts = await hdKeyring.getAccounts(); + + expect(cashAccounts[0]).not.toBe(hdAccounts[0]); + }); + + it('uses the cash account hd path when no hdPath option is provided', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toBe(cashHdPath); + }); + }); + + describe('#addAccounts', () => { + it('throws if an account already exists', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + await expect(keyring.addAccounts()).rejects.toThrow( + 'Cash keyring already has an account', + ); + }); + + it('adds an account when none exist', async () => { + const keyring = new CashKeyring(); + 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); + }); + }); + + describe('#signPersonalMessage', () => { + it('signs and the signature can be recovered', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const address = await getAddressAtIndex(keyring, 0); + const message = '0x68656c6c6f20776f726c64'; + const signature = await keyring.signPersonalMessage(address, message); + + const restored = recoverPersonalSignature({ + data: message, + signature, + }); + expect(restored).toStrictEqual(normalize(address)); + }); + }); + + describe('#signTypedData', () => { + it('signs V1 typed data and the signature can be recovered', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const address = await getAddressAtIndex(keyring, 0); + const typedData = [ + { + type: 'string', + name: 'message', + value: 'Hi, Alice!', + }, + ]; + + const signature = await keyring.signTypedData(address, typedData); + const restored = recoverTypedSignature({ + data: typedData, + signature, + version: SignTypedDataVersion.V1, + }); + expect(restored).toStrictEqual(address); + }); + + it('signs V3 typed data and the signature can be recovered', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const address = await getAddressAtIndex(keyring, 0); + const typedData: TypedMessage = { + types: { + EIP712Domain: [], + }, + domain: {}, + primaryType: 'EIP712Domain', + message: {}, + }; + + const signature = await keyring.signTypedData(address, typedData, { + version: SignTypedDataVersion.V3, + }); + const restored = recoverTypedSignature({ + data: typedData, + signature, + version: SignTypedDataVersion.V3, + }); + expect(restored).toStrictEqual(address); + }); + }); + + describe('#removeAccount', () => { + it('removes an existing account', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(1); + + keyring.removeAccount(await getAddressAtIndex(keyring, 0)); + const remaining = await keyring.getAccounts(); + expect(remaining).toHaveLength(0); + }); + + it('throws when removing a non-existent account', () => { + const keyring = new CashKeyring(); + const fakeAddress = '0x0000000000000000000000000000000000000000'; + expect(() => keyring.removeAccount(fakeAddress)).toThrow( + `Address ${fakeAddress} not found in this keyring`, + ); + }); + }); + + describe('#serialize / #deserialize round-trip', () => { + it('serializes what it deserializes', async () => { + const keyring = new CashKeyring(); + await keyring.deserialize({ + mnemonic: sampleMnemonic, + }); + + const accounts = await keyring.getAccounts(); + const serialized = await keyring.serialize(); + + const restored = new CashKeyring(); + await restored.deserialize({ + mnemonic: serialized.mnemonic, + }); + + const restoredAccounts = await restored.getAccounts(); + expect(restoredAccounts).toStrictEqual(accounts); + + const restoredSerialized = await restored.serialize(); + expect(restoredSerialized.hdPath).toBe(cashHdPath); + expect(restoredSerialized.numberOfAccounts).toBe(1); + }); + }); +}); diff --git a/packages/keyring-eth-cash/src/cash-keyring.ts b/packages/keyring-eth-cash/src/cash-keyring.ts new file mode 100644 index 000000000..78c9cde9a --- /dev/null +++ b/packages/keyring-eth-cash/src/cash-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 CASH_DERIVATION_PATH = `m/44'/4392018'/0'/0`; +const type = 'Cash Keyring'; + +export class CashKeyring extends HdKeyring { + static override type: string = type; + + override readonly type: string = type; + + override readonly hdPath: string = CASH_DERIVATION_PATH; + + // This override is required because the deserialize method in the + // CashKeyring 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: CASH_DERIVATION_PATH, + }); + } + + override async addAccounts(): Promise { + const existing = await this.getAccounts(); + if (existing.length > 0) { + throw new Error('Cash keyring already has an account'); + } + return super.addAccounts(1); + } +} diff --git a/packages/keyring-eth-cash/src/index.ts b/packages/keyring-eth-cash/src/index.ts new file mode 100644 index 000000000..4474660a7 --- /dev/null +++ b/packages/keyring-eth-cash/src/index.ts @@ -0,0 +1 @@ +export { CashKeyring, CASH_DERIVATION_PATH } from './cash-keyring'; diff --git a/packages/keyring-eth-cash/tsconfig.build.json b/packages/keyring-eth-cash/tsconfig.build.json new file mode 100644 index 000000000..b6de6895a --- /dev/null +++ b/packages/keyring-eth-cash/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-cash/tsconfig.json b/packages/keyring-eth-cash/tsconfig.json new file mode 100644 index 000000000..4dd9c7011 --- /dev/null +++ b/packages/keyring-eth-cash/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..331687caa 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-cash/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..75a9f48e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1678,7 +1678,24 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-hd-keyring@workspace:packages/keyring-eth-hd": +"@metamask/eth-cash-keyring@workspace:packages/keyring-eth-cash": + version: 0.0.0-use.local + resolution: "@metamask/eth-cash-keyring@workspace:packages/keyring-eth-cash" + 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-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: