From ccd708e269aede839a4d72e0b5aa147bb3e35030 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 25 Sep 2024 12:50:59 +0200 Subject: [PATCH 01/46] wip: qr implementation --- packages/keyring-eth-qr/CHANGELOG.md | 2 + packages/keyring-eth-qr/LICENSE | 15 ++ packages/keyring-eth-qr/README.md | 1 + packages/keyring-eth-qr/jest.config.js | 29 +++ packages/keyring-eth-qr/package.json | 76 ++++++++ .../keyring-eth-qr/src/account-deriver.ts | 91 +++++++++ packages/keyring-eth-qr/src/index.ts | 1 + packages/keyring-eth-qr/src/qr-keyring.ts | 76 ++++++++ packages/keyring-eth-qr/tsconfig.build.json | 11 ++ packages/keyring-eth-qr/tsconfig.json | 8 + packages/keyring-eth-qr/typedoc.json | 6 + tsconfig.build.json | 1 + yarn.lock | 175 +++++++++++++++++- 13 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 packages/keyring-eth-qr/CHANGELOG.md create mode 100644 packages/keyring-eth-qr/LICENSE create mode 100644 packages/keyring-eth-qr/README.md create mode 100644 packages/keyring-eth-qr/jest.config.js create mode 100644 packages/keyring-eth-qr/package.json create mode 100644 packages/keyring-eth-qr/src/account-deriver.ts create mode 100644 packages/keyring-eth-qr/src/index.ts create mode 100644 packages/keyring-eth-qr/src/qr-keyring.ts create mode 100644 packages/keyring-eth-qr/tsconfig.build.json create mode 100644 packages/keyring-eth-qr/tsconfig.json create mode 100644 packages/keyring-eth-qr/typedoc.json diff --git a/packages/keyring-eth-qr/CHANGELOG.md b/packages/keyring-eth-qr/CHANGELOG.md new file mode 100644 index 000000000..4dc68c6ff --- /dev/null +++ b/packages/keyring-eth-qr/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/packages/keyring-eth-qr/LICENSE b/packages/keyring-eth-qr/LICENSE new file mode 100644 index 000000000..b5ed1b9c5 --- /dev/null +++ b/packages/keyring-eth-qr/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-qr/README.md b/packages/keyring-eth-qr/README.md new file mode 100644 index 000000000..54ae5e095 --- /dev/null +++ b/packages/keyring-eth-qr/README.md @@ -0,0 +1 @@ +# QR Keyring diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js new file mode 100644 index 000000000..fdd7e10c8 --- /dev/null +++ b/packages/keyring-eth-qr/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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'], + + // 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-qr/package.json b/packages/keyring-eth-qr/package.json new file mode 100644 index 000000000..b0d1f15be --- /dev/null +++ b/packages/keyring-eth-qr/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/eth-qr-keyring", + "version": "1.0.0", + "description": "A simple standard interface for a series of Ethereum private keys.", + "keywords": [ + "ethereum", + "keyring", + "qr", + "metamask" + ], + "homepage": "https://github.com/MetaMask/accounts/packages/keyring-eth-qr#readme", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build": "tsc --build tsconfig.build.json", + "build:clean": "rimraf dist && yarn build", + "build:docs": "typedoc", + "build:force": "tsc --build tsconfig.build.json --force", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-simple-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-simple-keyring", + "publish:preview": "yarn npm publish --tag preview", + "sample": "ts-node src/sample.ts", + "test": "jest", + "test:clean": "jest --clearCache", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "dependencies": { + "@ethereumjs/util": "^9.1.0", + "@keystonehq/bc-ur-registry-eth": "^0.21.0", + "@keystonehq/ur-decoder": "^0.12.2", + "@metamask/utils": "^9.2.1", + "@ngraveio/bc-ur": "^1.1.13", + "hdkey": "^2.1.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.2.1", + "@metamask/auto-changelog": "^3.4.4", + "@types/hdkey": "^2.0.1", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.12", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "jest": "^29.5.0", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.2", + "typedoc": "^0.25.13", + "typescript": "~4.8.4" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "lavamoat": { + "allowScripts": { + "keccak": true, + "secp256k1": true, + "@lavamoat/preinstall-always-fail": false, + "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>keccak": false, + "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>secp256k1": false + } + } +} diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts new file mode 100644 index 000000000..7a7a569df --- /dev/null +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -0,0 +1,91 @@ +import { pubToAddress } from '@ethereumjs/util'; +import { + CryptoAccount, + type CryptoHDKey, + type CryptoOutput, +} from '@keystonehq/bc-ur-registry-eth'; +import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; +import HDKey from 'hdkey'; + +export type RootAccount = { fingerprint: Hex } & ( + | { + type: 'hd'; + hdPath: string; + childrenPath: string; + bip32xPub: string; + } + | { + type: 'account'; + addressPaths: Record; + } +); + +export class AccountDeriver { + #root?: RootAccount; + + init(source: CryptoAccount | CryptoHDKey) { + const fingerprint = this.#getFingerprintFromSource(source); + if (source instanceof CryptoAccount) { + this.#root = { + fingerprint, + type: 'account', + addressPaths: this.#getPathsFromCryptoOutputDescriptors( + source.getOutputDescriptors(), + ), + }; + } else { + this.#root = { + fingerprint, + type: 'hd', + hdPath: `m/${source.getOrigin().getPath()}`, + childrenPath: source.getChildren().getPath(), + bip32xPub: source.getBip32Key(), + }; + } + } + + deriveIndex(index: number): Hex { + if (!this.#root) { + throw new Error('AccountDeriver not initialized'); + } + + if (this.#root.type === 'account') { + const address = Object.keys(this.#root.addressPaths)[index]; + if (!address) { + throw new Error(`Address not found for index ${index}`); + } + return add0x(address); + } else { + const hdKey = HDKey.fromExtendedKey(this.#root.bip32xPub); + hdKey.derive(`m/${index}`); + const address = getChecksumAddress( + add0x(Buffer.from(pubToAddress(hdKey.publicKey, true)).toString('hex')), + ); + return add0x(address); + } + } + + #getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): Hex { + return source instanceof CryptoAccount + ? add0x(source.getMasterFingerprint().toString('hex')) + : add0x(source.getOrigin().getSourceFingerprint().toString('hex')); + } + + #getPathsFromCryptoOutputDescriptors( + descriptors: CryptoOutput[], + ): Record { + return descriptors.reduce((paths: Record, current) => { + const hdKey = current.getHDKey(); + if (hdKey) { + const path = `M/${hdKey.getOrigin().getPath()}`; + const address = getChecksumAddress( + add0x( + Buffer.from(pubToAddress(hdKey.getKey(), true)).toString('hex'), + ), + ); + paths[address] = path; + } + return paths; + }, {}); + } +} diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts new file mode 100644 index 000000000..bea7d1d3f --- /dev/null +++ b/packages/keyring-eth-qr/src/index.ts @@ -0,0 +1 @@ +export * from './qr-keyring'; diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts new file mode 100644 index 000000000..805d055be --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -0,0 +1,76 @@ +import { CryptoAccount, CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; +import { URRegistryDecoder } from '@keystonehq/ur-decoder'; +import type { Hex, Keyring } from '@metamask/utils'; +import { UR } from '@ngraveio/bc-ur'; +import { AccountDeriver } from './account-deriver'; + +export const QR_KEYRING_TYPE = 'QrKeyring' as const; + +export const SUPPORTED_UR_TYPE = { + CRYPTO_HDKEY: 'crypto-hdkey', + CRYPTO_ACCOUNT: 'crypto-account', + ETH_SIGNATURE: 'eth-signature', +}; + +/** + * The state of the QrKeyring + * + * @property accounts - The accounts in the QrKeyring + */ +export type QrKeyringState = { + accounts: Record; + cbor?: Hex; +}; + +export class QrKeyring implements Keyring { + type = QR_KEYRING_TYPE; + + #accounts: Record = {}; + + #deriver: AccountDeriver = new AccountDeriver(); + + async serialize(): Promise { + return { + accounts: Object.values(this.#accounts), + cbor: '', // TODO: Serialize deriver + }; + } + + async deserialize(state: QrKeyringState): Promise { + this.#accounts = state.accounts; + if (state.cbor) { + this.submitCBOR(state.cbor); + } + } + + async addAccounts(_accountsToAdd: number): Promise { + // TODO: Implement + } + + async getAccounts(): Promise { + return Object.values(this.#accounts); + } + + submitCBOR(cbor: Hex) { + const ur = URRegistryDecoder.decode(cbor); + const bufferedCbor = Buffer.from(cbor, 'hex'); + let derivationSource: CryptoHDKey | CryptoAccount; + + switch (ur.type) { + case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: + derivationSource = CryptoHDKey.fromCBOR(bufferedCbor); + break; + case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: + derivationSource = CryptoAccount.fromCBOR(bufferedCbor); + break; + default: + throw new Error('Unsupported UR type'); + } + + this.#deriver.init(derivationSource); + } + + async setAccountToUnlock(index: number): Promise { + // TODO: Implement + } +} diff --git a/packages/keyring-eth-qr/tsconfig.build.json b/packages/keyring-eth-qr/tsconfig.build.json new file mode 100644 index 000000000..ccb5cea3e --- /dev/null +++ b/packages/keyring-eth-qr/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "target": "es2020" + }, + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts", "./src/sample.ts"] +} diff --git a/packages/keyring-eth-qr/tsconfig.json b/packages/keyring-eth-qr/tsconfig.json new file mode 100644 index 000000000..bba26c346 --- /dev/null +++ b/packages/keyring-eth-qr/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/packages/keyring-eth-qr/typedoc.json b/packages/keyring-eth-qr/typedoc.json new file mode 100644 index 000000000..b527b6257 --- /dev/null +++ b/packages/keyring-eth-qr/typedoc.json @@ -0,0 +1,6 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index d8d7df0f1..64894d613 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,6 +2,7 @@ "references": [ { "path": "./packages/keyring-api/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" }, { "path": "./packages/keyring-eth-trezor/tsconfig.build.json" }, { "path": "./packages/keyring-snap-bridge/tsconfig.build.json" } diff --git a/yarn.lock b/yarn.lock index b724881c1..7a1098e84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,7 +666,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/util@npm:^9.1.0": +"@ethereumjs/util@npm:^9.0.3, @ethereumjs/util@npm:^9.1.0": version: 9.1.0 resolution: "@ethereumjs/util@npm:9.1.0" dependencies: @@ -1449,6 +1449,69 @@ __metadata: languageName: node linkType: hard +"@keystonehq/alias-sampling@npm:^0.1.1": + version: 0.1.2 + resolution: "@keystonehq/alias-sampling@npm:0.1.2" + checksum: 10/4dfdfb91e070b1d9f28058c92b5b8fad81696ac63bd432cd6bd359f2ab92eb50df75e8c5da1f75a351756387e9902f043b3ecc2cbf662c9c9456ecacc848abfd + languageName: node + linkType: hard + +"@keystonehq/bc-ur-registry-eth@npm:^0.21.0": + version: 0.21.0 + resolution: "@keystonehq/bc-ur-registry-eth@npm:0.21.0" + dependencies: + "@ethereumjs/util": "npm:^9.0.3" + "@keystonehq/bc-ur-registry": "npm:^0.7.0" + hdkey: "npm:^2.0.1" + uuid: "npm:^8.3.2" + checksum: 10/2f694957c5e5d93db5523af59e5ed582dbbd9d08cc9c165ebe79b4f953ab2401b6212bb5c931e41ff9e5e6e8469f18c1aff90ff88b0ff85d093cd5794ddbdbbc + languageName: node + linkType: hard + +"@keystonehq/bc-ur-registry-eth@npm:^0.6.12": + version: 0.6.14 + resolution: "@keystonehq/bc-ur-registry-eth@npm:0.6.14" + dependencies: + "@keystonehq/bc-ur-registry": "npm:^0.4.4" + ethereumjs-util: "npm:^7.0.8" + hdkey: "npm:^2.0.1" + uuid: "npm:^8.3.2" + checksum: 10/d705d7d0d11a82db1417df9666f8a6bc5996eb46b543dc5f979d39d7694910d09c178a1acb6859caf2f93c902f3cc4614eb856936db84ea0b310ac18e5ab139d + languageName: node + linkType: hard + +"@keystonehq/bc-ur-registry@npm:^0.4.4": + version: 0.4.4 + resolution: "@keystonehq/bc-ur-registry@npm:0.4.4" + dependencies: + "@ngraveio/bc-ur": "npm:^1.1.5" + base58check: "npm:^2.0.0" + tslib: "npm:^2.3.0" + checksum: 10/640c5ef55e34713d338ecc29fef779203f2488041d54dd2f036ccd56f01dda1859ff20853b4eda53933a1f08f01d3f3e4c2fdc7b2e63886c88a40755da3a562e + languageName: node + linkType: hard + +"@keystonehq/bc-ur-registry@npm:^0.7.0": + version: 0.7.0 + resolution: "@keystonehq/bc-ur-registry@npm:0.7.0" + dependencies: + "@ngraveio/bc-ur": "npm:^1.1.5" + bs58check: "npm:^2.1.2" + tslib: "npm:^2.3.0" + checksum: 10/44bb60d0e8471ec3148fab47bc3484406ea43b378c4e5bfbadc8978ed898ed387ad5d1c4f6b2983dced8ead4c26414e28a3b80c45364b6d066e0050a910e89d4 + languageName: node + linkType: hard + +"@keystonehq/ur-decoder@npm:^0.12.2": + version: 0.12.2 + resolution: "@keystonehq/ur-decoder@npm:0.12.2" + dependencies: + "@keystonehq/bc-ur-registry-eth": "npm:^0.6.12" + "@ngraveio/bc-ur": "npm:^1.1.6" + checksum: 10/0904492775842dc01035bcf5fc1355918548c3e1ab9f0be677a55301d72bb87fea0377ee866bfa401568b99572114ac10b72d4b2d6cbc22a87a9d3eea2df9c37 + languageName: node + linkType: hard + "@lavamoat/aa@npm:^4.3.0": version: 4.3.0 resolution: "@lavamoat/aa@npm:4.3.0" @@ -1920,6 +1983,31 @@ __metadata: 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" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@keystonehq/bc-ur-registry-eth": "npm:^0.21.0" + "@keystonehq/ur-decoder": "npm:^0.12.2" + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/utils": "npm:^9.2.1" + "@ngraveio/bc-ur": "npm:^1.1.13" + "@types/hdkey": "npm:^2.0.1" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^20.12.12" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + hdkey: "npm:^2.1.0" + jest: "npm:^29.5.0" + ts-jest: "npm:^29.0.5" + ts-node: "npm:^10.9.2" + typedoc: "npm:^0.25.13" + typescript: "npm:~4.8.4" + languageName: unknown + linkType: soft + "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -2426,6 +2514,21 @@ __metadata: languageName: node linkType: hard +"@ngraveio/bc-ur@npm:^1.1.13, @ngraveio/bc-ur@npm:^1.1.5, @ngraveio/bc-ur@npm:^1.1.6": + version: 1.1.13 + resolution: "@ngraveio/bc-ur@npm:1.1.13" + dependencies: + "@keystonehq/alias-sampling": "npm:^0.1.1" + assert: "npm:^2.0.0" + bignumber.js: "npm:^9.0.1" + cbor-sync: "npm:^1.0.4" + crc: "npm:^3.8.0" + jsbi: "npm:^3.1.5" + sha.js: "npm:^2.4.11" + checksum: 10/0d3301b673a0bd9a069dae1f017cfd03010fddf19c1449d1a9e986b9b879ee4611f5af690ace9f59b75707573d1d3d6a4983166207db743425974a736689c6a0 + languageName: node + linkType: hard + "@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": version: 1.4.2 resolution: "@noble/curves@npm:1.4.2" @@ -4181,6 +4284,13 @@ __metadata: languageName: node linkType: hard +"base-x@npm:^1.1.0": + version: 1.1.0 + resolution: "base-x@npm:1.1.0" + checksum: 10/ca5f485f3868579e908187ccd50f4295a3ed3bd56a5d5dd266ba59a70c391155eabe50cd7976e6b696b5f7eca512019a886fcc6f27b4a738da1611249b3446f7 + languageName: node + linkType: hard + "base-x@npm:^3.0.2, base-x@npm:^3.0.9": version: 3.0.10 resolution: "base-x@npm:3.0.10" @@ -4197,6 +4307,15 @@ __metadata: languageName: node linkType: hard +"base58check@npm:^2.0.0": + version: 2.0.0 + resolution: "base58check@npm:2.0.0" + dependencies: + bs58: "npm:^3.0.0" + checksum: 10/19f77522a38d66d5c9cc16411880e258899a6807b31477e3ac33ebaa64555126e9b29e7d5d2be88748aae8b1d05f91e5eb3aef4847a033af25b8a0c0f995b037 + languageName: node + linkType: hard + "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -4254,7 +4373,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.1.2": +"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.0.1, bignumber.js@npm:^9.1.2": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" checksum: 10/d89b8800a987225d2c00dcbf8a69dc08e92aa0880157c851c287b307d31ceb2fc2acb0c62c3e3a3d42b6c5fcae9b004035f13eb4386e56d529d7edac18d5c9d8 @@ -4423,6 +4542,15 @@ __metadata: languageName: node linkType: hard +"bs58@npm:^3.0.0": + version: 3.1.0 + resolution: "bs58@npm:3.1.0" + dependencies: + base-x: "npm:^1.1.0" + checksum: 10/6d757a49958c43bc630f3b327511ce3ee065307c24240aa86189f72df0a4a8245c7ce7e7abad209b637f155006952c7b8f04bb4aa6ee4212a891882424bca82e + languageName: node + linkType: hard + "bs58@npm:^4.0.0, bs58@npm:^4.0.1": version: 4.0.1 resolution: "bs58@npm:4.0.1" @@ -4495,6 +4623,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.1.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6 + languageName: node + linkType: hard + "bufferutil@npm:^4.0.1": version: 4.0.8 resolution: "bufferutil@npm:4.0.8" @@ -4628,6 +4766,13 @@ __metadata: languageName: node linkType: hard +"cbor-sync@npm:^1.0.4": + version: 1.0.4 + resolution: "cbor-sync@npm:1.0.4" + checksum: 10/bdad5fbf442b5b2478ba59433cab145ad823f963f674ec42f3b730689e679327ec8a6dfab97724b63295badac915574139984e702475ff8025d7cb175e50e9ae + languageName: node + linkType: hard + "chalk-template@npm:1.1.0": version: 1.1.0 resolution: "chalk-template@npm:1.1.0" @@ -4935,6 +5080,15 @@ __metadata: languageName: node linkType: hard +"crc@npm:^3.8.0": + version: 3.8.0 + resolution: "crc@npm:3.8.0" + dependencies: + buffer: "npm:^5.1.0" + checksum: 10/3a43061e692113d60fbaf5e438c5f6aa3374fe2368244a75cc083ecee6762513bcee8583f67c2c56feea0b0c72b41b7304fbd3c1e26cfcfaec310b9a18543fa8 + languageName: node + linkType: hard + "create-hash@npm:^1.1.0, create-hash@npm:^1.1.2, create-hash@npm:^1.2.0": version: 1.2.0 resolution: "create-hash@npm:1.2.0" @@ -6091,7 +6245,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.0.9, ethereumjs-util@npm:^7.1.2": +"ethereumjs-util@npm:^7.0.8, ethereumjs-util@npm:^7.0.9, ethereumjs-util@npm:^7.1.2": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: @@ -6982,7 +7136,7 @@ __metadata: languageName: node linkType: hard -"hdkey@npm:^2.1.0": +"hdkey@npm:^2.0.1, hdkey@npm:^2.1.0": version: 2.1.0 resolution: "hdkey@npm:2.1.0" dependencies: @@ -7144,7 +7298,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 @@ -8262,6 +8416,13 @@ __metadata: languageName: node linkType: hard +"jsbi@npm:^3.1.5": + version: 3.2.5 + resolution: "jsbi@npm:3.2.5" + checksum: 10/2cceb3a06dcb16493e936aa22384d912dd5f0a1fd474b97b5c6705011bd0aac8214d9a392a730b3f3ffb61a8fbe910a34d0fe881329be6a02857520d7a61ace6 + languageName: node + linkType: hard + "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -10444,7 +10605,7 @@ __metadata: languageName: node linkType: hard -"sha.js@npm:^2.4.0, sha.js@npm:^2.4.8": +"sha.js@npm:^2.4.0, sha.js@npm:^2.4.11, sha.js@npm:^2.4.8": version: 2.4.11 resolution: "sha.js@npm:2.4.11" dependencies: @@ -11276,7 +11437,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.4.0": +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 From 922042c06bdb1308901812b02af7f791efafc24b Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 19 Mar 2025 12:18:37 +0100 Subject: [PATCH 02/46] fix account init --- packages/keyring-eth-qr/package.json | 5 +- .../keyring-eth-qr/src/account-deriver.ts | 75 +++++++++++- .../keyring-eth-qr/src/qr-keyring.test.ts | 17 +++ packages/keyring-eth-qr/src/qr-keyring.ts | 112 ++++++++++++------ yarn.lock | 39 +++--- 5 files changed, 186 insertions(+), 62 deletions(-) create mode 100644 packages/keyring-eth-qr/src/qr-keyring.test.ts diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index b0d1f15be..4203dbb58 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -37,9 +37,10 @@ }, "dependencies": { "@ethereumjs/util": "^9.1.0", - "@keystonehq/bc-ur-registry-eth": "^0.21.0", + "@keystonehq/bc-ur-registry-eth": "^0.19.1", "@keystonehq/ur-decoder": "^0.12.2", - "@metamask/utils": "^9.2.1", + "@metamask/keyring-utils": "workspace:^", + "@metamask/utils": "^11.3.0", "@ngraveio/bc-ur": "^1.1.13", "hdkey": "^2.1.0" }, diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index 7a7a569df..e69924a01 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -1,13 +1,20 @@ import { pubToAddress } from '@ethereumjs/util'; import { CryptoAccount, - type CryptoHDKey, + CryptoHDKey, + URRegistryDecoder, type CryptoOutput, } from '@keystonehq/bc-ur-registry-eth'; import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; import HDKey from 'hdkey'; -export type RootAccount = { fingerprint: Hex } & ( +export const SUPPORTED_UR_TYPE = { + CRYPTO_HDKEY: 'crypto-hdkey', + CRYPTO_ACCOUNT: 'crypto-account', + ETH_SIGNATURE: 'eth-signature', +}; + +export type RootAccount = { fingerprint: string } & ( | { type: 'hd'; hdPath: string; @@ -23,7 +30,16 @@ export type RootAccount = { fingerprint: Hex } & ( export class AccountDeriver { #root?: RootAccount; - init(source: CryptoAccount | CryptoHDKey) { + #cbor: Buffer | null = null; + + /** + * Initializes the AccountDeriver with a UR + * + * @param ur - The UR to initialize the AccountDeriver with + * @throws Will throw an error if the UR is not supported + */ + init(ur: string): void { + const source = this.#decodeUR(ur); const fingerprint = this.#getFingerprintFromSource(source); if (source instanceof CryptoAccount) { this.#root = { @@ -65,12 +81,38 @@ export class AccountDeriver { } } + /** + * Gets the CBOR encoded UR, if available + * + * @returns The CBOR encoded UR + */ + getCBOR(): Hex | null { + if (!this.#cbor) { + return null; + } + return add0x(this.#cbor.toString('hex')); + } + + /** + * Gets the fingerprint of the source CryptoAccount or CryptoHDKey + * + * @param source - The source CryptoAccount or CryptoHDKey + * @returns The fingerprint of the source + */ #getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): Hex { - return source instanceof CryptoAccount - ? add0x(source.getMasterFingerprint().toString('hex')) - : add0x(source.getOrigin().getSourceFingerprint().toString('hex')); + return add0x( + source instanceof CryptoAccount + ? source.getMasterFingerprint()?.toString('hex') + : source.getParentFingerprint()?.toString('hex'), + ); } + /** + * Gets the paths from the CryptoOutputDescriptors + * + * @param descriptors - The CryptoOutputDescriptors + * @returns The paths + */ #getPathsFromCryptoOutputDescriptors( descriptors: CryptoOutput[], ): Record { @@ -88,4 +130,25 @@ export class AccountDeriver { return paths; }, {}); } + + /** + * Decodes a UR + * + * @param ur - The UR to decode + * @returns The decoded CryptoAccount or CryptoHDKey + * @throws Will throw an error if the UR type is not supported + */ + #decodeUR(ur: string): CryptoAccount | CryptoHDKey { + const decodedUR = URRegistryDecoder.decode(ur); + this.#cbor = decodedUR.cbor; + + switch (decodedUR.type) { + case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: + return CryptoHDKey.fromCBOR(this.#cbor); + case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: + return CryptoAccount.fromCBOR(this.#cbor); + default: + throw new Error('Unsupported UR type'); + } + } } diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts new file mode 100644 index 000000000..89c14ab2f --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -0,0 +1,17 @@ +import { add0x, bytesToHex, type Hex } from '@metamask/utils'; +import { UR } from '@ngraveio/bc-ur'; + +import { QrKeyring } from './qr-keyring'; + +const MOCK_HDKEY_UR = + 'UR:CRYPTO-HDKEY/PTAOWKAXHDCLAXSTAMKNVLDPLPFSAACNVEOLIEJYGAVASSGAEHDEPEYKDESWTSFXWMENTPPSDNLKGSAAHDCXTEKTMNGRRTWMJZWLIELKCWWFFHPTTBFPKBKTSODNSAZEWYLKBAQDLPSNHECHWKLKAHTAADEHOEADCSFNAOAEAMTAADDYOTADLNCSDWYKCSFNYKAEYKAOCYPSSWIAVLAXAXATTAADDYOEADLRAEWKLAWKAXAEAYCYSBTKETFYASISGRIHKKJKJYJLJTIHBKJOHSIAIAJLKPJTJYDMJKJYHSJTIEHSJPIEQZBZVWLP'; + +describe('QrKeyring', () => { + describe('constructor', () => { + it('can be constructed with a CBOR encoded UR', () => { + // @ts-expect-error testing + const keyring = new QrKeyring({ cbor: MOCK_HDKEY_UR }); + expect(keyring).toBeInstanceOf(QrKeyring); + }); + }); +}); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 805d055be..b7b2431ad 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,15 +1,12 @@ -import { CryptoAccount, CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; -import { URRegistryDecoder } from '@keystonehq/ur-decoder'; -import type { Hex, Keyring } from '@metamask/utils'; -import { UR } from '@ngraveio/bc-ur'; +import type { Keyring } from '@metamask/keyring-utils'; +import type { Hex } from '@metamask/utils'; + import { AccountDeriver } from './account-deriver'; -export const QR_KEYRING_TYPE = 'QrKeyring' as const; +export const QR_KEYRING_TYPE = 'QrKeyring'; -export const SUPPORTED_UR_TYPE = { - CRYPTO_HDKEY: 'crypto-hdkey', - CRYPTO_ACCOUNT: 'crypto-account', - ETH_SIGNATURE: 'eth-signature', +export type QrKeyringOptions = { + cbor: Hex; }; /** @@ -19,58 +16,103 @@ export const SUPPORTED_UR_TYPE = { */ export type QrKeyringState = { accounts: Record; - cbor?: Hex; + cbor?: Hex | null; }; +/** + * A keyring that derives accounts from a CBOR encoded UR + */ export class QrKeyring implements Keyring { type = QR_KEYRING_TYPE; - #accounts: Record = {}; + #accounts: Map = new Map(); + + #accountToUnlock?: number | undefined; - #deriver: AccountDeriver = new AccountDeriver(); + readonly #deriver: AccountDeriver = new AccountDeriver(); + + constructor({ cbor }: QrKeyringOptions) { + if (cbor) { + this.submitCBOR(cbor); + } + } + /** + * Serializes the QrKeyring state + * + * @returns The serialized state + */ async serialize(): Promise { return { accounts: Object.values(this.#accounts), - cbor: '', // TODO: Serialize deriver + cbor: this.#deriver.getCBOR(), }; } + /** + * Deserializes the QrKeyring state + * + * @param state - The state to deserialize + */ async deserialize(state: QrKeyringState): Promise { - this.#accounts = state.accounts; + this.#accounts = new Map( + Object.entries(state.accounts).map(([index, address]) => [ + Number(index), + address, + ]), + ); if (state.cbor) { this.submitCBOR(state.cbor); } } - async addAccounts(_accountsToAdd: number): Promise { - // TODO: Implement + /** + * Adds accounts to the QrKeyring + * + * @param accountsToAdd - The number of accounts to add + * @returns The accounts added + */ + async addAccounts(accountsToAdd: number): Promise { + const lastIndex = this.#accountToUnlock ?? this.#accounts.size; + const newAccounts: Hex[] = []; + + for (let i = 0; i < accountsToAdd; i++) { + const index = lastIndex + i + 1; + const account = this.#deriver.deriveIndex(index); + this.#accounts.set(index, account); + newAccounts.push(account); + } + + this.#accountToUnlock = undefined; + + return newAccounts; } + /** + * Gets the accounts in the QrKeyring + * + * @returns The accounts in the QrKeyring + */ async getAccounts(): Promise { return Object.values(this.#accounts); } - submitCBOR(cbor: Hex) { - const ur = URRegistryDecoder.decode(cbor); - const bufferedCbor = Buffer.from(cbor, 'hex'); - let derivationSource: CryptoHDKey | CryptoAccount; - - switch (ur.type) { - case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: - derivationSource = CryptoHDKey.fromCBOR(bufferedCbor); - break; - case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: - derivationSource = CryptoAccount.fromCBOR(bufferedCbor); - break; - default: - throw new Error('Unsupported UR type'); - } - - this.#deriver.init(derivationSource); + /** + * Submits a CBOR encoded UR to the QrKeyring + * + * @param cbor - The CBOR encoded UR + * @throws An error if the UR type is not supported + */ + submitCBOR(cbor: Hex): void { + this.#deriver.init(cbor); } - async setAccountToUnlock(index: number): Promise { - // TODO: Implement + /** + * Sets the next account index to unlock + * + * @param index - The index of the account to unlock + */ + setAccountToUnlock(index: number): void { + this.#accountToUnlock = index; } } diff --git a/yarn.lock b/yarn.lock index f8880b084..cabfa4d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -655,7 +655,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/util@npm:^8.1.0": +"@ethereumjs/util@npm:^8.0.0, @ethereumjs/util@npm:^8.1.0": version: 8.1.0 resolution: "@ethereumjs/util@npm:8.1.0" dependencies: @@ -666,7 +666,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/util@npm:^9.0.3, @ethereumjs/util@npm:^9.1.0": +"@ethereumjs/util@npm:^9.1.0": version: 9.1.0 resolution: "@ethereumjs/util@npm:9.1.0" dependencies: @@ -1267,15 +1267,15 @@ __metadata: languageName: node linkType: hard -"@keystonehq/bc-ur-registry-eth@npm:^0.21.0": - version: 0.21.0 - resolution: "@keystonehq/bc-ur-registry-eth@npm:0.21.0" +"@keystonehq/bc-ur-registry-eth@npm:^0.19.1": + version: 0.19.1 + resolution: "@keystonehq/bc-ur-registry-eth@npm:0.19.1" dependencies: - "@ethereumjs/util": "npm:^9.0.3" - "@keystonehq/bc-ur-registry": "npm:^0.7.0" + "@ethereumjs/util": "npm:^8.0.0" + "@keystonehq/bc-ur-registry": "npm:^0.6.0" hdkey: "npm:^2.0.1" uuid: "npm:^8.3.2" - checksum: 10/2f694957c5e5d93db5523af59e5ed582dbbd9d08cc9c165ebe79b4f953ab2401b6212bb5c931e41ff9e5e6e8469f18c1aff90ff88b0ff85d093cd5794ddbdbbc + checksum: 10/7e64e6a754e6b66fc83a8f3880b54828c5b37f4eaaea3287eee31bd9d9b5ac0ba4cd4b8e751af9bd2f66e6f19291eaf02f46cd177d05ed9b30c1349cdd04572f languageName: node linkType: hard @@ -1302,14 +1302,14 @@ __metadata: languageName: node linkType: hard -"@keystonehq/bc-ur-registry@npm:^0.7.0": - version: 0.7.0 - resolution: "@keystonehq/bc-ur-registry@npm:0.7.0" +"@keystonehq/bc-ur-registry@npm:^0.6.0": + version: 0.6.4 + resolution: "@keystonehq/bc-ur-registry@npm:0.6.4" dependencies: "@ngraveio/bc-ur": "npm:^1.1.5" bs58check: "npm:^2.1.2" tslib: "npm:^2.3.0" - checksum: 10/44bb60d0e8471ec3148fab47bc3484406ea43b378c4e5bfbadc8978ed898ed387ad5d1c4f6b2983dced8ead4c26414e28a3b80c45364b6d066e0050a910e89d4 + checksum: 10/d4cdbefc14f3305543340d509564e1a795eb458327d46aad8665927999150df7e282939dcb714b81fea386061019e3b9f41eedbbb09a59d404355711c33159b2 languageName: node linkType: hard @@ -1785,11 +1785,12 @@ __metadata: resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@keystonehq/bc-ur-registry-eth": "npm:^0.21.0" + "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" "@keystonehq/ur-decoder": "npm:^0.12.2" "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/utils": "npm:^9.2.1" + "@metamask/keyring-utils": "workspace:^" + "@metamask/utils": "npm:^11.3.0" "@ngraveio/bc-ur": "npm:^1.1.13" "@types/hdkey": "npm:^2.0.1" "@types/jest": "npm:^29.5.12" @@ -2432,9 +2433,9 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/utils@npm:11.1.0" +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.3.0": + version: 11.3.0 + resolution: "@metamask/utils@npm:11.3.0" dependencies: "@ethereumjs/tx": "npm:^4.2.0" "@metamask/superstruct": "npm:^3.1.0" @@ -2445,11 +2446,11 @@ __metadata: pony-cause: "npm:^2.1.10" semver: "npm:^7.5.4" uuid: "npm:^9.0.1" - checksum: 10/756f13987881fe26adaa0a54354bc5af20cedee4dd228a736d481697dc634adb9e6e54d8f1dcc1d487b2376ab4ba8c576ecbb24beab2fb63aff721d0d5c0f5fe + checksum: 10/178367aaf608339cd7d6f9cb97e1cc01b58b03c55c0017e83ab8e08e66bcf061ee2f0dd867d4ed2a916cc03672f8dd0bb04b9ff79a0b43657e7d76bdbb2e72f2 languageName: node linkType: hard -"@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.2.1": +"@metamask/utils@npm:^9.0.0": version: 9.2.1 resolution: "@metamask/utils@npm:9.2.1" dependencies: From 4dcf294767fa8d003625aa584a3178840049b9e1 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 19 Mar 2025 13:08:09 +0100 Subject: [PATCH 03/46] test: add tests for serialize and deserialize --- .../keyring-eth-qr/src/account-deriver.ts | 18 +++-- .../keyring-eth-qr/src/qr-keyring.test.ts | 75 +++++++++++++++++-- packages/keyring-eth-qr/src/qr-keyring.ts | 49 ++++++++---- 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index e69924a01..29e327da0 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -14,7 +14,7 @@ export const SUPPORTED_UR_TYPE = { ETH_SIGNATURE: 'eth-signature', }; -export type RootAccount = { fingerprint: string } & ( +export type RootAccount = { fingerprint: Hex } & ( | { type: 'hd'; hdPath: string; @@ -30,6 +30,8 @@ export type RootAccount = { fingerprint: string } & ( export class AccountDeriver { #root?: RootAccount; + #ur: string | null = null; + #cbor: Buffer | null = null; /** @@ -73,9 +75,11 @@ export class AccountDeriver { return add0x(address); } else { const hdKey = HDKey.fromExtendedKey(this.#root.bip32xPub); - hdKey.derive(`m/${index}`); + const derived = hdKey.derive(`m/${index}`); const address = getChecksumAddress( - add0x(Buffer.from(pubToAddress(hdKey.publicKey, true)).toString('hex')), + add0x( + Buffer.from(pubToAddress(derived.publicKey, true)).toString('hex'), + ), ); return add0x(address); } @@ -86,11 +90,8 @@ export class AccountDeriver { * * @returns The CBOR encoded UR */ - getCBOR(): Hex | null { - if (!this.#cbor) { - return null; - } - return add0x(this.#cbor.toString('hex')); + getUR(): string | null { + return this.#ur; } /** @@ -140,6 +141,7 @@ export class AccountDeriver { */ #decodeUR(ur: string): CryptoAccount | CryptoHDKey { const decodedUR = URRegistryDecoder.decode(ur); + this.#ur = ur; this.#cbor = decodedUR.cbor; switch (decodedUR.type) { diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 89c14ab2f..0f936a7a2 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,6 +1,3 @@ -import { add0x, bytesToHex, type Hex } from '@metamask/utils'; -import { UR } from '@ngraveio/bc-ur'; - import { QrKeyring } from './qr-keyring'; const MOCK_HDKEY_UR = @@ -8,10 +5,76 @@ const MOCK_HDKEY_UR = describe('QrKeyring', () => { describe('constructor', () => { - it('can be constructed with a CBOR encoded UR', () => { - // @ts-expect-error testing - const keyring = new QrKeyring({ cbor: MOCK_HDKEY_UR }); + it('can be constructed with no arguments', () => { + const keyring = new QrKeyring({}); + expect(keyring).toBeInstanceOf(QrKeyring); + }); + + it('can be constructed with a UR', () => { + const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); expect(keyring).toBeInstanceOf(QrKeyring); }); }); + + describe('serialize', () => { + describe('when the QrKeyring has no accounts', () => { + it('returns the serialized state', async () => { + const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + + const serialized = await keyring.serialize(); + + expect(serialized).toStrictEqual({ + accounts: [], + ur: MOCK_HDKEY_UR, + }); + }); + }); + + describe('when the QrKeyring has accounts', () => { + it('returns the serialized state', async () => { + const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + const accounts = await keyring.addAccounts(1); + + const serialized = await keyring.serialize(); + + expect(serialized).toStrictEqual({ + accounts, + ur: MOCK_HDKEY_UR, + }); + }); + }); + }); + + describe('deserialize', () => { + describe('when the state does not include a UR', () => { + it('deserializes the state', async () => { + const keyring = new QrKeyring(); + + await keyring.deserialize({ accounts: [] }); + + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + }); + + describe('when the state includes a UR', () => { + it('deserializes the state', async () => { + const keyring = new QrKeyring(); + + await keyring.deserialize({ accounts: [], ur: MOCK_HDKEY_UR }); + + expect(keyring.ur).toStrictEqual(MOCK_HDKEY_UR); + }); + }); + }); + + describe('addAccounts', () => { + it('returns the new accounts', async () => { + const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + + const accounts = await keyring.addAccounts(1); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^0x[0-9a-f]{40}$/u); + }); + }); }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index b7b2431ad..4ff6bd312 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -3,10 +3,10 @@ import type { Hex } from '@metamask/utils'; import { AccountDeriver } from './account-deriver'; -export const QR_KEYRING_TYPE = 'QrKeyring'; +export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; export type QrKeyringOptions = { - cbor: Hex; + ur?: string; }; /** @@ -16,13 +16,15 @@ export type QrKeyringOptions = { */ export type QrKeyringState = { accounts: Record; - cbor?: Hex | null; + ur?: string | null; }; /** * A keyring that derives accounts from a CBOR encoded UR */ -export class QrKeyring implements Keyring { +export class QrKeyring implements Keyring { + static type = QR_KEYRING_TYPE; + type = QR_KEYRING_TYPE; #accounts: Map = new Map(); @@ -31,12 +33,21 @@ export class QrKeyring implements Keyring { readonly #deriver: AccountDeriver = new AccountDeriver(); - constructor({ cbor }: QrKeyringOptions) { - if (cbor) { - this.submitCBOR(cbor); + constructor(options?: QrKeyringOptions) { + if (options?.ur) { + this.submitUR(options.ur); } } + /** + * Gets the UR of the QrKeyring + * + * @returns The UR of the QrKeyring + */ + get ur(): string | null { + return this.#deriver.getUR(); + } + /** * Serializes the QrKeyring state * @@ -45,7 +56,7 @@ export class QrKeyring implements Keyring { async serialize(): Promise { return { accounts: Object.values(this.#accounts), - cbor: this.#deriver.getCBOR(), + ur: this.#deriver.getUR(), }; } @@ -55,14 +66,17 @@ export class QrKeyring implements Keyring { * @param state - The state to deserialize */ async deserialize(state: QrKeyringState): Promise { + const { accounts, ur } = state; + this.#accounts = new Map( - Object.entries(state.accounts).map(([index, address]) => [ + Object.entries(accounts).map(([index, address]) => [ Number(index), address, ]), ); - if (state.cbor) { - this.submitCBOR(state.cbor); + + if (ur) { + this.submitUR(ur); } } @@ -79,12 +93,16 @@ export class QrKeyring implements Keyring { for (let i = 0; i < accountsToAdd; i++) { const index = lastIndex + i + 1; const account = this.#deriver.deriveIndex(index); + + if (this.#accounts.has(index)) { + throw new Error(`Account at index ${index} already exists`); + } + this.#accounts.set(index, account); newAccounts.push(account); } this.#accountToUnlock = undefined; - return newAccounts; } @@ -100,11 +118,10 @@ export class QrKeyring implements Keyring { /** * Submits a CBOR encoded UR to the QrKeyring * - * @param cbor - The CBOR encoded UR - * @throws An error if the UR type is not supported + * @param ur - The CBOR encoded UR */ - submitCBOR(cbor: Hex): void { - this.#deriver.init(cbor); + submitUR(ur: string): void { + this.#deriver.init(ur); } /** From 2af99a267051293d5a46a6f5f067e69139af0f42 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 14:10:28 +0200 Subject: [PATCH 04/46] test: add crypto-hd account derivation tests --- packages/keyring-eth-qr/package.json | 5 +- .../keyring-eth-qr/src/account-deriver.ts | 51 ++++-- .../keyring-eth-qr/src/qr-keyring.test.ts | 164 ++++++++++++++++-- packages/keyring-eth-qr/src/qr-keyring.ts | 40 +++-- 4 files changed, 214 insertions(+), 46 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 4203dbb58..ef7071ea9 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -71,7 +71,10 @@ "secp256k1": true, "@lavamoat/preinstall-always-fail": false, "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>keccak": false, - "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>secp256k1": false + "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>secp256k1": false, + "@keystonehq/ur-decoder>@keystonehq/bc-ur-registry-eth>ethereumjs-util>ethereum-cryptography>keccak": false, + "@lavamoat/allow-scripts>@lavamoat/preinstall-always-fail": false, + "hdkey>secp256k1": false } } } diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index 29e327da0..d16a16092 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -1,4 +1,4 @@ -import { pubToAddress } from '@ethereumjs/util'; +import { publicToAddress } from '@ethereumjs/util'; import { CryptoAccount, CryptoHDKey, @@ -6,6 +6,7 @@ import { type CryptoOutput, } from '@keystonehq/bc-ur-registry-eth'; import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; +import { UR } from '@ngraveio/bc-ur'; import HDKey from 'hdkey'; export const SUPPORTED_UR_TYPE = { @@ -14,10 +15,13 @@ export const SUPPORTED_UR_TYPE = { ETH_SIGNATURE: 'eth-signature', }; +const DEFAULT_CHILDREN_PATH = '0/*'; + export type RootAccount = { fingerprint: Hex } & ( | { type: 'hd'; hdPath: string; + hdKey: HDKey; childrenPath: string; bip32xPub: string; } @@ -52,19 +56,26 @@ export class AccountDeriver { ), }; } else { + const bip32xPub = source.getBip32Key(); + const hdPath = `m/${source.getOrigin().getPath()}`; + const hdKey = HDKey.fromExtendedKey(bip32xPub); + const childrenPath = + source.getChildren()?.getPath() || DEFAULT_CHILDREN_PATH; + this.#root = { fingerprint, type: 'hd', - hdPath: `m/${source.getOrigin().getPath()}`, - childrenPath: source.getChildren().getPath(), - bip32xPub: source.getBip32Key(), + hdPath, + hdKey, + childrenPath, + bip32xPub, }; } } deriveIndex(index: number): Hex { if (!this.#root) { - throw new Error('AccountDeriver not initialized'); + throw new Error('UR not initialized'); } if (this.#root.type === 'account') { @@ -74,14 +85,22 @@ export class AccountDeriver { } return add0x(address); } else { + const resolvedPath = this.#root.childrenPath.replace( + '*', + index.toString(), + ); + + const childPath = resolvedPath.startsWith('m/') + ? resolvedPath + : `m/${resolvedPath}`; + const hdKey = HDKey.fromExtendedKey(this.#root.bip32xPub); - const derived = hdKey.derive(`m/${index}`); - const address = getChecksumAddress( - add0x( - Buffer.from(pubToAddress(derived.publicKey, true)).toString('hex'), - ), + const childKey = hdKey.derive(childPath); + + const address = add0x( + Buffer.from(publicToAddress(childKey.publicKey, true)).toString('hex'), ); - return add0x(address); + return getChecksumAddress(address); } } @@ -123,7 +142,7 @@ export class AccountDeriver { const path = `M/${hdKey.getOrigin().getPath()}`; const address = getChecksumAddress( add0x( - Buffer.from(pubToAddress(hdKey.getKey(), true)).toString('hex'), + Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), ), ); paths[address] = path; @@ -140,7 +159,13 @@ export class AccountDeriver { * @throws Will throw an error if the UR type is not supported */ #decodeUR(ur: string): CryptoAccount | CryptoHDKey { - const decodedUR = URRegistryDecoder.decode(ur); + let decodedUR: UR; + try { + decodedUR = URRegistryDecoder.decode(ur); + } catch (error) { + throw new Error(`Invalid UR format`); + } + this.#ur = ur; this.#cbor = decodedUR.cbor; diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 0f936a7a2..09d20334f 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,7 +1,13 @@ import { QrKeyring } from './qr-keyring'; -const MOCK_HDKEY_UR = - 'UR:CRYPTO-HDKEY/PTAOWKAXHDCLAXSTAMKNVLDPLPFSAACNVEOLIEJYGAVASSGAEHDEPEYKDESWTSFXWMENTPPSDNLKGSAAHDCXTEKTMNGRRTWMJZWLIELKCWWFFHPTTBFPKBKTSODNSAZEWYLKBAQDLPSNHECHWKLKAHTAADEHOEADCSFNAOAEAMTAADDYOTADLNCSDWYKCSFNYKAEYKAOCYPSSWIAVLAXAXATTAADDYOEADLRAEWKLAWKAXAEAYCYSBTKETFYASISGRIHKKJKJYJLJTIHBKJOHSIAIAJLKPJTJYDMJKJYHSJTIEHSJPIEQZBZVWLP'; +const KNOWN_HDKEY_UR = + 'UR:CRYPTO-HDKEY/ONAXHDCLAXPYSTPELBTLSNGWTTWPGSBSIOWKRFOXHSWSRHURSAHGMYMNTEFLTEBGADAHAXKTNBAAHDCXIODLDNDNZCONGOGMYKJLGHCLTDRYBEJTGOWZWLGLJKEOKIHEFHCLNLAMQDNDZSGEAMTAADDYOEADLNCSDWYKCSFNYKAEYKAOCYIHCHGSOYAYCYIHIAECEYASJNFPINJPFLHSJOCXDPCXJYIHJKJYINDAAAEC'; + +const EXPECTED_ACCOUNTS = [ + '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', + '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', + '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', +]; describe('QrKeyring', () => { describe('constructor', () => { @@ -11,35 +17,38 @@ describe('QrKeyring', () => { }); it('can be constructed with a UR', () => { - const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); expect(keyring).toBeInstanceOf(QrKeyring); + expect(keyring.ur).toBe(KNOWN_HDKEY_UR); }); }); describe('serialize', () => { describe('when the QrKeyring has no accounts', () => { it('returns the serialized state', async () => { - const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); const serialized = await keyring.serialize(); expect(serialized).toStrictEqual({ - accounts: [], - ur: MOCK_HDKEY_UR, + accounts: {}, + ur: KNOWN_HDKEY_UR, }); }); }); describe('when the QrKeyring has accounts', () => { it('returns the serialized state', async () => { - const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); - const accounts = await keyring.addAccounts(1); + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + await keyring.addAccounts(1); const serialized = await keyring.serialize(); expect(serialized).toStrictEqual({ - accounts, - ur: MOCK_HDKEY_UR, + accounts: { + '0': EXPECTED_ACCOUNTS[0], + }, + ur: KNOWN_HDKEY_UR, }); }); }); @@ -50,31 +59,150 @@ describe('QrKeyring', () => { it('deserializes the state', async () => { const keyring = new QrKeyring(); - await keyring.deserialize({ accounts: [] }); + await keyring.deserialize({ accounts: {} }); expect(await keyring.getAccounts()).toStrictEqual([]); }); }); + describe('when the state includes accounts but no UR', () => { + it('throws an error', async () => { + const keyring = new QrKeyring(); + + await expect( + keyring.deserialize({ accounts: { '0': EXPECTED_ACCOUNTS[0] } }), + ).rejects.toThrow( + 'QrKeyring state must include a UR when accounts are present', + ); + }); + }); + describe('when the state includes a UR', () => { it('deserializes the state', async () => { const keyring = new QrKeyring(); - await keyring.deserialize({ accounts: [], ur: MOCK_HDKEY_UR }); + await keyring.deserialize({ accounts: {}, ur: KNOWN_HDKEY_UR }); + + expect(keyring.ur).toStrictEqual(KNOWN_HDKEY_UR); + }); + }); + + describe('when the state includes a UR and accounts', () => { + it('deserializes the state with accounts', async () => { + const keyring = new QrKeyring(); + + await keyring.deserialize({ + accounts: { '0': EXPECTED_ACCOUNTS[0] }, + ur: KNOWN_HDKEY_UR, + }); - expect(keyring.ur).toStrictEqual(MOCK_HDKEY_UR); + expect(await keyring.getAccounts()).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, 1), + ); }); }); }); describe('addAccounts', () => { - it('returns the new accounts', async () => { - const keyring = new QrKeyring({ ur: MOCK_HDKEY_UR }); + describe('when the keyring is initialized with a UR', () => { + it('returns new accounts added', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); - const accounts = await keyring.addAccounts(1); + const accounts = await keyring.addAccounts(1); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toBe(EXPECTED_ACCOUNTS[0]); + }); + + it('adds multiple accounts', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + + const accounts = await keyring.addAccounts(3); + + expect(accounts).toHaveLength(3); + expect(accounts).toStrictEqual(EXPECTED_ACCOUNTS); + }); + + it('does not add accounts that already exist', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const firstAddition = await keyring.addAccounts(1); + keyring.setAccountToUnlock(0); + + const secondAddition = await keyring.addAccounts(1); + + expect(firstAddition).toStrictEqual(EXPECTED_ACCOUNTS.slice(0, 1)); + expect(secondAddition).toStrictEqual([]); + expect(await keyring.getAccounts()).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, 1), + ); + }); + }); + + describe('when the keyring is not initialized with a UR', () => { + it('throws an error', async () => { + const keyring = new QrKeyring(); + + await expect(keyring.addAccounts(1)).rejects.toThrow( + 'UR not initialized', + ); + }); + }); + }); + + describe('getAccounts', () => { + describe('when no accounts have been added', () => { + it('returns an empty array', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + expect(await keyring.getAccounts()).toStrictEqual([]); + }); + }); + + describe('when accounts have been added', () => { + it('returns all the accounts added', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + + const accounts = await keyring.addAccounts(3); + + expect(await keyring.getAccounts()).toStrictEqual(EXPECTED_ACCOUNTS); + expect(accounts).toStrictEqual(EXPECTED_ACCOUNTS); + }); + }); + }); + + describe('setAccountToUnlock', () => { + it('sets an arbitrary account index to unlock', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + + keyring.setAccountToUnlock(2); + + expect(await keyring.addAccounts(1)).toStrictEqual([ + EXPECTED_ACCOUNTS[2], + ]); + }); + }); + + describe('submitUR', () => { + it('initializes the QrKeyring with a UR', () => { + const keyring = new QrKeyring(); + + keyring.submitUR(KNOWN_HDKEY_UR); + + expect(keyring.ur).toBe(KNOWN_HDKEY_UR); + }); + + it('reinitializes the QrKeyring with a new UR', () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + + const newUr = + 'ur:crypto-hdkey/oxaxhdclaowdverokopdinhseeroisyalksaykctjshedprnuyjyfgrovawewftyghceglrpkgaahdcxtplfjsluknfwlaisaxwypalbjylswzamcxhscyuyloztmwfnldlgskpyptgsdecfamtaaddyoeadlncsdwykcsfnykaeykaocywlcscewfaycytedmfeayghlptnin'; + keyring.submitUR(newUr); + + expect(keyring.ur).toBe(newUr); + }); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toMatch(/^0x[0-9a-f]{40}$/u); + it('throws an error if the UR is invalid', () => { + const keyring = new QrKeyring(); + expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid UR format'); }); }); }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 4ff6bd312..7bded077c 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,5 +1,5 @@ import type { Keyring } from '@metamask/keyring-utils'; -import type { Hex } from '@metamask/utils'; +import { Hex, isObject } from '@metamask/utils'; import { AccountDeriver } from './account-deriver'; @@ -14,10 +14,15 @@ export type QrKeyringOptions = { * * @property accounts - The accounts in the QrKeyring */ -export type QrKeyringState = { - accounts: Record; - ur?: string | null; -}; +export type QrKeyringState = + | { + ur: string; + accounts: Record; + } + | { + ur?: string | null; + accounts: {}; + }; /** * A keyring that derives accounts from a CBOR encoded UR @@ -25,14 +30,14 @@ export type QrKeyringState = { export class QrKeyring implements Keyring { static type = QR_KEYRING_TYPE; - type = QR_KEYRING_TYPE; + readonly type = QR_KEYRING_TYPE; + + readonly #deriver: AccountDeriver = new AccountDeriver(); #accounts: Map = new Map(); #accountToUnlock?: number | undefined; - readonly #deriver: AccountDeriver = new AccountDeriver(); - constructor(options?: QrKeyringOptions) { if (options?.ur) { this.submitUR(options.ur); @@ -55,7 +60,7 @@ export class QrKeyring implements Keyring { */ async serialize(): Promise { return { - accounts: Object.values(this.#accounts), + accounts: Object.fromEntries(this.#accounts), ur: this.#deriver.getUR(), }; } @@ -68,6 +73,12 @@ export class QrKeyring implements Keyring { async deserialize(state: QrKeyringState): Promise { const { accounts, ur } = state; + if (Object.values(accounts).length && !ur) { + throw new Error( + 'QrKeyring state must include a UR when accounts are present', + ); + } + this.#accounts = new Map( Object.entries(accounts).map(([index, address]) => [ Number(index), @@ -91,14 +102,15 @@ export class QrKeyring implements Keyring { const newAccounts: Hex[] = []; for (let i = 0; i < accountsToAdd; i++) { - const index = lastIndex + i + 1; - const account = this.#deriver.deriveIndex(index); - + const index = lastIndex + i; if (this.#accounts.has(index)) { - throw new Error(`Account at index ${index} already exists`); + // If the account already exists, skip it + continue; } + const account = this.#deriver.deriveIndex(index); this.#accounts.set(index, account); + newAccounts.push(account); } @@ -112,7 +124,7 @@ export class QrKeyring implements Keyring { * @returns The accounts in the QrKeyring */ async getAccounts(): Promise { - return Object.values(this.#accounts); + return Array.from(this.#accounts.values()); } /** From 42e2d44e70d07f4aa039d56e24f62a5e457cb24f Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 14:15:29 +0200 Subject: [PATCH 05/46] fix: set `#accountToUnlock` to next index after adding accounts --- packages/keyring-eth-qr/src/qr-keyring.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 7bded077c..5ef0b81a1 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,5 +1,5 @@ import type { Keyring } from '@metamask/keyring-utils'; -import { Hex, isObject } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { AccountDeriver } from './account-deriver'; @@ -104,17 +104,15 @@ export class QrKeyring implements Keyring { for (let i = 0; i < accountsToAdd; i++) { const index = lastIndex + i; if (this.#accounts.has(index)) { - // If the account already exists, skip it continue; } const account = this.#deriver.deriveIndex(index); this.#accounts.set(index, account); - newAccounts.push(account); } - this.#accountToUnlock = undefined; + this.#accountToUnlock = lastIndex + accountsToAdd; return newAccounts; } From 47add0bbacfe845f085b2809e1210406c37f26ec Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 22:30:58 +0200 Subject: [PATCH 06/46] fix: make it compatible with keystone-keyring serialized state --- packages/keyring-eth-qr/package.json | 2 + .../keyring-eth-qr/src/account-deriver.ts | 258 +++++++++++------- .../keyring-eth-qr/src/qr-keyring.test.ts | 159 +++++++---- packages/keyring-eth-qr/src/qr-keyring.ts | 130 ++++++--- yarn.lock | 95 ++++++- 5 files changed, 447 insertions(+), 197 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index ef7071ea9..b6e3eff4c 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -45,6 +45,8 @@ "hdkey": "^2.1.0" }, "devDependencies": { + "@keystonehq/base-eth-keyring": "^0.15.1", + "@keystonehq/metamask-airgapped-keyring": "^0.15.2", "@lavamoat/allow-scripts": "^3.2.1", "@metamask/auto-changelog": "^3.4.4", "@types/hdkey": "^2.0.1", diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index d16a16092..ddb6e80cb 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -3,7 +3,6 @@ import { CryptoAccount, CryptoHDKey, URRegistryDecoder, - type CryptoOutput, } from '@keystonehq/bc-ur-registry-eth'; import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; import { UR } from '@ngraveio/bc-ur'; @@ -15,77 +14,159 @@ export const SUPPORTED_UR_TYPE = { ETH_SIGNATURE: 'eth-signature', }; +export enum KeyringMode { + HD = 'hd', + ACCOUNT = 'account', +} + const DEFAULT_CHILDREN_PATH = '0/*'; -export type RootAccount = { fingerprint: Hex } & ( - | { - type: 'hd'; - hdPath: string; - hdKey: HDKey; - childrenPath: string; - bip32xPub: string; - } - | { - type: 'account'; - addressPaths: Record; - } -); +export type CommonSourceDetails = { + /** + * Value take out from the device note field, if available. + */ + keyringAccount: string; + /** + * The name of the device + */ + name: string; + /** + * The device fingerprint, hex-encoded + */ + xfp: string; +}; -export class AccountDeriver { - #root?: RootAccount; +export type HDModeSourceDetails = { + /** + * The keyring mode is HD, indicating that it derives accounts from a + * root public key (xpub) and a derivation path. + */ + keyringMode: KeyringMode.HD; + /** + * The xpub of the HD key + */ + xpub: string; + /** + * The derivation path of the HD key + */ + hdPath: string; + /** + * The path used to derive child accounts + */ + childrenPath: string; +}; - #ur: string | null = null; +export type AccountModeSourceDetails = { + /** + * The keyring mode is ACCOUNT, indicating that it derives accounts from + * a set of addresses and their corresponding paths. + */ + keyringMode: KeyringMode.ACCOUNT; + /** + * The derivation paths for each hex-encoded address in the device + */ + paths: Record; +}; - #cbor: Buffer | null = null; +/** + * The details of the source CryptoAccount or CryptoHDKey + * that the AccountDeriver uses to derive accounts. + */ +export type AirgappedSourceDetails = CommonSourceDetails & + (HDModeSourceDetails | AccountModeSourceDetails); + +/** + * Get the fingerprint of the source CryptoAccount or CryptoHDKey + * + * @param source - The source CryptoAccount or CryptoHDKey + * @returns The fingerprint of the source + */ +function getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): string { + return source instanceof CryptoAccount + ? source.getMasterFingerprint()?.toString('hex') + : source.getParentFingerprint()?.toString('hex'); +} + +/** + * Get fingerprint, account paths and names from the a CryptoAccount + * + * Note: This function emulates the behavior of the `@keystonehq/base-eth-keyring` + * library when dealing with CryptoAccount objects (for backwards compatibility + * reasons). Though, the way it retrieves `name` and `keyringAccount` is questionable, + * as `name` and `keyringAccount` are updated after each descriptor discovery, effectively + * returning the last descriptor's `name` and `keyringAccount`. + * + * @param source - The source CryptoAccount + * @returns The paths + */ +function readCryptoAccountOutputDescriptors(source: CryptoAccount): { + xfp: string; + paths: Record; + name: string; + keyringAccount: string; +} { + const descriptors = source.getOutputDescriptors(); + + if (!descriptors || descriptors.length === 0) { + throw new Error('No output descriptors found in CryptoAccount'); + } + + let name = '', + keyringAccount = ''; + const paths = descriptors.reduce((paths: Record, current) => { + const hdKey = current.getHDKey(); + if (hdKey) { + const path = `M/${hdKey.getOrigin().getPath()}`; + const address = getChecksumAddress( + add0x( + Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), + ), + ); + paths[address] = path; + name = hdKey.getName(); + keyringAccount = hdKey.getNote(); + } + return paths; + }, {}); + + return { + paths, + name, + keyringAccount, + xfp: getFingerprintFromSource(source), + }; +} + +export class AccountDeriver { + #source?: AirgappedSourceDetails | undefined; /** - * Initializes the AccountDeriver with a UR + * Initializes the AccountDeriver * - * @param ur - The UR to initialize the AccountDeriver with + * @param source - The source CryptoAccount or CryptoHDKey, or a UR string * @throws Will throw an error if the UR is not supported */ - init(ur: string): void { - const source = this.#decodeUR(ur); - const fingerprint = this.#getFingerprintFromSource(source); - if (source instanceof CryptoAccount) { - this.#root = { - fingerprint, - type: 'account', - addressPaths: this.#getPathsFromCryptoOutputDescriptors( - source.getOutputDescriptors(), - ), - }; + init(source: AirgappedSourceDetails | string): void { + if (typeof source === 'string') { + this.#initFromUR(source); } else { - const bip32xPub = source.getBip32Key(); - const hdPath = `m/${source.getOrigin().getPath()}`; - const hdKey = HDKey.fromExtendedKey(bip32xPub); - const childrenPath = - source.getChildren()?.getPath() || DEFAULT_CHILDREN_PATH; - - this.#root = { - fingerprint, - type: 'hd', - hdPath, - hdKey, - childrenPath, - bip32xPub, - }; + this.#source = source; } } deriveIndex(index: number): Hex { - if (!this.#root) { + if (!this.#source) { throw new Error('UR not initialized'); } - if (this.#root.type === 'account') { - const address = Object.keys(this.#root.addressPaths)[index]; + if (this.#source.keyringMode === KeyringMode.ACCOUNT) { + const address = Object.keys(this.#source.paths)[index]; if (!address) { throw new Error(`Address not found for index ${index}`); } return add0x(address); } else { - const resolvedPath = this.#root.childrenPath.replace( + const resolvedPath = this.#source.childrenPath.replace( '*', index.toString(), ); @@ -94,7 +175,7 @@ export class AccountDeriver { ? resolvedPath : `m/${resolvedPath}`; - const hdKey = HDKey.fromExtendedKey(this.#root.bip32xPub); + const hdKey = HDKey.fromExtendedKey(this.#source.xpub); const childKey = hdKey.derive(childPath); const address = add0x( @@ -104,51 +185,45 @@ export class AccountDeriver { } } - /** - * Gets the CBOR encoded UR, if available - * - * @returns The CBOR encoded UR - */ - getUR(): string | null { - return this.#ur; + getSourceDetails(): AirgappedSourceDetails | undefined { + return this.#source; } - /** - * Gets the fingerprint of the source CryptoAccount or CryptoHDKey - * - * @param source - The source CryptoAccount or CryptoHDKey - * @returns The fingerprint of the source - */ - #getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): Hex { - return add0x( - source instanceof CryptoAccount - ? source.getMasterFingerprint()?.toString('hex') - : source.getParentFingerprint()?.toString('hex'), - ); + clear(): void { + this.#source = undefined; } /** - * Gets the paths from the CryptoOutputDescriptors + * Sets the root account from a UR string * - * @param descriptors - The CryptoOutputDescriptors - * @returns The paths + * @param ur - The UR string to set the root account from */ - #getPathsFromCryptoOutputDescriptors( - descriptors: CryptoOutput[], - ): Record { - return descriptors.reduce((paths: Record, current) => { - const hdKey = current.getHDKey(); - if (hdKey) { - const path = `M/${hdKey.getOrigin().getPath()}`; - const address = getChecksumAddress( - add0x( - Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), - ), - ); - paths[address] = path; - } - return paths; - }, {}); + #initFromUR(ur: string): void { + const source = this.#decodeUR(ur); + const fingerprint = getFingerprintFromSource(source); + + if (source instanceof CryptoAccount) { + const { name, xfp, paths, keyringAccount } = + readCryptoAccountOutputDescriptors(source); + this.#source = { + keyringMode: KeyringMode.ACCOUNT, + keyringAccount, + name, + xfp, + paths, + }; + } else { + const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; + this.#source = { + keyringMode: KeyringMode.HD, + keyringAccount: getNote(), + name: getName(), + xfp: fingerprint, + hdPath: `m/${getOrigin().getPath()}`, + childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, + xpub: getBip32Key(), + }; + } } /** @@ -166,14 +241,13 @@ export class AccountDeriver { throw new Error(`Invalid UR format`); } - this.#ur = ur; - this.#cbor = decodedUR.cbor; + const cbor = decodedUR.cbor; switch (decodedUR.type) { case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: - return CryptoHDKey.fromCBOR(this.#cbor); + return CryptoHDKey.fromCBOR(cbor); case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: - return CryptoAccount.fromCBOR(this.#cbor); + return CryptoAccount.fromCBOR(cbor); default: throw new Error('Unsupported UR type'); } diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 09d20334f..447fdd940 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,14 +1,84 @@ -import { QrKeyring } from './qr-keyring'; +import { StoredKeyring } from '@keystonehq/base-eth-keyring'; +import { CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; +import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; +import type { Hex } from '@metamask/utils'; +import { QrKeyring, SerializedQrKeyringState } from '.'; +import { KeyringMode } from './account-deriver'; const KNOWN_HDKEY_UR = 'UR:CRYPTO-HDKEY/ONAXHDCLAXPYSTPELBTLSNGWTTWPGSBSIOWKRFOXHSWSRHURSAHGMYMNTEFLTEBGADAHAXKTNBAAHDCXIODLDNDNZCONGOGMYKJLGHCLTDRYBEJTGOWZWLGLJKEOKIHEFHCLNLAMQDNDZSGEAMTAADDYOEADLNCSDWYKCSFNYKAEYKAOCYIHCHGSOYAYCYIHIAECEYASJNFPINJPFLHSJOCXDPCXJYIHJKJYINDAAAEC'; -const EXPECTED_ACCOUNTS = [ +const KNOWN_CBOR = + 'a503582103abc7af7fd5cd4fd1ec4c0f67f4bca461efb9dfc2578f8ed347d31201050377a0045820672f2b2bfda55552f56f5421d2bd106e55f2e94e73337d5f3f219906b39bfa4a06d90130a20186182cf5183cf500f5021a65174ca1081a65633532096d416972476170202d2074657374'; + +const KNOWN_HDKEY: CryptoHDKey = CryptoHDKey.fromCBOR( + Buffer.from(KNOWN_CBOR, 'hex'), +); + +const EXPECTED_ACCOUNTS: Hex[] = [ '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', ]; +// Coming from an extension installation using `@keystonehq/metamask-airgapped-keyring` +// and with a paired QR-based Device configured with the above known SRP +const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { + initialized: true, + accounts: [ + '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', + '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', + '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', + ], + keyringAccount: 'account.standard', + keyringMode: KeyringMode.HD, + name: 'AirGap - test', + version: 1, + xfp: '65174ca1', + xpub: 'xpub6CPzTSQVcBw9QiZT3wcB6bUVW53m1ica5uByu5ktss4aHSqdj85GitSLT9uarxQgoyfaedxkWZKAeSjSSMNSursAd7eiFpc5ywjnkS1Aur4', + hdPath: "m/44'/60'/0'", + childrenPath: '0/*', + indexes: { + '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5': 0, + '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B': 1, + '0xC64D05CD3582531f19dcB16e5FA9652B281fA018': 2, + }, + paths: {}, + // These last properties are not used by `@metamask/eth-qr-keyring` + currentAccount: 0, + page: 0, + perPage: 5, +}; + +const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { + initialized: true, + name: 'AirGap - test', + keyringMode: KeyringMode.HD, + keyringAccount: KNOWN_HDKEY.getNote(), + xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), + xpub: KNOWN_HDKEY.getBip32Key(), + accounts: [], + indexes: {}, + hdPath: "m/44'/60'/0'", + childrenPath: '0/*', +}; + +async function getXPUBFromKeyring( + keyring: QrKeyring | KeystoneKeyring, +): Promise { + const serialized = await keyring.serialize(); + if (!serialized.initialized || serialized.keyringMode !== KeyringMode.HD) { + throw new Error('Keyring is not initialized'); + } + return serialized.xpub; +} + +async function getLegacyKeystoneKeyring() { + const keystoneKeyring = new KeystoneKeyring(); + keystoneKeyring.deserialize(SERIALIZED_KEYSTONE_KEYRING); + return keystoneKeyring; +} + describe('QrKeyring', () => { describe('constructor', () => { it('can be constructed with no arguments', () => { @@ -16,10 +86,12 @@ describe('QrKeyring', () => { expect(keyring).toBeInstanceOf(QrKeyring); }); - it('can be constructed with a UR', () => { + it('can be constructed with a UR', async () => { const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); - expect(keyring).toBeInstanceOf(QrKeyring); - expect(keyring.ur).toBe(KNOWN_HDKEY_UR); + + expect(await keyring.serialize()).toStrictEqual( + SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + ); }); }); @@ -30,74 +102,57 @@ describe('QrKeyring', () => { const serialized = await keyring.serialize(); - expect(serialized).toStrictEqual({ - accounts: {}, - ur: KNOWN_HDKEY_UR, - }); + expect(serialized).toStrictEqual( + SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + ); }); }); describe('when the QrKeyring has accounts', () => { - it('returns the serialized state', async () => { + it('returns the serialized state including added accounts', async () => { const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); - await keyring.addAccounts(1); + await keyring.addAccounts(2); const serialized = await keyring.serialize(); expect(serialized).toStrictEqual({ - accounts: { - '0': EXPECTED_ACCOUNTS[0], + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]], + indexes: { + [EXPECTED_ACCOUNTS[0]!]: 0, + [EXPECTED_ACCOUNTS[1]!]: 1, }, - ur: KNOWN_HDKEY_UR, }); }); }); }); describe('deserialize', () => { - describe('when the state does not include a UR', () => { - it('deserializes the state', async () => { - const keyring = new QrKeyring(); - - await keyring.deserialize({ accounts: {} }); - - expect(await keyring.getAccounts()).toStrictEqual([]); - }); - }); - - describe('when the state includes accounts but no UR', () => { + describe('when the state includes accounts but not indexes', () => { it('throws an error', async () => { const keyring = new QrKeyring(); await expect( - keyring.deserialize({ accounts: { '0': EXPECTED_ACCOUNTS[0] } }), + keyring.deserialize({ accounts: EXPECTED_ACCOUNTS, indexes: {} }), ).rejects.toThrow( - 'QrKeyring state must include a UR when accounts are present', + 'The number of accounts does not match the number of indexes', ); }); }); - describe('when the state includes a UR', () => { - it('deserializes the state', async () => { - const keyring = new QrKeyring(); - - await keyring.deserialize({ accounts: {}, ur: KNOWN_HDKEY_UR }); - - expect(keyring.ur).toStrictEqual(KNOWN_HDKEY_UR); - }); - }); - - describe('when the state includes a UR and accounts', () => { + describe('when using a serialized state coming from `@keystonehq/metamask-airgapped-keyring`', () => { it('deserializes the state with accounts', async () => { + const keystoneKeyring = await getLegacyKeystoneKeyring(); const keyring = new QrKeyring(); - await keyring.deserialize({ - accounts: { '0': EXPECTED_ACCOUNTS[0] }, - ur: KNOWN_HDKEY_UR, - }); + // @ts-expect-error QrKeyring types are stricter than Keystone ones + await keyring.deserialize(await keystoneKeyring.serialize()); - expect(await keyring.getAccounts()).toStrictEqual( - EXPECTED_ACCOUNTS.slice(0, 1), + expect(await keystoneKeyring.getAccounts()).toStrictEqual( + await keystoneKeyring.getAccounts(), + ); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + await getXPUBFromKeyring(keystoneKeyring), ); }); }); @@ -182,22 +237,14 @@ describe('QrKeyring', () => { }); describe('submitUR', () => { - it('initializes the QrKeyring with a UR', () => { + it('initializes the QrKeyring with a UR', async () => { const keyring = new QrKeyring(); keyring.submitUR(KNOWN_HDKEY_UR); - expect(keyring.ur).toBe(KNOWN_HDKEY_UR); - }); - - it('reinitializes the QrKeyring with a new UR', () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); - - const newUr = - 'ur:crypto-hdkey/oxaxhdclaowdverokopdinhseeroisyalksaykctjshedprnuyjyfgrovawewftyghceglrpkgaahdcxtplfjsluknfwlaisaxwypalbjylswzamcxhscyuyloztmwfnldlgskpyptgsdecfamtaaddyoeadlncsdwykcsfnykaeykaocywlcscewfaycytedmfeayghlptnin'; - keyring.submitUR(newUr); - - expect(keyring.ur).toBe(newUr); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + SERIALIZED_KEYSTONE_KEYRING.xpub, + ); }); it('throws an error if the UR is invalid', () => { diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 5ef0b81a1..e7b914c2a 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,7 +1,12 @@ +import { TypedTxData } from '@ethereumjs/tx'; import type { Keyring } from '@metamask/keyring-utils'; -import type { Hex } from '@metamask/utils'; +import { add0x, assert, getChecksumAddress, Hex } from '@metamask/utils'; -import { AccountDeriver } from './account-deriver'; +import { + AccountDeriver, + AirgappedSourceDetails, + KeyringMode, +} from './account-deriver'; export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; @@ -14,15 +19,30 @@ export type QrKeyringOptions = { * * @property accounts - The accounts in the QrKeyring */ -export type QrKeyringState = +export type SerializedQrKeyringState = { + version?: number; + accounts: string[]; + indexes: Record; + currentAccount?: number; +} & ( | { - ur: string; - accounts: Record; + initialized?: false; } - | { - ur?: string | null; - accounts: {}; - }; + | ({ + initialized: true; + } & AirgappedSourceDetails) +); + +export const getDefaultSerializedQrKeyringState = + (): SerializedQrKeyringState => ({ + initialized: false, + accounts: [], + indexes: {}, + }); + +function normalizeAddress(address: string): Hex { + return getChecksumAddress(add0x(address)); +} /** * A keyring that derives accounts from a CBOR encoded UR @@ -44,51 +64,89 @@ export class QrKeyring implements Keyring { } } - /** - * Gets the UR of the QrKeyring - * - * @returns The UR of the QrKeyring - */ - get ur(): string | null { - return this.#deriver.getUR(); - } - /** * Serializes the QrKeyring state * * @returns The serialized state */ - async serialize(): Promise { - return { - accounts: Object.fromEntries(this.#accounts), - ur: this.#deriver.getUR(), - }; + async serialize(): Promise { + const source = this.#deriver.getSourceDetails(); + + if ( + !source || + ![KeyringMode.HD, KeyringMode.ACCOUNT].includes(source.keyringMode) + ) { + // the keyring has not initialized with a device source yet + return getDefaultSerializedQrKeyringState(); + } + + const accounts = Array.from(this.#accounts.values()); + const indexes = Object.fromEntries( + Array.from(this.#accounts.entries()).map(([index, address]) => [ + address, + index, + ]), + ); + + if (source.keyringMode === KeyringMode.HD) { + // These properties are only relevant for HD Keys + return { + initialized: true, + name: source.name, + keyringMode: KeyringMode.HD, + keyringAccount: source.keyringAccount, + xfp: source.xfp, + xpub: source.xpub, + hdPath: source.hdPath, + childrenPath: source.childrenPath, + accounts, + indexes, + }; + } else { + // These properties are only relevant for Account Keys + return { + initialized: true, + name: source.name, + keyringMode: KeyringMode.ACCOUNT, + keyringAccount: source.keyringAccount, + xfp: source.xfp, + paths: source.paths, + accounts, + indexes, + }; + } } /** * Deserializes the QrKeyring state * - * @param state - The state to deserialize + * @param state - The serialized state to deserialize */ - async deserialize(state: QrKeyringState): Promise { - const { accounts, ur } = state; + async deserialize(state: SerializedQrKeyringState): Promise { + if (!state.initialized) { + this.#accounts.clear(); + this.#deriver.clear(); + return; + } - if (Object.values(accounts).length && !ur) { + // Recover accounts from the serialized state + const { accounts = [], indexes = {} } = state; + if (accounts.length !== Object.keys(indexes).length) { throw new Error( - 'QrKeyring state must include a UR when accounts are present', + 'The number of accounts does not match the number of indexes', ); } - this.#accounts = new Map( - Object.entries(accounts).map(([index, address]) => [ - Number(index), - address, - ]), + accounts.map((address) => { + const normalizedAddress = normalizeAddress(address); + const index = indexes[normalizedAddress]; + assert(index !== undefined, 'Address not found in indexes map'); + return [index, normalizedAddress]; + }), ); - if (ur) { - this.submitUR(ur); - } + // Initialize the deriver with the source details + this.#deriver.init(state); } /** diff --git a/yarn.lock b/yarn.lock index 264d38ec3..bcbe47eeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -604,7 +604,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/common@npm:^4.2.0, @ethereumjs/common@npm:^4.4.0": +"@ethereumjs/common@npm:^4.1.0, @ethereumjs/common@npm:^4.2.0, @ethereumjs/common@npm:^4.4.0": version: 4.4.0 resolution: "@ethereumjs/common@npm:4.4.0" dependencies: @@ -622,7 +622,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/rlp@npm:^5.0.2": +"@ethereumjs/rlp@npm:^5.0.1, @ethereumjs/rlp@npm:^5.0.2": version: 5.0.2 resolution: "@ethereumjs/rlp@npm:5.0.2" bin: @@ -631,6 +631,23 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/tx@npm:5.1.0": + version: 5.1.0 + resolution: "@ethereumjs/tx@npm:5.1.0" + dependencies: + "@ethereumjs/common": "npm:^4.1.0" + "@ethereumjs/rlp": "npm:^5.0.1" + "@ethereumjs/util": "npm:^9.0.1" + ethereum-cryptography: "npm:^2.1.2" + peerDependencies: + c-kzg: ^2.1.2 + peerDependenciesMeta: + c-kzg: + optional: true + checksum: 10/f147f3a7101b1db055562b3eedda20c5a4b0cf8d518fc3416c4d40b1d116c7dbd17b003c348e88db45da0449b61cc503cebcc27f7095e6c5c023164ebb6498af + languageName: node + linkType: hard + "@ethereumjs/tx@npm:^4.2.0": version: 4.2.0 resolution: "@ethereumjs/tx@npm:4.2.0" @@ -655,6 +672,16 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/util@npm:9.1.0, @ethereumjs/util@npm:^9.0.1, @ethereumjs/util@npm:^9.1.0": + version: 9.1.0 + resolution: "@ethereumjs/util@npm:9.1.0" + dependencies: + "@ethereumjs/rlp": "npm:^5.0.2" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/4e22c4081c63eebb808eccd54f7f91cd3407f4cac192da5f30a0d6983fe07d51f25e6a9d08624f1376e604bb7dce574aafcf0fbf0becf42f62687c11e710ac41 + languageName: node + linkType: hard + "@ethereumjs/util@npm:^8.0.0, @ethereumjs/util@npm:^8.1.0": version: 8.1.0 resolution: "@ethereumjs/util@npm:8.1.0" @@ -666,16 +693,6 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/util@npm:^9.1.0": - version: 9.1.0 - resolution: "@ethereumjs/util@npm:9.1.0" - dependencies: - "@ethereumjs/rlp": "npm:^5.0.2" - ethereum-cryptography: "npm:^2.2.1" - checksum: 10/4e22c4081c63eebb808eccd54f7f91cd3407f4cac192da5f30a0d6983fe07d51f25e6a9d08624f1376e604bb7dce574aafcf0fbf0becf42f62687c11e710ac41 - languageName: node - linkType: hard - "@ethersproject/abi@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/abi@npm:5.7.0" @@ -1267,6 +1284,21 @@ __metadata: languageName: node linkType: hard +"@keystonehq/base-eth-keyring@npm:0.15.1, @keystonehq/base-eth-keyring@npm:^0.15.1": + version: 0.15.1 + resolution: "@keystonehq/base-eth-keyring@npm:0.15.1" + dependencies: + "@ethereumjs/rlp": "npm:^4.0.1" + "@ethereumjs/tx": "npm:5.1.0" + "@ethereumjs/util": "npm:^8.0.0" + "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" + hdkey: "npm:^2.0.1" + rlp: "npm:^3.0.0" + uuid: "npm:^8.3.2" + checksum: 10/49520dc7ed470f36fb31fef21b5cefb134acbffdfd633a421e1b7ccd726fd7e20743eb9f7e40f0e0b498e671be3e11ddf598a0d27cb7587ee00718c2b55b57f2 + languageName: node + linkType: hard + "@keystonehq/bc-ur-registry-eth@npm:^0.19.1": version: 0.19.1 resolution: "@keystonehq/bc-ur-registry-eth@npm:0.19.1" @@ -1313,6 +1345,22 @@ __metadata: languageName: node linkType: hard +"@keystonehq/metamask-airgapped-keyring@npm:^0.15.2": + version: 0.15.2 + resolution: "@keystonehq/metamask-airgapped-keyring@npm:0.15.2" + dependencies: + "@ethereumjs/rlp": "npm:^4.0.1" + "@ethereumjs/tx": "npm:5.1.0" + "@ethereumjs/util": "npm:9.1.0" + "@keystonehq/base-eth-keyring": "npm:0.15.1" + "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" + "@metamask/obs-store": "npm:^9.0.0" + rlp: "npm:^2.2.6" + uuid: "npm:^8.3.2" + checksum: 10/1b0de0f237d899369c625824f2081397a7ae5da10acea6efed37b20c56c14d6a0b4d27ff6549a0c6ea65a9f468023fa6339fe7693c8ab48ea05d16186d7d01cd + languageName: node + linkType: hard + "@keystonehq/ur-decoder@npm:^0.12.2": version: 0.12.2 resolution: "@keystonehq/ur-decoder@npm:0.12.2" @@ -1786,7 +1834,9 @@ __metadata: resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" dependencies: "@ethereumjs/util": "npm:^9.1.0" + "@keystonehq/base-eth-keyring": "npm:^0.15.1" "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" + "@keystonehq/metamask-airgapped-keyring": "npm:^0.15.2" "@keystonehq/ur-decoder": "npm:^0.12.2" "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" @@ -2195,6 +2245,16 @@ __metadata: languageName: node linkType: hard +"@metamask/obs-store@npm:^9.0.0": + version: 9.1.0 + resolution: "@metamask/obs-store@npm:9.1.0" + dependencies: + "@metamask/safe-event-emitter": "npm:^3.0.0" + readable-stream: "npm:^3.6.2" + checksum: 10/fa37a0b9e1e25f54c93a8f16449b3fc771c15b70d79d590cf41e22f871a19d673bcfd0d08b044093235d10d0bb031b47b8209d89997def0cb256abe3e371064f + languageName: node + linkType: hard + "@metamask/permission-controller@npm:^11.0.5": version: 11.0.5 resolution: "@metamask/permission-controller@npm:11.0.5" @@ -10164,7 +10224,7 @@ __metadata: languageName: node linkType: hard -"rlp@npm:^2.0.0, rlp@npm:^2.2.3, rlp@npm:^2.2.4": +"rlp@npm:^2.0.0, rlp@npm:^2.2.3, rlp@npm:^2.2.4, rlp@npm:^2.2.6": version: 2.2.7 resolution: "rlp@npm:2.2.7" dependencies: @@ -10175,6 +10235,15 @@ __metadata: languageName: node linkType: hard +"rlp@npm:^3.0.0": + version: 3.0.0 + resolution: "rlp@npm:3.0.0" + bin: + rlp: bin/rlp + checksum: 10/c85549fa5368ef029707d02f0937c0c503b69fb330c5941508c9eef537a4f179fbeecd17149aeb795d430ed5249b68d7c66383a9863068712a191d388786cfc1 + languageName: node + linkType: hard + "rpc-websockets@npm:^9.0.2": version: 9.0.2 resolution: "rpc-websockets@npm:9.0.2" From 6378b23180b69e06c01bcc643bd68dd61b334eb3 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 22:44:06 +0200 Subject: [PATCH 07/46] fix lint --- packages/keyring-eth-qr/CHANGELOG.md | 1 - .../keyring-eth-qr/src/account-deriver.ts | 83 +++++++++---------- .../keyring-eth-qr/src/qr-keyring.test.ts | 23 ++++- packages/keyring-eth-qr/src/qr-keyring.ts | 45 +++++----- 4 files changed, 85 insertions(+), 67 deletions(-) diff --git a/packages/keyring-eth-qr/CHANGELOG.md b/packages/keyring-eth-qr/CHANGELOG.md index 4dc68c6ff..825c32f0d 100644 --- a/packages/keyring-eth-qr/CHANGELOG.md +++ b/packages/keyring-eth-qr/CHANGELOG.md @@ -1,2 +1 @@ # Changelog - diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index ddb6e80cb..638add822 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -5,8 +5,8 @@ import { URRegistryDecoder, } from '@keystonehq/bc-ur-registry-eth'; import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; -import { UR } from '@ngraveio/bc-ur'; -import HDKey from 'hdkey'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import HdKey from 'hdkey'; export const SUPPORTED_UR_TYPE = { CRYPTO_HDKEY: 'crypto-hdkey', @@ -111,23 +111,26 @@ function readCryptoAccountOutputDescriptors(source: CryptoAccount): { throw new Error('No output descriptors found in CryptoAccount'); } - let name = '', - keyringAccount = ''; - const paths = descriptors.reduce((paths: Record, current) => { - const hdKey = current.getHDKey(); - if (hdKey) { - const path = `M/${hdKey.getOrigin().getPath()}`; - const address = getChecksumAddress( - add0x( - Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), - ), - ); - paths[address] = path; - name = hdKey.getName(); - keyringAccount = hdKey.getNote(); - } - return paths; - }, {}); + let name = ''; + let keyringAccount = ''; + const paths = descriptors.reduce( + (descriptorsPaths: Record, current) => { + const hdKey = current.getHDKey(); + if (hdKey) { + const path = `M/${hdKey.getOrigin().getPath()}`; + const address = getChecksumAddress( + add0x( + Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), + ), + ); + descriptorsPaths[address] = path; + name = hdKey.getName(); + keyringAccount = hdKey.getNote(); + } + return descriptorsPaths; + }, + {}, + ); return { paths, @@ -165,24 +168,23 @@ export class AccountDeriver { throw new Error(`Address not found for index ${index}`); } return add0x(address); - } else { - const resolvedPath = this.#source.childrenPath.replace( - '*', - index.toString(), - ); - - const childPath = resolvedPath.startsWith('m/') - ? resolvedPath - : `m/${resolvedPath}`; - - const hdKey = HDKey.fromExtendedKey(this.#source.xpub); - const childKey = hdKey.derive(childPath); - - const address = add0x( - Buffer.from(publicToAddress(childKey.publicKey, true)).toString('hex'), - ); - return getChecksumAddress(address); } + const resolvedPath = this.#source.childrenPath.replace( + '*', + index.toString(), + ); + + const childPath = resolvedPath.startsWith('m/') + ? resolvedPath + : `m/${resolvedPath}`; + + const hdKey = HdKey.fromExtendedKey(this.#source.xpub); + const childKey = hdKey.derive(childPath); + + const address = add0x( + Buffer.from(publicToAddress(childKey.publicKey, true)).toString('hex'), + ); + return getChecksumAddress(address); } getSourceDetails(): AirgappedSourceDetails | undefined { @@ -234,14 +236,9 @@ export class AccountDeriver { * @throws Will throw an error if the UR type is not supported */ #decodeUR(ur: string): CryptoAccount | CryptoHDKey { - let decodedUR: UR; - try { - decodedUR = URRegistryDecoder.decode(ur); - } catch (error) { - throw new Error(`Invalid UR format`); - } + const decodedUR = URRegistryDecoder.decode(ur); - const cbor = decodedUR.cbor; + const { cbor } = decodedUR; switch (decodedUR.type) { case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 447fdd940..7c3fb802e 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,8 +1,10 @@ -import { StoredKeyring } from '@keystonehq/base-eth-keyring'; +import type { StoredKeyring } from '@keystonehq/base-eth-keyring'; import { CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; import type { Hex } from '@metamask/utils'; -import { QrKeyring, SerializedQrKeyringState } from '.'; + +import type { SerializedQrKeyringState } from '.'; +import { QrKeyring } from '.'; import { KeyringMode } from './account-deriver'; const KNOWN_HDKEY_UR = @@ -63,6 +65,12 @@ const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { childrenPath: '0/*', }; +/** + * Get the xpub from a keyring. + * + * @param keyring - The keyring to get the xpub from. + * @returns The xpub of the keyring. + */ async function getXPUBFromKeyring( keyring: QrKeyring | KeystoneKeyring, ): Promise { @@ -73,7 +81,12 @@ async function getXPUBFromKeyring( return serialized.xpub; } -async function getLegacyKeystoneKeyring() { +/** + * Get a Keystone keyring instance using `@keystonehq/metamask-airgapped-keyring`. + * + * @returns A Keystone keyring instance with the serialized state. + */ +async function getLegacyKeystoneKeyring(): Promise { const keystoneKeyring = new KeystoneKeyring(); keystoneKeyring.deserialize(SERIALIZED_KEYSTONE_KEYRING); return keystoneKeyring; @@ -119,7 +132,9 @@ describe('QrKeyring', () => { ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]], indexes: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion [EXPECTED_ACCOUNTS[0]!]: 0, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion [EXPECTED_ACCOUNTS[1]!]: 1, }, }); @@ -249,7 +264,7 @@ describe('QrKeyring', () => { it('throws an error if the UR is invalid', () => { const keyring = new QrKeyring(); - expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid UR format'); + expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme'); }); }); }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index e7b914c2a..5df786d9e 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,12 +1,9 @@ -import { TypedTxData } from '@ethereumjs/tx'; import type { Keyring } from '@metamask/keyring-utils'; -import { add0x, assert, getChecksumAddress, Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { add0x, assert, getChecksumAddress } from '@metamask/utils'; -import { - AccountDeriver, - AirgappedSourceDetails, - KeyringMode, -} from './account-deriver'; +import type { AirgappedSourceDetails } from './account-deriver'; +import { AccountDeriver, KeyringMode } from './account-deriver'; export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; @@ -33,6 +30,11 @@ export type SerializedQrKeyringState = { } & AirgappedSourceDetails) ); +/** + * Returns the default serialized state of the QrKeyring. + * + * @returns The default serialized state. + */ export const getDefaultSerializedQrKeyringState = (): SerializedQrKeyringState => ({ initialized: false, @@ -40,6 +42,12 @@ export const getDefaultSerializedQrKeyringState = indexes: {}, }); +/** + * Normalizes an address to a 0x-prefixed checksum address. + * + * @param address - The address to normalize. + * @returns The normalized address as a Hex string. + */ function normalizeAddress(address: string): Hex { return getChecksumAddress(add0x(address)); } @@ -102,19 +110,18 @@ export class QrKeyring implements Keyring { accounts, indexes, }; - } else { - // These properties are only relevant for Account Keys - return { - initialized: true, - name: source.name, - keyringMode: KeyringMode.ACCOUNT, - keyringAccount: source.keyringAccount, - xfp: source.xfp, - paths: source.paths, - accounts, - indexes, - }; } + // These properties are only relevant for Account Keys + return { + initialized: true, + name: source.name, + keyringMode: KeyringMode.ACCOUNT, + keyringAccount: source.keyringAccount, + xfp: source.xfp, + paths: source.paths, + accounts, + indexes, + }; } /** From f1c94c1e97aa295c063fec761501da4f00147003 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 22:50:22 +0200 Subject: [PATCH 08/46] fix tests --- packages/keyring-eth-qr/jest.config.js | 8 +++---- .../keyring-eth-qr/src/qr-keyring.test.ts | 23 ++++++++++++++++--- packages/keyring-eth-qr/src/qr-keyring.ts | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index fdd7e10c8..fa7a1dffa 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 55.88, + functions: 81.81, + lines: 73.63, + statements: 73.2, }, }, }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 7c3fb802e..e96eadde5 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -143,12 +143,29 @@ describe('QrKeyring', () => { }); describe('deserialize', () => { - describe('when the state includes accounts but not indexes', () => { - it('throws an error', async () => { + describe('when deserializing a state with accounts', () => { + it('deserializes the state with accounts', async () => { + const keyring = new QrKeyring(); + + await keyring.deserialize(SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS); + + expect(await keyring.getAccounts()).toStrictEqual([]); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS.xpub, + ); + }); + + it('throws an error if accounts and indexes are not of the same number', async () => { const keyring = new QrKeyring(); await expect( - keyring.deserialize({ accounts: EXPECTED_ACCOUNTS, indexes: {} }), + keyring.deserialize({ + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: EXPECTED_ACCOUNTS, + indexes: { + // there should be 3 indexes mapped here + }, + }), ).rejects.toThrow( 'The number of accounts does not match the number of indexes', ); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 5df786d9e..85202a31d 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -138,6 +138,7 @@ export class QrKeyring implements Keyring { // Recover accounts from the serialized state const { accounts = [], indexes = {} } = state; + console.log(accounts, indexes); if (accounts.length !== Object.keys(indexes).length) { throw new Error( 'The number of accounts does not match the number of indexes', From 46df5a1a6c1658678ac0ddc7f7de56b2f5329b68 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 23:21:48 +0200 Subject: [PATCH 09/46] feat: add `removeAccount` --- packages/keyring-eth-qr/jest.config.js | 8 +++--- .../keyring-eth-qr/src/qr-keyring.test.ts | 26 +++++++++++++++++++ packages/keyring-eth-qr/src/qr-keyring.ts | 18 ++++++++++--- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index fa7a1dffa..e18fa86e4 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 55.88, - functions: 81.81, - lines: 73.63, - statements: 73.2, + branches: 57.14, + functions: 83.33, + lines: 74.33, + statements: 73.91, }, }, }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index e96eadde5..f643e806c 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -256,6 +256,32 @@ describe('QrKeyring', () => { }); }); + describe('removeAccount', () => { + it('removes an account from the keyring', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + await keyring.addAccounts(3); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + keyring.removeAccount(EXPECTED_ACCOUNTS[1]!); + + expect(await keyring.getAccounts()).toStrictEqual([ + EXPECTED_ACCOUNTS[0], + EXPECTED_ACCOUNTS[2], + ]); + }); + + it('does not throw if the account does not exist', async () => { + const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + await keyring.addAccounts(1); + const initialAccounts = await keyring.getAccounts(); + + expect(() => + keyring.removeAccount('0x0000000000000000000000000000000000000000'), + ).not.toThrow(); + expect(await keyring.getAccounts()).toStrictEqual(initialAccounts); + }); + }); + describe('setAccountToUnlock', () => { it('sets an arbitrary account index to unlock', async () => { const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 85202a31d..f3a582bea 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -52,9 +52,6 @@ function normalizeAddress(address: string): Hex { return getChecksumAddress(add0x(address)); } -/** - * A keyring that derives accounts from a CBOR encoded UR - */ export class QrKeyring implements Keyring { static type = QR_KEYRING_TYPE; @@ -138,7 +135,6 @@ export class QrKeyring implements Keyring { // Recover accounts from the serialized state const { accounts = [], indexes = {} } = state; - console.log(accounts, indexes); if (accounts.length !== Object.keys(indexes).length) { throw new Error( 'The number of accounts does not match the number of indexes', @@ -191,6 +187,20 @@ export class QrKeyring implements Keyring { return Array.from(this.#accounts.values()); } + /** + * Remove an account from the keyring + * + * @param address - The address of the account to remove + */ + removeAccount(address: Hex): void { + const normalizedAddress = normalizeAddress(address); + this.#accounts.forEach((storedAddress, storedIndex) => { + if (storedAddress === normalizedAddress) { + this.#accounts.delete(storedIndex); + } + }); + } + /** * Submits a CBOR encoded UR to the QrKeyring * From 5782759b707cf62000d995f9b52554427d8d3618 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 24 Jun 2025 23:23:38 +0200 Subject: [PATCH 10/46] fix tsconfig build --- packages/keyring-eth-qr/tsconfig.build.json | 5 +++++ packages/keyring-eth-qr/tsconfig.json | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/keyring-eth-qr/tsconfig.build.json b/packages/keyring-eth-qr/tsconfig.build.json index ccb5cea3e..08ef33751 100644 --- a/packages/keyring-eth-qr/tsconfig.build.json +++ b/packages/keyring-eth-qr/tsconfig.build.json @@ -6,6 +6,11 @@ "rootDir": "src", "target": "es2020" }, + "references": [ + { + "path": "../keyring-utils/tsconfig.build.json" + } + ], "include": ["./src/**/*.ts"], "exclude": ["./src/**/*.test.ts", "./src/sample.ts"] } diff --git a/packages/keyring-eth-qr/tsconfig.json b/packages/keyring-eth-qr/tsconfig.json index bba26c346..3b734c355 100644 --- a/packages/keyring-eth-qr/tsconfig.json +++ b/packages/keyring-eth-qr/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "baseUrl": "./" }, + "references": [{ "path": "../keyring-utils" }], "include": ["./src"], "exclude": ["./dist/**/*"] } From 5c7227b3e8759752b0994939161b2100cf6f08e2 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 25 Jun 2025 12:36:18 +0200 Subject: [PATCH 11/46] fix: accounts and cached indexes are treated separately --- .../keyring-eth-qr/src/account-deriver.ts | 89 ++++++++++++++++--- .../keyring-eth-qr/src/qr-keyring.test.ts | 73 +++++++++------ packages/keyring-eth-qr/src/qr-keyring.ts | 66 +++++--------- 3 files changed, 146 insertions(+), 82 deletions(-) diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/account-deriver.ts index 638add822..9e1172cd8 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/account-deriver.ts @@ -21,6 +21,8 @@ export enum KeyringMode { const DEFAULT_CHILDREN_PATH = '0/*'; +const MAX_INDEX = 1_000; + export type CommonSourceDetails = { /** * Value take out from the device note field, if available. @@ -34,6 +36,11 @@ export type CommonSourceDetails = { * The device fingerprint, hex-encoded */ xfp: string; + /** + * Indexes of the accounts derived from the device + * in the form of a map from address to index + */ + indexes: Record; }; export type HDModeSourceDetails = { @@ -157,7 +164,14 @@ export class AccountDeriver { } } - deriveIndex(index: number): Hex { + /** + * Derive an address from the source at a given index + * + * @param index - The index to derive the address from + * @returns The derived address in hex format + * @throws Will throw an error if the source is not initialized + */ + addressFromIndex(index: number): Hex { if (!this.#source) { throw new Error('UR not initialized'); } @@ -169,34 +183,83 @@ export class AccountDeriver { } return add0x(address); } - const resolvedPath = this.#source.childrenPath.replace( + const childPath = `m/${this.#source.childrenPath.replace( '*', index.toString(), - ); - - const childPath = resolvedPath.startsWith('m/') - ? resolvedPath - : `m/${resolvedPath}`; + )}`; const hdKey = HdKey.fromExtendedKey(this.#source.xpub); const childKey = hdKey.derive(childPath); - const address = add0x( - Buffer.from(publicToAddress(childKey.publicKey, true)).toString('hex'), - ); - return getChecksumAddress(address); + const address = Buffer.from( + publicToAddress(childKey.publicKey, true), + ).toString('hex'); + + const normalizedAddress = getChecksumAddress(add0x(address)); + + this.#source.indexes[normalizedAddress] = index; + + return normalizedAddress; + } + + /** + * Retrieve the index of an address from the source + * + * @param address - The normalized address to retrieve the index for + * @returns The index of the address + */ + indexFromAddress(address: Hex): number { + if (!this.#source) { + throw new Error('UR not initialized'); + } + + const cachedIndex = this.#source.indexes[address]; + if (cachedIndex !== undefined) { + return Number(cachedIndex); + } + + if (this.#source.keyringMode === KeyringMode.ACCOUNT) { + const path = this.#source.paths[address]; + if (path === undefined) { + throw new Error(`Unknown address`); + } + + const index = path.split('/').pop(); + if (index === undefined) { + throw new Error(`Invalid path for address ${address}`); + } + + return Number(index); + } + + for (let i = 0; i < MAX_INDEX; i++) { + const derivedAddress = this.addressFromIndex(i); + if (derivedAddress === address) { + return i; + } + } + + throw new Error(`Address ${address} not found`); } + /** + * Gets the source details of the AccountDeriver + * + * @returns The source details, or undefined if not initialized + */ getSourceDetails(): AirgappedSourceDetails | undefined { return this.#source; } + /** + * Clear the source details. + */ clear(): void { this.#source = undefined; } /** - * Sets the root account from a UR string + * Set the root account from a UR string * * @param ur - The UR string to set the root account from */ @@ -213,6 +276,7 @@ export class AccountDeriver { name, xfp, paths, + indexes: {}, }; } else { const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; @@ -224,6 +288,7 @@ export class AccountDeriver { hdPath: `m/${getOrigin().getPath()}`, childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, xpub: getBip32Key(), + indexes: {}, }; } } diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index f643e806c..1c23e2041 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -59,7 +59,6 @@ const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { keyringAccount: KNOWN_HDKEY.getNote(), xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), xpub: KNOWN_HDKEY.getBip32Key(), - accounts: [], indexes: {}, hdPath: "m/44'/60'/0'", childrenPath: '0/*', @@ -73,10 +72,10 @@ const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { */ async function getXPUBFromKeyring( keyring: QrKeyring | KeystoneKeyring, -): Promise { +): Promise { const serialized = await keyring.serialize(); - if (!serialized.initialized || serialized.keyringMode !== KeyringMode.HD) { - throw new Error('Keyring is not initialized'); + if (!('xpub' in serialized)) { + return undefined; } return serialized.xpub; } @@ -102,9 +101,10 @@ describe('QrKeyring', () => { it('can be constructed with a UR', async () => { const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); - expect(await keyring.serialize()).toStrictEqual( - SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, - ); + expect(await keyring.serialize()).toStrictEqual({ + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: [], + }); }); }); @@ -115,9 +115,11 @@ describe('QrKeyring', () => { const serialized = await keyring.serialize(); - expect(serialized).toStrictEqual( - SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, - ); + expect(serialized).toStrictEqual({ + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: [], + indexes: {}, + }); }); }); @@ -143,6 +145,17 @@ describe('QrKeyring', () => { }); describe('deserialize', () => { + describe('when deserializing an empty state', () => { + it('deserializes the state with no accounts', async () => { + const keyring = new QrKeyring(); + + await keyring.deserialize({}); + + expect(await keyring.getAccounts()).toStrictEqual([]); + expect(await getXPUBFromKeyring(keyring)).toBeUndefined(); + }); + }); + describe('when deserializing a state with accounts', () => { it('deserializes the state with accounts', async () => { const keyring = new QrKeyring(); @@ -154,22 +167,6 @@ describe('QrKeyring', () => { SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS.xpub, ); }); - - it('throws an error if accounts and indexes are not of the same number', async () => { - const keyring = new QrKeyring(); - - await expect( - keyring.deserialize({ - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, - accounts: EXPECTED_ACCOUNTS, - indexes: { - // there should be 3 indexes mapped here - }, - }), - ).rejects.toThrow( - 'The number of accounts does not match the number of indexes', - ); - }); }); describe('when using a serialized state coming from `@keystonehq/metamask-airgapped-keyring`', () => { @@ -210,6 +207,30 @@ describe('QrKeyring', () => { expect(accounts).toStrictEqual(EXPECTED_ACCOUNTS); }); + it('recovers indexes if they are not present in the serialized state', async () => { + const keyring = new QrKeyring(); + await keyring.deserialize({ + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: EXPECTED_ACCOUNTS.slice(0, 3), + }); + + // This function call will create the third account starting + // from the last account's index. + // This will require the keyring to recover indexes for the first two accounts + await keyring.addAccounts(1); + + const serialized = await keyring.serialize(); + expect(await keyring.getAccounts()).toHaveLength(3); + expect('indexes' in serialized && serialized.indexes).toStrictEqual({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + [EXPECTED_ACCOUNTS[0]!]: 0, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + [EXPECTED_ACCOUNTS[1]!]: 1, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + [EXPECTED_ACCOUNTS[2]!]: 2, + }); + }); + it('does not add accounts that already exist', async () => { const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); const firstAddition = await keyring.addAccounts(1); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index f3a582bea..8c17ac203 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,6 +1,6 @@ import type { Keyring } from '@metamask/keyring-utils'; import type { Hex } from '@metamask/utils'; -import { add0x, assert, getChecksumAddress } from '@metamask/utils'; +import { add0x, getChecksumAddress } from '@metamask/utils'; import type { AirgappedSourceDetails } from './account-deriver'; import { AccountDeriver, KeyringMode } from './account-deriver'; @@ -18,8 +18,7 @@ export type QrKeyringOptions = { */ export type SerializedQrKeyringState = { version?: number; - accounts: string[]; - indexes: Record; + accounts?: string[]; currentAccount?: number; } & ( | { @@ -39,7 +38,6 @@ export const getDefaultSerializedQrKeyringState = (): SerializedQrKeyringState => ({ initialized: false, accounts: [], - indexes: {}, }); /** @@ -59,7 +57,7 @@ export class QrKeyring implements Keyring { readonly #deriver: AccountDeriver = new AccountDeriver(); - #accounts: Map = new Map(); + #accounts: Hex[] = []; #accountToUnlock?: number | undefined; @@ -85,13 +83,7 @@ export class QrKeyring implements Keyring { return getDefaultSerializedQrKeyringState(); } - const accounts = Array.from(this.#accounts.values()); - const indexes = Object.fromEntries( - Array.from(this.#accounts.entries()).map(([index, address]) => [ - address, - index, - ]), - ); + const accounts = this.#accounts.slice(); if (source.keyringMode === KeyringMode.HD) { // These properties are only relevant for HD Keys @@ -105,7 +97,7 @@ export class QrKeyring implements Keyring { hdPath: source.hdPath, childrenPath: source.childrenPath, accounts, - indexes, + indexes: source.indexes, }; } // These properties are only relevant for Account Keys @@ -117,7 +109,7 @@ export class QrKeyring implements Keyring { xfp: source.xfp, paths: source.paths, accounts, - indexes, + indexes: source.indexes, }; } @@ -128,29 +120,13 @@ export class QrKeyring implements Keyring { */ async deserialize(state: SerializedQrKeyringState): Promise { if (!state.initialized) { - this.#accounts.clear(); + this.#accounts = []; this.#deriver.clear(); return; } - // Recover accounts from the serialized state - const { accounts = [], indexes = {} } = state; - if (accounts.length !== Object.keys(indexes).length) { - throw new Error( - 'The number of accounts does not match the number of indexes', - ); - } - this.#accounts = new Map( - accounts.map((address) => { - const normalizedAddress = normalizeAddress(address); - const index = indexes[normalizedAddress]; - assert(index !== undefined, 'Address not found in indexes map'); - return [index, normalizedAddress]; - }), - ); - - // Initialize the deriver with the source details this.#deriver.init(state); + this.#accounts = (state.accounts ?? []).map(normalizeAddress); } /** @@ -160,21 +136,25 @@ export class QrKeyring implements Keyring { * @returns The accounts added */ async addAccounts(accountsToAdd: number): Promise { - const lastIndex = this.#accountToUnlock ?? this.#accounts.size; + const lastAccount = this.#accounts[this.#accounts.length - 1]; + const startIndex = + this.#accountToUnlock ?? + (lastAccount ? this.#deriver.indexFromAddress(lastAccount) : 0); const newAccounts: Hex[] = []; for (let i = 0; i < accountsToAdd; i++) { - const index = lastIndex + i; - if (this.#accounts.has(index)) { + const index = startIndex + i; + const address = this.#deriver.addressFromIndex(index); + + if (this.#accounts.includes(address)) { continue; } - const account = this.#deriver.deriveIndex(index); - this.#accounts.set(index, account); - newAccounts.push(account); + this.#accounts.push(address); + newAccounts.push(address); } - this.#accountToUnlock = lastIndex + accountsToAdd; + this.#accountToUnlock = startIndex + accountsToAdd; return newAccounts; } @@ -194,11 +174,9 @@ export class QrKeyring implements Keyring { */ removeAccount(address: Hex): void { const normalizedAddress = normalizeAddress(address); - this.#accounts.forEach((storedAddress, storedIndex) => { - if (storedAddress === normalizedAddress) { - this.#accounts.delete(storedIndex); - } - }); + this.#accounts = this.#accounts.filter( + (account) => account !== normalizedAddress, + ); } /** From a39075940ea0d43e687d39b5fe4ddbac9b52f1b5 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 25 Jun 2025 12:36:55 +0200 Subject: [PATCH 12/46] fix: package export config --- packages/keyring-eth-qr/package.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index b6e3eff4c..160119e36 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -16,8 +16,20 @@ "type": "git", "url": "https://github.com/MetaMask/accounts.git" }, - "main": "dist/index.js", - "types": "dist/index.d.ts", + "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/" ], From 2217aa2752c2f14e28430ebb3e6a86a3773b9a91 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 25 Jun 2025 12:44:31 +0200 Subject: [PATCH 13/46] rename account-deriver to airgapped-signer --- packages/keyring-eth-qr/jest.config.js | 4 +-- ...account-deriver.ts => airgapped-signer.ts} | 27 +++++++++---------- .../keyring-eth-qr/src/qr-keyring.test.ts | 2 +- packages/keyring-eth-qr/src/qr-keyring.ts | 23 +++++++++------- 4 files changed, 29 insertions(+), 27 deletions(-) rename packages/keyring-eth-qr/src/{account-deriver.ts => airgapped-signer.ts} (91%) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index e18fa86e4..8d5e46b3a 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -22,8 +22,8 @@ module.exports = merge(baseConfig, { global: { branches: 57.14, functions: 83.33, - lines: 74.33, - statements: 73.91, + lines: 72.58, + statements: 73.22, }, }, }); diff --git a/packages/keyring-eth-qr/src/account-deriver.ts b/packages/keyring-eth-qr/src/airgapped-signer.ts similarity index 91% rename from packages/keyring-eth-qr/src/account-deriver.ts rename to packages/keyring-eth-qr/src/airgapped-signer.ts index 9e1172cd8..05a922d77 100644 --- a/packages/keyring-eth-qr/src/account-deriver.ts +++ b/packages/keyring-eth-qr/src/airgapped-signer.ts @@ -23,7 +23,7 @@ const DEFAULT_CHILDREN_PATH = '0/*'; const MAX_INDEX = 1_000; -export type CommonSourceDetails = { +export type CommonSignerDetails = { /** * Value take out from the device note field, if available. */ @@ -43,7 +43,7 @@ export type CommonSourceDetails = { indexes: Record; }; -export type HDModeSourceDetails = { +export type HDModeSignerDetails = { /** * The keyring mode is HD, indicating that it derives accounts from a * root public key (xpub) and a derivation path. @@ -63,7 +63,7 @@ export type HDModeSourceDetails = { childrenPath: string; }; -export type AccountModeSourceDetails = { +export type AccountModeSignerDetails = { /** * The keyring mode is ACCOUNT, indicating that it derives accounts from * a set of addresses and their corresponding paths. @@ -77,10 +77,10 @@ export type AccountModeSourceDetails = { /** * The details of the source CryptoAccount or CryptoHDKey - * that the AccountDeriver uses to derive accounts. + * that the AirgappedSigner uses to derive accounts. */ -export type AirgappedSourceDetails = CommonSourceDetails & - (HDModeSourceDetails | AccountModeSourceDetails); +export type AirgappedSignerDetails = CommonSignerDetails & + (HDModeSignerDetails | AccountModeSignerDetails); /** * Get the fingerprint of the source CryptoAccount or CryptoHDKey @@ -147,16 +147,15 @@ function readCryptoAccountOutputDescriptors(source: CryptoAccount): { }; } -export class AccountDeriver { - #source?: AirgappedSourceDetails | undefined; +export class AirgappedSigner { + #source?: AirgappedSignerDetails | undefined; /** - * Initializes the AccountDeriver + * Initialize the AirgappedSigner with a source. * - * @param source - The source CryptoAccount or CryptoHDKey, or a UR string - * @throws Will throw an error if the UR is not supported + * @param source - The signer source, in the form of details object, or a UR string */ - init(source: AirgappedSourceDetails | string): void { + init(source: AirgappedSignerDetails | string): void { if (typeof source === 'string') { this.#initFromUR(source); } else { @@ -243,11 +242,11 @@ export class AccountDeriver { } /** - * Gets the source details of the AccountDeriver + * Gets the source details of the AirgappedSigner. * * @returns The source details, or undefined if not initialized */ - getSourceDetails(): AirgappedSourceDetails | undefined { + getSourceDetails(): AirgappedSignerDetails | undefined { return this.#source; } diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 1c23e2041..1a97b6c83 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -5,7 +5,7 @@ import type { Hex } from '@metamask/utils'; import type { SerializedQrKeyringState } from '.'; import { QrKeyring } from '.'; -import { KeyringMode } from './account-deriver'; +import { KeyringMode } from './airgapped-signer'; const KNOWN_HDKEY_UR = 'UR:CRYPTO-HDKEY/ONAXHDCLAXPYSTPELBTLSNGWTTWPGSBSIOWKRFOXHSWSRHURSAHGMYMNTEFLTEBGADAHAXKTNBAAHDCXIODLDNDNZCONGOGMYKJLGHCLTDRYBEJTGOWZWLGLJKEOKIHEFHCLNLAMQDNDZSGEAMTAADDYOEADLNCSDWYKCSFNYKAEYKAOCYIHCHGSOYAYCYIHIAECEYASJNFPINJPFLHSJOCXDPCXJYIHJKJYINDAAAEC'; diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 8c17ac203..a45317e9b 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -2,8 +2,11 @@ import type { Keyring } from '@metamask/keyring-utils'; import type { Hex } from '@metamask/utils'; import { add0x, getChecksumAddress } from '@metamask/utils'; -import type { AirgappedSourceDetails } from './account-deriver'; -import { AccountDeriver, KeyringMode } from './account-deriver'; +import { + type AirgappedSignerDetails, + AirgappedSigner, + KeyringMode, +} from './airgapped-signer'; export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; @@ -26,7 +29,7 @@ export type SerializedQrKeyringState = { } | ({ initialized: true; - } & AirgappedSourceDetails) + } & AirgappedSignerDetails) ); /** @@ -55,7 +58,7 @@ export class QrKeyring implements Keyring { readonly type = QR_KEYRING_TYPE; - readonly #deriver: AccountDeriver = new AccountDeriver(); + readonly #signer: AirgappedSigner = new AirgappedSigner(); #accounts: Hex[] = []; @@ -73,7 +76,7 @@ export class QrKeyring implements Keyring { * @returns The serialized state */ async serialize(): Promise { - const source = this.#deriver.getSourceDetails(); + const source = this.#signer.getSourceDetails(); if ( !source || @@ -121,11 +124,11 @@ export class QrKeyring implements Keyring { async deserialize(state: SerializedQrKeyringState): Promise { if (!state.initialized) { this.#accounts = []; - this.#deriver.clear(); + this.#signer.clear(); return; } - this.#deriver.init(state); + this.#signer.init(state); this.#accounts = (state.accounts ?? []).map(normalizeAddress); } @@ -139,12 +142,12 @@ export class QrKeyring implements Keyring { const lastAccount = this.#accounts[this.#accounts.length - 1]; const startIndex = this.#accountToUnlock ?? - (lastAccount ? this.#deriver.indexFromAddress(lastAccount) : 0); + (lastAccount ? this.#signer.indexFromAddress(lastAccount) : 0); const newAccounts: Hex[] = []; for (let i = 0; i < accountsToAdd; i++) { const index = startIndex + i; - const address = this.#deriver.addressFromIndex(index); + const address = this.#signer.addressFromIndex(index); if (this.#accounts.includes(address)) { continue; @@ -185,7 +188,7 @@ export class QrKeyring implements Keyring { * @param ur - The CBOR encoded UR */ submitUR(ur: string): void { - this.#deriver.init(ur); + this.#signer.init(ur); } /** From 065a8901909f8ee2eb0e3a2958640b3f05c1daec Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 25 Jun 2025 23:11:00 +0200 Subject: [PATCH 14/46] add bridge and missing methods for pairing --- .../keyring-eth-qr/src/airgapped-signer.ts | 49 ++++++++++ packages/keyring-eth-qr/src/index.ts | 1 + .../keyring-eth-qr/src/qr-keyring-bridge.ts | 32 +++++++ packages/keyring-eth-qr/src/qr-keyring.ts | 94 ++++++++++++++++++- 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 packages/keyring-eth-qr/src/qr-keyring-bridge.ts diff --git a/packages/keyring-eth-qr/src/airgapped-signer.ts b/packages/keyring-eth-qr/src/airgapped-signer.ts index 05a922d77..0e59ce743 100644 --- a/packages/keyring-eth-qr/src/airgapped-signer.ts +++ b/packages/keyring-eth-qr/src/airgapped-signer.ts @@ -23,6 +23,9 @@ const DEFAULT_CHILDREN_PATH = '0/*'; const MAX_INDEX = 1_000; +/** + * Common details for the signer source, which can be either a CryptoAccount or CryptoHDKey. + */ export type CommonSignerDetails = { /** * Value take out from the device note field, if available. @@ -43,6 +46,10 @@ export type CommonSignerDetails = { indexes: Record; }; +/** + * Details for the HD mode of the AirgappedSigner. This mode derives + * accounts from a root public key (xpub) and a derivation path. + */ export type HDModeSignerDetails = { /** * The keyring mode is HD, indicating that it derives accounts from a @@ -63,6 +70,10 @@ export type HDModeSignerDetails = { childrenPath: string; }; +/** + * Details for the Account mode of the AirgappedSigner. This mode derives + * accounts from a set of addresses and their corresponding paths. + */ export type AccountModeSignerDetails = { /** * The keyring mode is ACCOUNT, indicating that it derives accounts from @@ -75,6 +86,14 @@ export type AccountModeSignerDetails = { paths: Record; }; +/** + * An address with its corresponding index. + */ +export type IndexedAddress = { + address: Hex; + index: number; +}; + /** * The details of the source CryptoAccount or CryptoHDKey * that the AirgappedSigner uses to derive accounts. @@ -163,6 +182,15 @@ export class AirgappedSigner { } } + /** + * Check if the AirgappedSigner is initialized + * + * @returns True if the AirgappedSigner is initialized, false otherwise + */ + isInitialized(): boolean { + return this.#source !== undefined; + } + /** * Derive an address from the source at a given index * @@ -241,6 +269,27 @@ export class AirgappedSigner { throw new Error(`Address ${address} not found`); } + /** + * Get a page of addresses derived from the source. + * + * @param page - The page number to retrieve + * @param pageSize - The number of addresses per page + * @returns An array of IndexedAddress objects, each containing the address and its index + * @throws Will throw an error if the source is not initialized + */ + getAddressesPage(page: number, pageSize = 5): IndexedAddress[] { + const startIndex = page * pageSize; + const endIndex = startIndex + pageSize; + const addresses: IndexedAddress[] = []; + + for (let i = startIndex; i < endIndex; i++) { + const address = this.addressFromIndex(i); + addresses.push({ address, index: i }); + } + + return addresses; + } + /** * Gets the source details of the AirgappedSigner. * diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts index bea7d1d3f..c79e5c071 100644 --- a/packages/keyring-eth-qr/src/index.ts +++ b/packages/keyring-eth-qr/src/index.ts @@ -1 +1,2 @@ +export * from './qr-keyring-bridge'; export * from './qr-keyring'; diff --git a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts new file mode 100644 index 000000000..3d89fc74b --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts @@ -0,0 +1,32 @@ +import type { QrKeyringBridge } from './qr-keyring'; + +/** + * Options for the QrKeyringScannerBridge. + */ +export type QrKeyringScannerBridgeOptions = { + /** + * An injected function that the bridge uses to request a QR code scan. + */ + requestScan: () => Promise; +}; + +/** + * A bridge that allows the QrKeyring to request a QR code scan + * while keeping the implementation defined by the QrKeyring consumer. + */ +export class QrKeyringScannerBridge implements QrKeyringBridge { + readonly #requestScan: () => Promise; + + constructor({ requestScan }: QrKeyringScannerBridgeOptions) { + this.#requestScan = requestScan; + } + + /** + * Requests a QR code scan and returns the scanned data. + * + * @returns The scanned data as a string. + */ + async requestScan(): Promise { + return this.#requestScan(); + } +} diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index a45317e9b..17184a393 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -4,14 +4,20 @@ import { add0x, getChecksumAddress } from '@metamask/utils'; import { type AirgappedSignerDetails, + type IndexedAddress, AirgappedSigner, KeyringMode, } from './airgapped-signer'; export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; +export type QrKeyringBridge = { + requestScan: () => Promise; +}; + export type QrKeyringOptions = { ur?: string; + bridge: QrKeyringBridge; }; /** @@ -58,13 +64,19 @@ export class QrKeyring implements Keyring { readonly type = QR_KEYRING_TYPE; + readonly bridge: QrKeyringBridge; + readonly #signer: AirgappedSigner = new AirgappedSigner(); #accounts: Hex[] = []; #accountToUnlock?: number | undefined; - constructor(options?: QrKeyringOptions) { + #currentPage: number = 0; + + constructor(options: QrKeyringOptions) { + this.bridge = options.bridge; + if (options?.ur) { this.submitUR(options.ur); } @@ -199,4 +211,84 @@ export class QrKeyring implements Keyring { setAccountToUnlock(index: number): void { this.#accountToUnlock = index; } + + /** + * Get the name of the paired device or the keyring type + * if unavailable. + * + * @returns The name of the paired device or the keyring type. + */ + getName(): string { + const source = this.#signer.getSourceDetails(); + return source?.name ?? QR_KEYRING_TYPE; + } + + /** + * Fetch the first page of accounts. If the keyring is not currently initialized, + * it will trigger a scan request to initialize it. + * + * @returns The first page of accounts as an array of Hex strings. + */ + async getFirstPage(): Promise { + this.#currentPage = 0; + return this.getCurrentPage(); + } + + /** + * Fetch the next page of accounts. If the keyring is not currently initialized, + * it will trigger a scan request to initialize it. + * + * @returns The next page of accounts as an array of IndexedAddress objects. + */ + async getNextPage(): Promise { + this.#currentPage += 1; + return this.getCurrentPage(); + } + + /** + * Fetch the previous page of accounts. If the keyring is not currently initialized, + * it will trigger a scan request to initialize it. + * + * @returns The previous page of accounts as an array of IndexedAddress objects. + */ + async getPreviousPage(): Promise { + if (this.#currentPage > 0) { + this.#currentPage -= 1; + } else { + this.#currentPage = 0; + } + return this.getCurrentPage(); + } + + /** + * Fetch the current page of accounts. If the keyring is not currently initialized, + * it will trigger a scan request to initialize it. + * + * @returns The current page of accounts as an array of IndexedAddress objects. + */ + async getCurrentPage(): Promise { + if (!this.#signer.isInitialized()) { + await this.#scanAndInitialize(); + } + + return this.#signer.getAddressesPage(this.#currentPage); + } + + /** + * Clear the keyring state and forget any paired device or accounts. + */ + async forgetDevice(): Promise { + this.#signer.clear(); + this.#accounts = []; + this.#accountToUnlock = undefined; + this.#currentPage = 0; + } + + /** + * Scan for a QR code and initialize the keyring with the + * scanned UR. + */ + async #scanAndInitialize(): Promise { + this.submitUR(await this.bridge.requestScan()); + } } From 18060dca1928e91696896a44d3912cd4ed3a173a Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 00:38:52 +0200 Subject: [PATCH 15/46] accept decoded URs as input --- .../keyring-eth-qr/src/airgapped-signer.ts | 26 ++++++++++++------- .../keyring-eth-qr/src/qr-keyring-bridge.ts | 8 +++--- packages/keyring-eth-qr/src/qr-keyring.ts | 9 +++++-- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/keyring-eth-qr/src/airgapped-signer.ts b/packages/keyring-eth-qr/src/airgapped-signer.ts index 0e59ce743..7d6f7a8f5 100644 --- a/packages/keyring-eth-qr/src/airgapped-signer.ts +++ b/packages/keyring-eth-qr/src/airgapped-signer.ts @@ -8,6 +8,8 @@ import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; // eslint-disable-next-line @typescript-eslint/naming-convention import HdKey from 'hdkey'; +import type { QrScanResponse } from './qr-keyring'; + export const SUPPORTED_UR_TYPE = { CRYPTO_HDKEY: 'crypto-hdkey', CRYPTO_ACCOUNT: 'crypto-account', @@ -174,8 +176,8 @@ export class AirgappedSigner { * * @param source - The signer source, in the form of details object, or a UR string */ - init(source: AirgappedSignerDetails | string): void { - if (typeof source === 'string') { + init(source: AirgappedSignerDetails | string | QrScanResponse): void { + if (typeof source === 'string' || 'cbor' in source) { this.#initFromUR(source); } else { this.#source = source; @@ -311,7 +313,7 @@ export class AirgappedSigner { * * @param ur - The UR string to set the root account from */ - #initFromUR(ur: string): void { + #initFromUR(ur: string | QrScanResponse): void { const source = this.#decodeUR(ur); const fingerprint = getFingerprintFromSource(source); @@ -348,12 +350,18 @@ export class AirgappedSigner { * @returns The decoded CryptoAccount or CryptoHDKey * @throws Will throw an error if the UR type is not supported */ - #decodeUR(ur: string): CryptoAccount | CryptoHDKey { - const decodedUR = URRegistryDecoder.decode(ur); - - const { cbor } = decodedUR; - - switch (decodedUR.type) { + #decodeUR(ur: string | QrScanResponse): CryptoAccount | CryptoHDKey { + const decodedUR = + typeof ur === 'string' + ? URRegistryDecoder.decode(ur) + : { + type: ur.type, + cbor: Buffer.from(ur.cbor, 'hex'), + }; + + const { type, cbor } = decodedUR; + + switch (type) { case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: return CryptoHDKey.fromCBOR(cbor); case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: diff --git a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts index 3d89fc74b..7c47d9676 100644 --- a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts +++ b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts @@ -1,4 +1,4 @@ -import type { QrKeyringBridge } from './qr-keyring'; +import type { QrKeyringBridge, QrScanResponse } from './qr-keyring'; /** * Options for the QrKeyringScannerBridge. @@ -7,7 +7,7 @@ export type QrKeyringScannerBridgeOptions = { /** * An injected function that the bridge uses to request a QR code scan. */ - requestScan: () => Promise; + requestScan: () => Promise; }; /** @@ -15,7 +15,7 @@ export type QrKeyringScannerBridgeOptions = { * while keeping the implementation defined by the QrKeyring consumer. */ export class QrKeyringScannerBridge implements QrKeyringBridge { - readonly #requestScan: () => Promise; + readonly #requestScan: () => Promise; constructor({ requestScan }: QrKeyringScannerBridgeOptions) { this.#requestScan = requestScan; @@ -26,7 +26,7 @@ export class QrKeyringScannerBridge implements QrKeyringBridge { * * @returns The scanned data as a string. */ - async requestScan(): Promise { + async requestScan(): Promise { return this.#requestScan(); } } diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 17184a393..790b8ac86 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -11,8 +11,13 @@ import { export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; +export type QrScanResponse = { + type: string; + cbor: Hex; +}; + export type QrKeyringBridge = { - requestScan: () => Promise; + requestScan: () => Promise; }; export type QrKeyringOptions = { @@ -199,7 +204,7 @@ export class QrKeyring implements Keyring { * * @param ur - The CBOR encoded UR */ - submitUR(ur: string): void { + submitUR(ur: string | QrScanResponse): void { this.#signer.init(ur); } From 8a027ff297a4427b2f99047448f80e9de0d7cffb Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 00:49:25 +0200 Subject: [PATCH 16/46] align dependencies to monorepo and remove unused --- README.md | 3 + packages/keyring-eth-qr/package.json | 6 +- yarn.lock | 90 ++-------------------------- 3 files changed, 10 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 39a66916b..b06c796b1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,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-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) - [`@metamask/eth-trezor-keyring`](packages/keyring-eth-trezor) @@ -37,6 +38,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_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); keyring_internal_api(["@metamask/keyring-internal-api"]); @@ -48,6 +50,7 @@ linkStyle default opacity:0.5 keyring_api --> keyring_utils; eth_hd_keyring --> keyring_utils; eth_ledger_bridge_keyring --> keyring_utils; + eth_qr_keyring --> keyring_utils; eth_simple_keyring --> keyring_utils; eth_trezor_keyring --> keyring_utils; keyring_internal_api --> keyring_api; diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 160119e36..83753d2d7 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -50,10 +50,8 @@ "dependencies": { "@ethereumjs/util": "^9.1.0", "@keystonehq/bc-ur-registry-eth": "^0.19.1", - "@keystonehq/ur-decoder": "^0.12.2", "@metamask/keyring-utils": "workspace:^", - "@metamask/utils": "^11.3.0", - "@ngraveio/bc-ur": "^1.1.13", + "@metamask/utils": "^11.1.0", "hdkey": "^2.1.0" }, "devDependencies": { @@ -70,7 +68,7 @@ "ts-jest": "^29.0.5", "ts-node": "^10.9.2", "typedoc": "^0.25.13", - "typescript": "~4.8.4" + "typescript": "~5.6.3" }, "engines": { "node": "^18.18 || >=20" diff --git a/yarn.lock b/yarn.lock index a846b62d5..15a4e8b08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1279,29 +1279,6 @@ __metadata: languageName: node linkType: hard -"@keystonehq/bc-ur-registry-eth@npm:^0.6.12": - version: 0.6.14 - resolution: "@keystonehq/bc-ur-registry-eth@npm:0.6.14" - dependencies: - "@keystonehq/bc-ur-registry": "npm:^0.4.4" - ethereumjs-util: "npm:^7.0.8" - hdkey: "npm:^2.0.1" - uuid: "npm:^8.3.2" - checksum: 10/d705d7d0d11a82db1417df9666f8a6bc5996eb46b543dc5f979d39d7694910d09c178a1acb6859caf2f93c902f3cc4614eb856936db84ea0b310ac18e5ab139d - languageName: node - linkType: hard - -"@keystonehq/bc-ur-registry@npm:^0.4.4": - version: 0.4.4 - resolution: "@keystonehq/bc-ur-registry@npm:0.4.4" - dependencies: - "@ngraveio/bc-ur": "npm:^1.1.5" - base58check: "npm:^2.0.0" - tslib: "npm:^2.3.0" - checksum: 10/640c5ef55e34713d338ecc29fef779203f2488041d54dd2f036ccd56f01dda1859ff20853b4eda53933a1f08f01d3f3e4c2fdc7b2e63886c88a40755da3a562e - languageName: node - linkType: hard - "@keystonehq/bc-ur-registry@npm:^0.6.0": version: 0.6.4 resolution: "@keystonehq/bc-ur-registry@npm:0.6.4" @@ -1329,16 +1306,6 @@ __metadata: languageName: node linkType: hard -"@keystonehq/ur-decoder@npm:^0.12.2": - version: 0.12.2 - resolution: "@keystonehq/ur-decoder@npm:0.12.2" - dependencies: - "@keystonehq/bc-ur-registry-eth": "npm:^0.6.12" - "@ngraveio/bc-ur": "npm:^1.1.6" - checksum: 10/0904492775842dc01035bcf5fc1355918548c3e1ab9f0be677a55301d72bb87fea0377ee866bfa401568b99572114ac10b72d4b2d6cbc22a87a9d3eea2df9c37 - languageName: node - linkType: hard - "@lavamoat/aa@npm:^4.3.0": version: 4.3.0 resolution: "@lavamoat/aa@npm:4.3.0" @@ -1817,12 +1784,10 @@ __metadata: "@keystonehq/base-eth-keyring": "npm:^0.15.1" "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" "@keystonehq/metamask-airgapped-keyring": "npm:^0.15.2" - "@keystonehq/ur-decoder": "npm:^0.12.2" "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/keyring-utils": "workspace:^" - "@metamask/utils": "npm:^11.3.0" - "@ngraveio/bc-ur": "npm:^1.1.13" + "@metamask/utils": "npm:^11.1.0" "@types/hdkey": "npm:^2.0.1" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.12.12" @@ -1833,7 +1798,7 @@ __metadata: ts-jest: "npm:^29.0.5" ts-node: "npm:^10.9.2" typedoc: "npm:^0.25.13" - typescript: "npm:~4.8.4" + typescript: "npm:~5.6.3" languageName: unknown linkType: soft @@ -2479,7 +2444,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.3.0, @metamask/utils@npm:^11.4.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.4.0": version: 11.4.0 resolution: "@metamask/utils@npm:11.4.0" dependencies: @@ -2520,7 +2485,7 @@ __metadata: languageName: node linkType: hard -"@ngraveio/bc-ur@npm:^1.1.13, @ngraveio/bc-ur@npm:^1.1.5, @ngraveio/bc-ur@npm:^1.1.6": +"@ngraveio/bc-ur@npm:^1.1.5": version: 1.1.13 resolution: "@ngraveio/bc-ur@npm:1.1.13" dependencies: @@ -4845,13 +4810,6 @@ __metadata: languageName: node linkType: hard -"base-x@npm:^1.1.0": - version: 1.1.0 - resolution: "base-x@npm:1.1.0" - checksum: 10/ca5f485f3868579e908187ccd50f4295a3ed3bd56a5d5dd266ba59a70c391155eabe50cd7976e6b696b5f7eca512019a886fcc6f27b4a738da1611249b3446f7 - languageName: node - linkType: hard - "base-x@npm:^3.0.2, base-x@npm:^3.0.9": version: 3.0.10 resolution: "base-x@npm:3.0.10" @@ -4875,15 +4833,6 @@ __metadata: languageName: node linkType: hard -"base58check@npm:^2.0.0": - version: 2.0.0 - resolution: "base58check@npm:2.0.0" - dependencies: - bs58: "npm:^3.0.0" - checksum: 10/19f77522a38d66d5c9cc16411880e258899a6807b31477e3ac33ebaa64555126e9b29e7d5d2be88748aae8b1d05f91e5eb3aef4847a033af25b8a0c0f995b037 - languageName: node - linkType: hard - "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -5091,15 +5040,6 @@ __metadata: languageName: node linkType: hard -"bs58@npm:^3.0.0": - version: 3.1.0 - resolution: "bs58@npm:3.1.0" - dependencies: - base-x: "npm:^1.1.0" - checksum: 10/6d757a49958c43bc630f3b327511ce3ee065307c24240aa86189f72df0a4a8245c7ce7e7abad209b637f155006952c7b8f04bb4aa6ee4212a891882424bca82e - languageName: node - linkType: hard - "bs58@npm:^4.0.0": version: 4.0.1 resolution: "bs58@npm:4.0.1" @@ -6618,7 +6558,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.0.8, ethereumjs-util@npm:^7.0.9, ethereumjs-util@npm:^7.1.2": +"ethereumjs-util@npm:^7.0.9, ethereumjs-util@npm:^7.1.2": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: @@ -11517,16 +11457,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:~4.8.4": - version: 4.8.4 - resolution: "typescript@npm:4.8.4" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/f985d8dd6ae815753d61cb81e434f3a4a5796ac52e423370fca6ad11bcd188df4013d82e3ba3b88c9746745b9341390ba68f862dc9d30bac6465e0699f2a795b - languageName: node - linkType: hard - "typescript@npm:~5.6.3": version: 5.6.3 resolution: "typescript@npm:5.6.3" @@ -11537,16 +11467,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A~4.8.4#optional!builtin": - version: 4.8.4 - resolution: "typescript@patch:typescript@npm%3A4.8.4#optional!builtin::version=4.8.4&hash=1a91c8" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/5d81fd8cf5152091a0c0b84ebc868de8433583072a340c4899e0fc7ad6a80314b880a1466868c9a6a1f640c3d1f2fe7f41f8c541b99d78c8b414263dfa27eba3 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A~5.6.3#optional!builtin": version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" From bb9b6baf4393c447ef3ccdfc289c92df4d65dffe Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 01:49:00 +0200 Subject: [PATCH 17/46] fix unit tests --- packages/keyring-eth-qr/jest.config.js | 8 +- .../keyring-eth-qr/src/qr-keyring.test.ts | 102 ++++++++++++++---- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 8d5e46b3a..58e027140 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 57.14, - functions: 83.33, - lines: 72.58, - statements: 73.22, + branches: 55.1, + functions: 61.76, + lines: 59.61, + statements: 60, }, }, }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 1a97b6c83..ee003d3dc 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -3,7 +3,7 @@ import { CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; import type { Hex } from '@metamask/utils'; -import type { SerializedQrKeyringState } from '.'; +import type { QrKeyringBridge, SerializedQrKeyringState } from '.'; import { QrKeyring } from '.'; import { KeyringMode } from './airgapped-signer'; @@ -91,15 +91,29 @@ async function getLegacyKeystoneKeyring(): Promise { return keystoneKeyring; } +/** + * Get a mock bridge for the QrKeyring. + * + * @returns A mock bridge with a requestScan method. + */ +function getMockBridge(): QrKeyringBridge { + return { + requestScan: jest.fn(), + }; +} + describe('QrKeyring', () => { describe('constructor', () => { - it('can be constructed with no arguments', () => { - const keyring = new QrKeyring({}); + it('can be constructed with the bridge only', () => { + const keyring = new QrKeyring({ bridge: getMockBridge() }); expect(keyring).toBeInstanceOf(QrKeyring); }); - it('can be constructed with a UR', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + it('can be constructed with a bridge and a UR string', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); expect(await keyring.serialize()).toStrictEqual({ ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, @@ -111,7 +125,10 @@ describe('QrKeyring', () => { describe('serialize', () => { describe('when the QrKeyring has no accounts', () => { it('returns the serialized state', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); const serialized = await keyring.serialize(); @@ -125,7 +142,10 @@ describe('QrKeyring', () => { describe('when the QrKeyring has accounts', () => { it('returns the serialized state including added accounts', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); await keyring.addAccounts(2); const serialized = await keyring.serialize(); @@ -147,7 +167,9 @@ describe('QrKeyring', () => { describe('deserialize', () => { describe('when deserializing an empty state', () => { it('deserializes the state with no accounts', async () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); await keyring.deserialize({}); @@ -158,7 +180,9 @@ describe('QrKeyring', () => { describe('when deserializing a state with accounts', () => { it('deserializes the state with accounts', async () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); await keyring.deserialize(SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS); @@ -172,7 +196,9 @@ describe('QrKeyring', () => { describe('when using a serialized state coming from `@keystonehq/metamask-airgapped-keyring`', () => { it('deserializes the state with accounts', async () => { const keystoneKeyring = await getLegacyKeystoneKeyring(); - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); // @ts-expect-error QrKeyring types are stricter than Keystone ones await keyring.deserialize(await keystoneKeyring.serialize()); @@ -190,7 +216,10 @@ describe('QrKeyring', () => { describe('addAccounts', () => { describe('when the keyring is initialized with a UR', () => { it('returns new accounts added', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); const accounts = await keyring.addAccounts(1); @@ -199,7 +228,10 @@ describe('QrKeyring', () => { }); it('adds multiple accounts', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); const accounts = await keyring.addAccounts(3); @@ -208,7 +240,9 @@ describe('QrKeyring', () => { }); it('recovers indexes if they are not present in the serialized state', async () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); await keyring.deserialize({ ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, accounts: EXPECTED_ACCOUNTS.slice(0, 3), @@ -232,7 +266,10 @@ describe('QrKeyring', () => { }); it('does not add accounts that already exist', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); const firstAddition = await keyring.addAccounts(1); keyring.setAccountToUnlock(0); @@ -248,7 +285,9 @@ describe('QrKeyring', () => { describe('when the keyring is not initialized with a UR', () => { it('throws an error', async () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); await expect(keyring.addAccounts(1)).rejects.toThrow( 'UR not initialized', @@ -260,14 +299,20 @@ describe('QrKeyring', () => { describe('getAccounts', () => { describe('when no accounts have been added', () => { it('returns an empty array', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); expect(await keyring.getAccounts()).toStrictEqual([]); }); }); describe('when accounts have been added', () => { it('returns all the accounts added', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); const accounts = await keyring.addAccounts(3); @@ -279,7 +324,10 @@ describe('QrKeyring', () => { describe('removeAccount', () => { it('removes an account from the keyring', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); await keyring.addAccounts(3); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -292,7 +340,10 @@ describe('QrKeyring', () => { }); it('does not throw if the account does not exist', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); await keyring.addAccounts(1); const initialAccounts = await keyring.getAccounts(); @@ -305,7 +356,10 @@ describe('QrKeyring', () => { describe('setAccountToUnlock', () => { it('sets an arbitrary account index to unlock', async () => { - const keyring = new QrKeyring({ ur: KNOWN_HDKEY_UR }); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); keyring.setAccountToUnlock(2); @@ -317,7 +371,9 @@ describe('QrKeyring', () => { describe('submitUR', () => { it('initializes the QrKeyring with a UR', async () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); keyring.submitUR(KNOWN_HDKEY_UR); @@ -327,7 +383,9 @@ describe('QrKeyring', () => { }); it('throws an error if the UR is invalid', () => { - const keyring = new QrKeyring(); + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme'); }); }); From ae6ca94f8566a85143d2b7e3916fa5c9f4db4947 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 11:48:32 +0200 Subject: [PATCH 18/46] add scan request type argument --- .../keyring-eth-qr/src/qr-keyring-bridge.ts | 15 ++++++++++----- packages/keyring-eth-qr/src/qr-keyring.ts | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts index 7c47d9676..87fb4d2e9 100644 --- a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts +++ b/packages/keyring-eth-qr/src/qr-keyring-bridge.ts @@ -1,4 +1,8 @@ -import type { QrKeyringBridge, QrScanResponse } from './qr-keyring'; +import type { + QrKeyringBridge, + QrScanRequestType, + QrScanResponse, +} from './qr-keyring'; /** * Options for the QrKeyringScannerBridge. @@ -7,7 +11,7 @@ export type QrKeyringScannerBridgeOptions = { /** * An injected function that the bridge uses to request a QR code scan. */ - requestScan: () => Promise; + requestScan: (type: QrScanRequestType) => Promise; }; /** @@ -15,7 +19,7 @@ export type QrKeyringScannerBridgeOptions = { * while keeping the implementation defined by the QrKeyring consumer. */ export class QrKeyringScannerBridge implements QrKeyringBridge { - readonly #requestScan: () => Promise; + readonly #requestScan: (type: QrScanRequestType) => Promise; constructor({ requestScan }: QrKeyringScannerBridgeOptions) { this.#requestScan = requestScan; @@ -24,9 +28,10 @@ export class QrKeyringScannerBridge implements QrKeyringBridge { /** * Requests a QR code scan and returns the scanned data. * + * @param type - The type of QR scan request. * @returns The scanned data as a string. */ - async requestScan(): Promise { - return this.#requestScan(); + async requestScan(type: QrScanRequestType): Promise { + return this.#requestScan(type); } } diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 790b8ac86..87ee79f84 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -11,13 +11,26 @@ import { export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; +export enum QrScanRequestType { + /** + * Request a scan for a QR code containing a UR + * with information related to a hardware wallet. + */ + PAIR = 'pair', + /** + * Request a scan for a QR code containing a + * UR-encoded transaction signature. + */ + SIGN = 'sign', +} + export type QrScanResponse = { type: string; cbor: Hex; }; export type QrKeyringBridge = { - requestScan: () => Promise; + requestScan: (type: QrScanRequestType) => Promise; }; export type QrKeyringOptions = { @@ -294,6 +307,6 @@ export class QrKeyring implements Keyring { * scanned UR. */ async #scanAndInitialize(): Promise { - this.submitUR(await this.bridge.requestScan()); + this.submitUR(await this.bridge.requestScan(QrScanRequestType.PAIR)); } } From 33294fd9ce7de78f5a8b08a61481ac33470d4191 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 15:08:42 +0200 Subject: [PATCH 19/46] implement `signTransaction` and `signTypedData` --- packages/keyring-eth-qr/package.json | 6 +- .../keyring-eth-qr/src/airgapped-signer.ts | 35 +++- packages/keyring-eth-qr/src/index.ts | 2 +- ...bridge.ts => qr-keyring-scanner-bridge.ts} | 18 +- packages/keyring-eth-qr/src/qr-keyring.ts | 198 +++++++++++++++++- yarn.lock | 6 +- 6 files changed, 242 insertions(+), 23 deletions(-) rename packages/keyring-eth-qr/src/{qr-keyring-bridge.ts => qr-keyring-scanner-bridge.ts} (56%) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 83753d2d7..efe83788e 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -48,11 +48,15 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/rlp": "^5.0.2", + "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^9.1.0", "@keystonehq/bc-ur-registry-eth": "^0.19.1", + "@metamask/eth-sig-util": "^8.2.0", "@metamask/keyring-utils": "workspace:^", "@metamask/utils": "^11.1.0", - "hdkey": "^2.1.0" + "hdkey": "^2.1.0", + "uuid": "^9.0.0" }, "devDependencies": { "@keystonehq/base-eth-keyring": "^0.15.1", diff --git a/packages/keyring-eth-qr/src/airgapped-signer.ts b/packages/keyring-eth-qr/src/airgapped-signer.ts index 7d6f7a8f5..5008eba42 100644 --- a/packages/keyring-eth-qr/src/airgapped-signer.ts +++ b/packages/keyring-eth-qr/src/airgapped-signer.ts @@ -8,7 +8,7 @@ import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; // eslint-disable-next-line @typescript-eslint/naming-convention import HdKey from 'hdkey'; -import type { QrScanResponse } from './qr-keyring'; +import type { SerializedUR } from './qr-keyring'; export const SUPPORTED_UR_TYPE = { CRYPTO_HDKEY: 'crypto-hdkey', @@ -176,7 +176,7 @@ export class AirgappedSigner { * * @param source - The signer source, in the form of details object, or a UR string */ - init(source: AirgappedSignerDetails | string | QrScanResponse): void { + init(source: AirgappedSignerDetails | string | SerializedUR): void { if (typeof source === 'string' || 'cbor' in source) { this.#initFromUR(source); } else { @@ -231,6 +231,33 @@ export class AirgappedSigner { return normalizedAddress; } + /** + * Retrieve the path of an address derived from the source. + * + * @param address - The address to retrieve the path for + * @returns The path of the address + */ + pathFromAddress(address: Hex): string { + if (!this.#source) { + throw new Error('UR not initialized'); + } + + const normalizedAddress = getChecksumAddress(add0x(address)); + + if (this.#source.keyringMode === KeyringMode.ACCOUNT) { + const path = this.#source.paths[normalizedAddress]; + if (path === undefined) { + throw new Error(`Unknown address ${normalizedAddress}`); + } + return path; + } + + const index = this.indexFromAddress(normalizedAddress); + return `${this.#source.hdPath}/${this.#source.childrenPath + .replace('*', index.toString()) + .replace(/\*/gu, '0')}`; + } + /** * Retrieve the index of an address from the source * @@ -313,7 +340,7 @@ export class AirgappedSigner { * * @param ur - The UR string to set the root account from */ - #initFromUR(ur: string | QrScanResponse): void { + #initFromUR(ur: string | SerializedUR): void { const source = this.#decodeUR(ur); const fingerprint = getFingerprintFromSource(source); @@ -350,7 +377,7 @@ export class AirgappedSigner { * @returns The decoded CryptoAccount or CryptoHDKey * @throws Will throw an error if the UR type is not supported */ - #decodeUR(ur: string | QrScanResponse): CryptoAccount | CryptoHDKey { + #decodeUR(ur: string | SerializedUR): CryptoAccount | CryptoHDKey { const decodedUR = typeof ur === 'string' ? URRegistryDecoder.decode(ur) diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts index c79e5c071..0acd0dc8d 100644 --- a/packages/keyring-eth-qr/src/index.ts +++ b/packages/keyring-eth-qr/src/index.ts @@ -1,2 +1,2 @@ -export * from './qr-keyring-bridge'; +export * from './qr-keyring-scanner-bridge'; export * from './qr-keyring'; diff --git a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts similarity index 56% rename from packages/keyring-eth-qr/src/qr-keyring-bridge.ts rename to packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts index 87fb4d2e9..a844deebc 100644 --- a/packages/keyring-eth-qr/src/qr-keyring-bridge.ts +++ b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts @@ -1,7 +1,7 @@ import type { QrKeyringBridge, - QrScanRequestType, - QrScanResponse, + QrScanRequest, + SerializedUR, } from './qr-keyring'; /** @@ -11,7 +11,7 @@ export type QrKeyringScannerBridgeOptions = { /** * An injected function that the bridge uses to request a QR code scan. */ - requestScan: (type: QrScanRequestType) => Promise; + requestScan: (request: QrScanRequest) => Promise; }; /** @@ -19,19 +19,19 @@ export type QrKeyringScannerBridgeOptions = { * while keeping the implementation defined by the QrKeyring consumer. */ export class QrKeyringScannerBridge implements QrKeyringBridge { - readonly #requestScan: (type: QrScanRequestType) => Promise; + readonly #requestScan: QrKeyringScannerBridgeOptions['requestScan']; constructor({ requestScan }: QrKeyringScannerBridgeOptions) { this.#requestScan = requestScan; } /** - * Requests a QR code scan and returns the scanned data. + * Request a QR code scan, obtaining a CBOR and a type as response. * - * @param type - The type of QR scan request. - * @returns The scanned data as a string. + * @param request - The type of QR scan request. + * @returns The scanned data as a serialized UR. */ - async requestScan(type: QrScanRequestType): Promise { - return this.#requestScan(type); + async requestScan(request: QrScanRequest): Promise { + return this.#requestScan(request); } } diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 87ee79f84..27c7bd627 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,6 +1,20 @@ +import { RLP } from '@ethereumjs/rlp'; +import { + type FeeMarketEIP1559Transaction, + TransactionFactory, + TransactionType, + type TypedTransaction, + type TypedTxData, +} from '@ethereumjs/tx'; +import { + DataType, + ETHSignature, + EthSignRequest, +} from '@keystonehq/bc-ur-registry-eth'; +import type { TypedMessage, MessageTypes } from '@metamask/eth-sig-util'; import type { Keyring } from '@metamask/keyring-utils'; -import type { Hex } from '@metamask/utils'; -import { add0x, getChecksumAddress } from '@metamask/utils'; +import { type Hex, add0x, getChecksumAddress } from '@metamask/utils'; +import { stringify, v4 as uuidv4 } from 'uuid'; import { type AirgappedSignerDetails, @@ -11,6 +25,11 @@ import { export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; +const DEFAULT_SCAN_REQUEST_TITLE = 'Scan with your hardware wallet'; + +const DEFAULT_SCAN_REQUEST_DESCRIPTION = + 'After your device has scanned this QR code, click on "Scan" to receive the information.'; + export enum QrScanRequestType { /** * Request a scan for a QR code containing a UR @@ -24,13 +43,25 @@ export enum QrScanRequestType { SIGN = 'sign', } -export type QrScanResponse = { +export type QrSignatureRequest = { + requestId: string; + payload: SerializedUR; + requestTitle?: string; + requestDescription?: string; +}; + +export type QrScanRequest = { + type: QrScanRequestType; + request?: QrSignatureRequest; +}; + +export type SerializedUR = { type: string; - cbor: Hex; + cbor: string; }; export type QrKeyringBridge = { - requestScan: (type: QrScanRequestType) => Promise; + requestScan: (request: QrScanRequest) => Promise; }; export type QrKeyringOptions = { @@ -217,7 +248,7 @@ export class QrKeyring implements Keyring { * * @param ur - The CBOR encoded UR */ - submitUR(ur: string | QrScanResponse): void { + submitUR(ur: string | SerializedUR): void { this.#signer.init(ur); } @@ -302,11 +333,164 @@ export class QrKeyring implements Keyring { this.#currentPage = 0; } + /** + * Sign a transaction. This is equivalent to the `eth_signTransaction` + * Ethereum JSON-RPC method. See the Ethereum JSON-RPC API documentation for + * more details. + * + * @param address - The address of the account to use for signing. + * @param transaction - The transaction to sign. + * @returns The signed transaction. + */ + async signTransaction( + address: Hex, + transaction: TypedTransaction, + ): Promise { + const signer = this.#signer.getSourceDetails(); + if (!signer?.xfp) { + throw new Error('Keyring is not initialized. Please scan a QR code.'); + } + const dataType = + transaction.type === TransactionType.Legacy + ? DataType.transaction + : DataType.typedTransaction; + let messageToSign: Buffer; + if (transaction.type === TransactionType.Legacy) { + messageToSign = Buffer.from(RLP.encode(transaction.getMessageToSign())); + } else { + messageToSign = Buffer.from( + (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), + ); + } + + const hdPath = this.#signer.pathFromAddress(address); + const chainId = Number(transaction.common.chainId()); + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + messageToSign, + dataType, + hdPath, + signer.xfp, + requestId, + chainId, + ).toUR(); + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + requestTitle: 'Scan with your hardware wallet', + requestDescription: + 'After your device has signed this message, click on "Scan" to receive the signature', + }); + + return TransactionFactory.fromTxData( + { + ...transaction, + r, + s, + v, + }, + { + common: transaction.common, + }, + ); + } + + /** + * Sign a message. This is equivalent to the `eth_signTypedData` v4 Ethereum + * JSON-RPC method. + * + * @param address - The address of the account to use for signing. + * @param data - The data to sign. + * @returns The signed message. + */ + async signTypedData( + address: Hex, + data: TypedMessage, + ): Promise { + const signer = this.#signer.getSourceDetails(); + if (!signer?.xfp) { + throw new Error('Keyring is not initialized. Please scan a QR code.'); + } + + const hdPath = this.#signer.pathFromAddress(address); + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + Buffer.from(JSON.stringify(data), 'utf8'), + DataType.typedData, + hdPath, + signer.xfp, + requestId, + undefined, + address, + ).toUR(); + + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + requestTitle: DEFAULT_SCAN_REQUEST_TITLE, + requestDescription: DEFAULT_SCAN_REQUEST_DESCRIPTION, + }); + + return add0x( + Buffer.concat([ + Uint8Array.from(r), + Uint8Array.from(s), + Uint8Array.from(v), + ]).toString('hex'), + ); + } + /** * Scan for a QR code and initialize the keyring with the * scanned UR. */ async #scanAndInitialize(): Promise { - this.submitUR(await this.bridge.requestScan(QrScanRequestType.PAIR)); + this.submitUR( + await this.bridge.requestScan({ type: QrScanRequestType.PAIR }), + ); + } + + /** + * Request a signature for a transaction or message. + * + * @param request - The signature request containing the data to sign. + * @returns The signature as an object containing r, s, and v values. + */ + async #requestSignature( + request: QrSignatureRequest, + ): Promise<{ r: Buffer; s: Buffer; v: Buffer }> { + const response = await this.bridge.requestScan({ + type: QrScanRequestType.SIGN, + request, + }); + const signatureEnvelope = ETHSignature.fromCBOR( + Buffer.from(response.cbor, 'hex'), + ); + const signature = signatureEnvelope.getSignature(); + const requestId = signatureEnvelope.getRequestId(); + + if (!requestId) { + throw new Error('Signature request ID is missing.'); + } + + if (request.requestId !== stringify(requestId)) { + throw new Error( + `Signature request ID mismatch. Expected: ${ + request.requestId + }, received: ${requestId.toString('hex')}`, + ); + } + + return { + r: signature.subarray(0, 32), + s: signature.subarray(32, 64), + v: signature.subarray(64), + }; } } diff --git a/yarn.lock b/yarn.lock index 15a4e8b08..2405af1f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1780,12 +1780,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" dependencies: + "@ethereumjs/rlp": "npm:^5.0.2" + "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^9.1.0" "@keystonehq/base-eth-keyring": "npm:^0.15.1" "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" "@keystonehq/metamask-airgapped-keyring": "npm:^0.15.2" "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/keyring-utils": "workspace:^" "@metamask/utils": "npm:^11.1.0" "@types/hdkey": "npm:^2.0.1" @@ -1799,6 +1802,7 @@ __metadata: ts-node: "npm:^10.9.2" typedoc: "npm:^0.25.13" typescript: "npm:~5.6.3" + uuid: "npm:^9.0.0" languageName: unknown linkType: soft @@ -11643,7 +11647,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^9.0.1": +"uuid@npm:^9.0.0, uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" bin: From 4de83c0a945b5f689a07bb0ccf36990095f151dd Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 15:19:23 +0200 Subject: [PATCH 20/46] fix ci --- packages/keyring-eth-qr/CHANGELOG.md | 9 +++++++++ packages/keyring-eth-qr/jest.config.js | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/keyring-eth-qr/CHANGELOG.md b/packages/keyring-eth-qr/CHANGELOG.md index 825c32f0d..f4eb4815c 100644 --- a/packages/keyring-eth-qr/CHANGELOG.md +++ b/packages/keyring-eth-qr/CHANGELOG.md @@ -1 +1,10 @@ # Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 58e027140..50a7df0ec 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 55.1, - functions: 61.76, - lines: 59.61, - statements: 60, + branches: 46.77, + functions: 56.41, + lines: 49.75, + statements: 50.23, }, }, }); From e4f6caa017710f23b3b818bfb29d828b70854a64 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 15:19:57 +0200 Subject: [PATCH 21/46] fix lint --- packages/keyring-eth-qr/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index efe83788e..27f013e6e 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -56,7 +56,7 @@ "@metamask/keyring-utils": "workspace:^", "@metamask/utils": "^11.1.0", "hdkey": "^2.1.0", - "uuid": "^9.0.0" + "uuid": "^9.0.1" }, "devDependencies": { "@keystonehq/base-eth-keyring": "^0.15.1", diff --git a/yarn.lock b/yarn.lock index 2405af1f5..21b9a5718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1802,7 +1802,7 @@ __metadata: ts-node: "npm:^10.9.2" typedoc: "npm:^0.25.13" typescript: "npm:~5.6.3" - uuid: "npm:^9.0.0" + uuid: "npm:^9.0.1" languageName: unknown linkType: soft @@ -11647,7 +11647,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^9.0.0, uuid@npm:^9.0.1": +"uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" bin: From a63b6036304509c3618e93d204dca3d3294ad884 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 26 Jun 2025 16:48:28 +0200 Subject: [PATCH 22/46] add `signPersonalMessage` --- .../src/qr-keyring-scanner-bridge.ts | 6 +- packages/keyring-eth-qr/src/qr-keyring.ts | 85 ++++++++++++++----- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts index a844deebc..6029ff398 100644 --- a/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts +++ b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts @@ -9,14 +9,14 @@ import type { */ export type QrKeyringScannerBridgeOptions = { /** - * An injected function that the bridge uses to request a QR code scan. + * The function that the bridge will use to initiate a QR scan request. */ requestScan: (request: QrScanRequest) => Promise; }; /** - * A bridge that allows the QrKeyring to request a QR code scan - * while keeping the implementation defined by the QrKeyring consumer. + * A generic transport bridge that allows the consumer to inject a scan + * request hook at construction time. */ export class QrKeyringScannerBridge implements QrKeyringBridge { readonly #requestScan: QrKeyringScannerBridgeOptions['requestScan']; diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 27c7bd627..9b30d0a7b 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -13,7 +13,7 @@ import { } from '@keystonehq/bc-ur-registry-eth'; import type { TypedMessage, MessageTypes } from '@metamask/eth-sig-util'; import type { Keyring } from '@metamask/keyring-utils'; -import { type Hex, add0x, getChecksumAddress } from '@metamask/utils'; +import { type Hex, add0x, getChecksumAddress, remove0x } from '@metamask/utils'; import { stringify, v4 as uuidv4 } from 'uuid'; import { @@ -346,34 +346,32 @@ export class QrKeyring implements Keyring { address: Hex, transaction: TypedTransaction, ): Promise { - const signer = this.#signer.getSourceDetails(); - if (!signer?.xfp) { + const signerSource = this.#signer.getSourceDetails(); + if (!signerSource?.xfp) { throw new Error('Keyring is not initialized. Please scan a QR code.'); } + const dataType = transaction.type === TransactionType.Legacy ? DataType.transaction : DataType.typedTransaction; - let messageToSign: Buffer; - if (transaction.type === TransactionType.Legacy) { - messageToSign = Buffer.from(RLP.encode(transaction.getMessageToSign())); - } else { - messageToSign = Buffer.from( - (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), - ); - } - const hdPath = this.#signer.pathFromAddress(address); - const chainId = Number(transaction.common.chainId()); + const messageToSign = Buffer.from( + transaction.type === TransactionType.Legacy + ? RLP.encode(transaction.getMessageToSign()) + : (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), + ); + const requestId = uuidv4(); const ethSignRequestUR = EthSignRequest.constructETHRequest( messageToSign, dataType, - hdPath, - signer.xfp, + this.#signer.pathFromAddress(address), + signerSource.xfp, requestId, - chainId, + Number(transaction.common.chainId()), ).toUR(); + const { r, s, v } = await this.#requestSignature({ requestId, payload: { @@ -410,18 +408,17 @@ export class QrKeyring implements Keyring { address: Hex, data: TypedMessage, ): Promise { - const signer = this.#signer.getSourceDetails(); - if (!signer?.xfp) { + const signerSource = this.#signer.getSourceDetails(); + if (!signerSource?.xfp) { throw new Error('Keyring is not initialized. Please scan a QR code.'); } - const hdPath = this.#signer.pathFromAddress(address); const requestId = uuidv4(); const ethSignRequestUR = EthSignRequest.constructETHRequest( Buffer.from(JSON.stringify(data), 'utf8'), DataType.typedData, - hdPath, - signer.xfp, + this.#signer.pathFromAddress(address), + signerSource.xfp, requestId, undefined, address, @@ -446,6 +443,52 @@ export class QrKeyring implements Keyring { ); } + /** + * Sign a message. This is equivalent to the `eth_sign` Ethereum JSON-RPC + * method, which is exposed by MetaMask as the method `personal_sign`. See + * the Ethereum JSON-RPC API documentation for more details. + * + * For more information about this method and why we call it `personal_sign`, + * see the {@link https://docs.metamask.io/guide/signing-data.html|MetaMask Docs}. + * + * @param address - The address of the account to use for signing. + * @param message - The message to sign. + * @returns The signed message. + */ + async signPersonalMessage(address: Hex, message: Hex): Promise { + const signerSource = this.#signer.getSourceDetails(); + if (!signerSource?.xfp) { + throw new Error('Keyring is not initialized. Please scan a QR code.'); + } + + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + Buffer.from(remove0x(message), 'hex'), + DataType.personalMessage, + this.#signer.pathFromAddress(address), + signerSource.xfp, + requestId, + undefined, + address, + ).toUR(); + + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + }); + + return add0x( + Buffer.concat([ + Uint8Array.from(r), + Uint8Array.from(s), + Uint8Array.from(v), + ]).toString('hex'), + ); + } + /** * Scan for a QR code and initialize the keyring with the * scanned UR. From 56b5f54ecfd5c839026e534a3582a113be2d5861 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 27 Jun 2025 12:53:20 +0200 Subject: [PATCH 23/46] fix `sendTransaction` --- packages/keyring-eth-qr/src/qr-keyring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 9b30d0a7b..eb721e1ce 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -385,7 +385,7 @@ export class QrKeyring implements Keyring { return TransactionFactory.fromTxData( { - ...transaction, + ...transaction.toJSON(), r, s, v, From b36b2f606912a3e3cd35766f59103759815436c6 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 27 Jun 2025 13:28:11 +0200 Subject: [PATCH 24/46] move signing logic to `device.ts` --- packages/keyring-eth-qr/jest.config.js | 8 +- .../keyring-eth-qr/src/airgapped-signer.ts | 400 ------------ packages/keyring-eth-qr/src/device.ts | 613 ++++++++++++++++++ .../keyring-eth-qr/src/qr-keyring.test.ts | 8 +- packages/keyring-eth-qr/src/qr-keyring.ts | 245 ++----- yarn.lock | 4 +- 6 files changed, 664 insertions(+), 614 deletions(-) delete mode 100644 packages/keyring-eth-qr/src/airgapped-signer.ts create mode 100644 packages/keyring-eth-qr/src/device.ts diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 50a7df0ec..d9d85df73 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 46.77, - functions: 56.41, - lines: 49.75, - statements: 50.23, + branches: 46.03, + functions: 51.16, + lines: 49.51, + statements: 50, }, }, }); diff --git a/packages/keyring-eth-qr/src/airgapped-signer.ts b/packages/keyring-eth-qr/src/airgapped-signer.ts deleted file mode 100644 index 5008eba42..000000000 --- a/packages/keyring-eth-qr/src/airgapped-signer.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { publicToAddress } from '@ethereumjs/util'; -import { - CryptoAccount, - CryptoHDKey, - URRegistryDecoder, -} from '@keystonehq/bc-ur-registry-eth'; -import { add0x, getChecksumAddress, type Hex } from '@metamask/utils'; -// eslint-disable-next-line @typescript-eslint/naming-convention -import HdKey from 'hdkey'; - -import type { SerializedUR } from './qr-keyring'; - -export const SUPPORTED_UR_TYPE = { - CRYPTO_HDKEY: 'crypto-hdkey', - CRYPTO_ACCOUNT: 'crypto-account', - ETH_SIGNATURE: 'eth-signature', -}; - -export enum KeyringMode { - HD = 'hd', - ACCOUNT = 'account', -} - -const DEFAULT_CHILDREN_PATH = '0/*'; - -const MAX_INDEX = 1_000; - -/** - * Common details for the signer source, which can be either a CryptoAccount or CryptoHDKey. - */ -export type CommonSignerDetails = { - /** - * Value take out from the device note field, if available. - */ - keyringAccount: string; - /** - * The name of the device - */ - name: string; - /** - * The device fingerprint, hex-encoded - */ - xfp: string; - /** - * Indexes of the accounts derived from the device - * in the form of a map from address to index - */ - indexes: Record; -}; - -/** - * Details for the HD mode of the AirgappedSigner. This mode derives - * accounts from a root public key (xpub) and a derivation path. - */ -export type HDModeSignerDetails = { - /** - * The keyring mode is HD, indicating that it derives accounts from a - * root public key (xpub) and a derivation path. - */ - keyringMode: KeyringMode.HD; - /** - * The xpub of the HD key - */ - xpub: string; - /** - * The derivation path of the HD key - */ - hdPath: string; - /** - * The path used to derive child accounts - */ - childrenPath: string; -}; - -/** - * Details for the Account mode of the AirgappedSigner. This mode derives - * accounts from a set of addresses and their corresponding paths. - */ -export type AccountModeSignerDetails = { - /** - * The keyring mode is ACCOUNT, indicating that it derives accounts from - * a set of addresses and their corresponding paths. - */ - keyringMode: KeyringMode.ACCOUNT; - /** - * The derivation paths for each hex-encoded address in the device - */ - paths: Record; -}; - -/** - * An address with its corresponding index. - */ -export type IndexedAddress = { - address: Hex; - index: number; -}; - -/** - * The details of the source CryptoAccount or CryptoHDKey - * that the AirgappedSigner uses to derive accounts. - */ -export type AirgappedSignerDetails = CommonSignerDetails & - (HDModeSignerDetails | AccountModeSignerDetails); - -/** - * Get the fingerprint of the source CryptoAccount or CryptoHDKey - * - * @param source - The source CryptoAccount or CryptoHDKey - * @returns The fingerprint of the source - */ -function getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): string { - return source instanceof CryptoAccount - ? source.getMasterFingerprint()?.toString('hex') - : source.getParentFingerprint()?.toString('hex'); -} - -/** - * Get fingerprint, account paths and names from the a CryptoAccount - * - * Note: This function emulates the behavior of the `@keystonehq/base-eth-keyring` - * library when dealing with CryptoAccount objects (for backwards compatibility - * reasons). Though, the way it retrieves `name` and `keyringAccount` is questionable, - * as `name` and `keyringAccount` are updated after each descriptor discovery, effectively - * returning the last descriptor's `name` and `keyringAccount`. - * - * @param source - The source CryptoAccount - * @returns The paths - */ -function readCryptoAccountOutputDescriptors(source: CryptoAccount): { - xfp: string; - paths: Record; - name: string; - keyringAccount: string; -} { - const descriptors = source.getOutputDescriptors(); - - if (!descriptors || descriptors.length === 0) { - throw new Error('No output descriptors found in CryptoAccount'); - } - - let name = ''; - let keyringAccount = ''; - const paths = descriptors.reduce( - (descriptorsPaths: Record, current) => { - const hdKey = current.getHDKey(); - if (hdKey) { - const path = `M/${hdKey.getOrigin().getPath()}`; - const address = getChecksumAddress( - add0x( - Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), - ), - ); - descriptorsPaths[address] = path; - name = hdKey.getName(); - keyringAccount = hdKey.getNote(); - } - return descriptorsPaths; - }, - {}, - ); - - return { - paths, - name, - keyringAccount, - xfp: getFingerprintFromSource(source), - }; -} - -export class AirgappedSigner { - #source?: AirgappedSignerDetails | undefined; - - /** - * Initialize the AirgappedSigner with a source. - * - * @param source - The signer source, in the form of details object, or a UR string - */ - init(source: AirgappedSignerDetails | string | SerializedUR): void { - if (typeof source === 'string' || 'cbor' in source) { - this.#initFromUR(source); - } else { - this.#source = source; - } - } - - /** - * Check if the AirgappedSigner is initialized - * - * @returns True if the AirgappedSigner is initialized, false otherwise - */ - isInitialized(): boolean { - return this.#source !== undefined; - } - - /** - * Derive an address from the source at a given index - * - * @param index - The index to derive the address from - * @returns The derived address in hex format - * @throws Will throw an error if the source is not initialized - */ - addressFromIndex(index: number): Hex { - if (!this.#source) { - throw new Error('UR not initialized'); - } - - if (this.#source.keyringMode === KeyringMode.ACCOUNT) { - const address = Object.keys(this.#source.paths)[index]; - if (!address) { - throw new Error(`Address not found for index ${index}`); - } - return add0x(address); - } - const childPath = `m/${this.#source.childrenPath.replace( - '*', - index.toString(), - )}`; - - const hdKey = HdKey.fromExtendedKey(this.#source.xpub); - const childKey = hdKey.derive(childPath); - - const address = Buffer.from( - publicToAddress(childKey.publicKey, true), - ).toString('hex'); - - const normalizedAddress = getChecksumAddress(add0x(address)); - - this.#source.indexes[normalizedAddress] = index; - - return normalizedAddress; - } - - /** - * Retrieve the path of an address derived from the source. - * - * @param address - The address to retrieve the path for - * @returns The path of the address - */ - pathFromAddress(address: Hex): string { - if (!this.#source) { - throw new Error('UR not initialized'); - } - - const normalizedAddress = getChecksumAddress(add0x(address)); - - if (this.#source.keyringMode === KeyringMode.ACCOUNT) { - const path = this.#source.paths[normalizedAddress]; - if (path === undefined) { - throw new Error(`Unknown address ${normalizedAddress}`); - } - return path; - } - - const index = this.indexFromAddress(normalizedAddress); - return `${this.#source.hdPath}/${this.#source.childrenPath - .replace('*', index.toString()) - .replace(/\*/gu, '0')}`; - } - - /** - * Retrieve the index of an address from the source - * - * @param address - The normalized address to retrieve the index for - * @returns The index of the address - */ - indexFromAddress(address: Hex): number { - if (!this.#source) { - throw new Error('UR not initialized'); - } - - const cachedIndex = this.#source.indexes[address]; - if (cachedIndex !== undefined) { - return Number(cachedIndex); - } - - if (this.#source.keyringMode === KeyringMode.ACCOUNT) { - const path = this.#source.paths[address]; - if (path === undefined) { - throw new Error(`Unknown address`); - } - - const index = path.split('/').pop(); - if (index === undefined) { - throw new Error(`Invalid path for address ${address}`); - } - - return Number(index); - } - - for (let i = 0; i < MAX_INDEX; i++) { - const derivedAddress = this.addressFromIndex(i); - if (derivedAddress === address) { - return i; - } - } - - throw new Error(`Address ${address} not found`); - } - - /** - * Get a page of addresses derived from the source. - * - * @param page - The page number to retrieve - * @param pageSize - The number of addresses per page - * @returns An array of IndexedAddress objects, each containing the address and its index - * @throws Will throw an error if the source is not initialized - */ - getAddressesPage(page: number, pageSize = 5): IndexedAddress[] { - const startIndex = page * pageSize; - const endIndex = startIndex + pageSize; - const addresses: IndexedAddress[] = []; - - for (let i = startIndex; i < endIndex; i++) { - const address = this.addressFromIndex(i); - addresses.push({ address, index: i }); - } - - return addresses; - } - - /** - * Gets the source details of the AirgappedSigner. - * - * @returns The source details, or undefined if not initialized - */ - getSourceDetails(): AirgappedSignerDetails | undefined { - return this.#source; - } - - /** - * Clear the source details. - */ - clear(): void { - this.#source = undefined; - } - - /** - * Set the root account from a UR string - * - * @param ur - The UR string to set the root account from - */ - #initFromUR(ur: string | SerializedUR): void { - const source = this.#decodeUR(ur); - const fingerprint = getFingerprintFromSource(source); - - if (source instanceof CryptoAccount) { - const { name, xfp, paths, keyringAccount } = - readCryptoAccountOutputDescriptors(source); - this.#source = { - keyringMode: KeyringMode.ACCOUNT, - keyringAccount, - name, - xfp, - paths, - indexes: {}, - }; - } else { - const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; - this.#source = { - keyringMode: KeyringMode.HD, - keyringAccount: getNote(), - name: getName(), - xfp: fingerprint, - hdPath: `m/${getOrigin().getPath()}`, - childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, - xpub: getBip32Key(), - indexes: {}, - }; - } - } - - /** - * Decodes a UR - * - * @param ur - The UR to decode - * @returns The decoded CryptoAccount or CryptoHDKey - * @throws Will throw an error if the UR type is not supported - */ - #decodeUR(ur: string | SerializedUR): CryptoAccount | CryptoHDKey { - const decodedUR = - typeof ur === 'string' - ? URRegistryDecoder.decode(ur) - : { - type: ur.type, - cbor: Buffer.from(ur.cbor, 'hex'), - }; - - const { type, cbor } = decodedUR; - - switch (type) { - case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: - return CryptoHDKey.fromCBOR(cbor); - case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: - return CryptoAccount.fromCBOR(cbor); - default: - throw new Error('Unsupported UR type'); - } - } -} diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts new file mode 100644 index 000000000..9e29aa2dc --- /dev/null +++ b/packages/keyring-eth-qr/src/device.ts @@ -0,0 +1,613 @@ +import { RLP } from '@ethereumjs/rlp'; +import { + type TypedTransaction, + TransactionType, + type TypedTxData, + type FeeMarketEIP1559Transaction, + TransactionFactory, +} from '@ethereumjs/tx'; +import { publicToAddress } from '@ethereumjs/util'; +import { + CryptoAccount, + CryptoHDKey, + DataType, + ETHSignature, + EthSignRequest, + URRegistryDecoder, +} from '@keystonehq/bc-ur-registry-eth'; +import type { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; +import { add0x, getChecksumAddress, remove0x, type Hex } from '@metamask/utils'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import HdKey from 'hdkey'; +import { stringify, v4 as uuidv4 } from 'uuid'; + +import { + QrScanRequestType, + type QrKeyringBridge, + type QrSignatureRequest, + type SerializedUR, +} from './qr-keyring'; + +export const SUPPORTED_UR_TYPE = { + CRYPTO_HDKEY: 'crypto-hdkey', + CRYPTO_ACCOUNT: 'crypto-account', + ETH_SIGNATURE: 'eth-signature', +}; + +export enum DeviceMode { + HD = 'hd', + ACCOUNT = 'account', +} + +const DEFAULT_CHILDREN_PATH = '0/*'; + +const MAX_INDEX = 1_000; + +/** + * Common details for the device source, which can be either a CryptoAccount or CryptoHDKey. + */ +export type CommonDeviceDetails = { + /** + * Value take out from the device note field, if available. + */ + keyringAccount: string; + /** + * The name of the device + */ + name: string; + /** + * The device fingerprint, hex-encoded + */ + xfp: string; + /** + * Indexes of the accounts derived from the device + * in the form of a map from address to index + */ + indexes: Record; +}; + +/** + * Details for the HD mode of the Device. This mode derives + * accounts from a root public key (xpub) and a derivation path. + */ +export type HDModeDeviceDetails = { + /** + * The device mode is HD, indicating that it derives accounts from a + * root public key (xpub) and a derivation path. + */ + keyringMode: DeviceMode.HD; + /** + * The xpub of the HD key + */ + xpub: string; + /** + * The derivation path of the HD key + */ + hdPath: string; + /** + * The path used to derive child accounts + */ + childrenPath: string; +}; + +/** + * Details for the Account mode of the Device. This mode derives + * accounts from a set of addresses and their corresponding paths. + */ +export type AccountModeDeviceDetails = { + /** + * The device mode is ACCOUNT, indicating that it derives accounts from + * a set of addresses and their corresponding paths. + */ + keyringMode: DeviceMode.ACCOUNT; + /** + * The derivation paths for each hex-encoded address in the device + */ + paths: Record; +}; + +/** + * An address with its corresponding index. + */ +export type IndexedAddress = { + address: Hex; + index: number; +}; + +/** + * The details of the source CryptoAccount or CryptoHDKey + * that the Device uses to derive accounts. + */ +export type DeviceDetails = CommonDeviceDetails & + (HDModeDeviceDetails | AccountModeDeviceDetails); + +/** + * Get the fingerprint of the source CryptoAccount or CryptoHDKey + * + * @param source - The source CryptoAccount or CryptoHDKey + * @returns The fingerprint of the source + */ +function getFingerprintFromSource(source: CryptoAccount | CryptoHDKey): string { + return source instanceof CryptoAccount + ? source.getMasterFingerprint()?.toString('hex') + : source.getParentFingerprint()?.toString('hex'); +} + +/** + * Get fingerprint, account paths and names from the a CryptoAccount + * + * Note: This function emulates the behavior of the `@keystonehq/base-eth-keyring` + * library when dealing with CryptoAccount objects (for backwards compatibility + * reasons). Though, the way it retrieves `name` and `keyringAccount` is questionable, + * as `name` and `keyringAccount` are updated after each descriptor discovery, effectively + * returning the last descriptor's `name` and `keyringAccount`. + * + * @param source - The source CryptoAccount + * @returns The paths + */ +function readCryptoAccountOutputDescriptors(source: CryptoAccount): { + xfp: string; + paths: Record; + name: string; + keyringAccount: string; +} { + const descriptors = source.getOutputDescriptors(); + + if (!descriptors || descriptors.length === 0) { + throw new Error('No output descriptors found in CryptoAccount'); + } + + let name = ''; + let keyringAccount = ''; + const paths = descriptors.reduce( + (descriptorsPaths: Record, current) => { + const hdKey = current.getHDKey(); + if (hdKey) { + const path = `M/${hdKey.getOrigin().getPath()}`; + const address = getChecksumAddress( + add0x( + Buffer.from(publicToAddress(hdKey.getKey(), true)).toString('hex'), + ), + ); + descriptorsPaths[address] = path; + name = hdKey.getName(); + keyringAccount = hdKey.getNote(); + } + return descriptorsPaths; + }, + {}, + ); + + return { + paths, + name, + keyringAccount, + xfp: getFingerprintFromSource(source), + }; +} + +export class Device { + readonly #requestScan: QrKeyringBridge['requestScan']; + + #pairedDevice?: DeviceDetails | undefined; + + constructor(requestScan: QrKeyringBridge['requestScan']) { + this.#requestScan = requestScan; + } + + /** + * Initialize the Device with a source. + * + * @param source - The account source, which can be a CryptoAccount, CryptoHDKey, or a UR string. + */ + init(source: DeviceDetails | string | SerializedUR): void { + if (typeof source === 'string' || 'cbor' in source) { + this.#initFromUR(source); + } else { + this.#pairedDevice = source; + } + } + + /** + * Check if this class instance is paired with a device + * + * @returns True if the Device is initialized, false otherwise + */ + isInitialized(): boolean { + return this.#pairedDevice !== undefined; + } + + /** + * Derive an address from the source at a given index + * + * @param index - The index to derive the address from + * @returns The derived address in hex format + * @throws Will throw an error if the source is not initialized + */ + addressFromIndex(index: number): Hex { + if (!this.#pairedDevice) { + throw new Error('Device not paired'); + } + + if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { + const address = Object.keys(this.#pairedDevice.paths)[index]; + if (!address) { + throw new Error(`Address not found for index ${index}`); + } + return add0x(address); + } + const childPath = `m/${this.#pairedDevice.childrenPath.replace( + '*', + index.toString(), + )}`; + + const hdKey = HdKey.fromExtendedKey(this.#pairedDevice.xpub); + const childKey = hdKey.derive(childPath); + + const address = Buffer.from( + publicToAddress(childKey.publicKey, true), + ).toString('hex'); + + const normalizedAddress = getChecksumAddress(add0x(address)); + + this.#pairedDevice.indexes[normalizedAddress] = index; + + return normalizedAddress; + } + + /** + * Retrieve the path of an address derived from the source. + * + * @param address - The address to retrieve the path for + * @returns The path of the address + */ + pathFromAddress(address: Hex): string { + if (!this.#pairedDevice) { + throw new Error('UR not initialized'); + } + + const normalizedAddress = getChecksumAddress(add0x(address)); + + if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { + const path = this.#pairedDevice.paths[normalizedAddress]; + if (path === undefined) { + throw new Error(`Unknown address ${normalizedAddress}`); + } + return path; + } + + const index = this.indexFromAddress(normalizedAddress); + return `${this.#pairedDevice.hdPath}/${this.#pairedDevice.childrenPath + .replace('*', index.toString()) + .replace(/\*/gu, '0')}`; + } + + /** + * Retrieve the index of an address from the source + * + * @param address - The normalized address to retrieve the index for + * @returns The index of the address + */ + indexFromAddress(address: Hex): number { + if (!this.#pairedDevice) { + throw new Error('UR not initialized'); + } + + const cachedIndex = this.#pairedDevice.indexes[address]; + if (cachedIndex !== undefined) { + return Number(cachedIndex); + } + + if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { + const path = this.#pairedDevice.paths[address]; + if (path === undefined) { + throw new Error(`Unknown address`); + } + + const index = path.split('/').pop(); + if (index === undefined) { + throw new Error(`Invalid path for address ${address}`); + } + + return Number(index); + } + + for (let i = 0; i < MAX_INDEX; i++) { + const derivedAddress = this.addressFromIndex(i); + if (derivedAddress === address) { + return i; + } + } + + throw new Error(`Address ${address} not found`); + } + + /** + * Get a page of addresses derived from the source. + * + * @param page - The page number to retrieve + * @param pageSize - The number of addresses per page + * @returns An array of IndexedAddress objects, each containing the address and its index + * @throws Will throw an error if the source is not initialized + */ + getAddressesPage(page: number, pageSize = 5): IndexedAddress[] { + const startIndex = page * pageSize; + const endIndex = startIndex + pageSize; + const addresses: IndexedAddress[] = []; + + for (let i = startIndex; i < endIndex; i++) { + const address = this.addressFromIndex(i); + addresses.push({ address, index: i }); + } + + return addresses; + } + + /** + * Retrieve the details of the paired device, if any. + * + * @returns Thea paired device details, or undefined if not initialized + */ + getDeviceDetails(): DeviceDetails | undefined { + return this.#pairedDevice; + } + + /** + * Clear the paired device details. + */ + clear(): void { + this.#pairedDevice = undefined; + } + + /** + * Sign a transaction. This is equivalent to the `eth_signTransaction` + * Ethereum JSON-RPC method. See the Ethereum JSON-RPC API documentation for + * more details. + * + * @param address - The address of the account to use for signing. + * @param transaction - The transaction to sign. + * @returns The signed transaction. + */ + async signTransaction( + address: Hex, + transaction: TypedTransaction, + ): Promise { + if (!this.#pairedDevice?.xfp) { + throw new Error('No device paired.'); + } + + const dataType = + transaction.type === TransactionType.Legacy + ? DataType.transaction + : DataType.typedTransaction; + + const messageToSign = Buffer.from( + transaction.type === TransactionType.Legacy + ? RLP.encode(transaction.getMessageToSign()) + : (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), + ); + + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + messageToSign, + dataType, + this.pathFromAddress(address), + this.#pairedDevice.xfp, + requestId, + Number(transaction.common.chainId()), + ).toUR(); + + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + requestTitle: 'Scan with your hardware wallet', + requestDescription: + 'After your device has signed this message, click on "Scan" to receive the signature', + }); + + return TransactionFactory.fromTxData( + { + ...transaction.toJSON(), + r, + s, + v, + }, + { + common: transaction.common, + }, + ); + } + + /** + * Sign a message. This is equivalent to the `eth_signTypedData` v4 Ethereum + * JSON-RPC method. + * + * @param address - The address of the account to use for signing. + * @param data - The data to sign. + * @returns The signed message. + */ + async signTypedData( + address: Hex, + data: TypedMessage, + ): Promise { + if (!this.#pairedDevice?.xfp) { + throw new Error('No device paired.'); + } + + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + Buffer.from(JSON.stringify(data), 'utf8'), + DataType.typedData, + this.pathFromAddress(address), + this.#pairedDevice.xfp, + requestId, + undefined, + address, + ).toUR(); + + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + }); + + return add0x( + Buffer.concat([ + Uint8Array.from(r), + Uint8Array.from(s), + Uint8Array.from(v), + ]).toString('hex'), + ); + } + + /** + * Sign a message. This is equivalent to the `eth_sign` Ethereum JSON-RPC + * method, which is exposed by MetaMask as the method `personal_sign`. See + * the Ethereum JSON-RPC API documentation for more details. + * + * For more information about this method and why we call it `personal_sign`, + * see the {@link https://docs.metamask.io/guide/signing-data.html|MetaMask Docs}. + * + * @param address - The address of the account to use for signing. + * @param message - The message to sign. + * @returns The signed message. + */ + async signPersonalMessage(address: Hex, message: Hex): Promise { + if (!this.#pairedDevice?.xfp) { + throw new Error('No device paired.'); + } + + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + Buffer.from(remove0x(message), 'hex'), + DataType.personalMessage, + this.pathFromAddress(address), + this.#pairedDevice.xfp, + requestId, + undefined, + address, + ).toUR(); + + const { r, s, v } = await this.#requestSignature({ + requestId, + payload: { + type: ethSignRequestUR.type, + cbor: ethSignRequestUR.cbor.toString('hex'), + }, + }); + + return add0x( + Buffer.concat([ + Uint8Array.from(r), + Uint8Array.from(s), + Uint8Array.from(v), + ]).toString('hex'), + ); + } + + /** + * Set the paired device from a UR string + * + * @param ur - The UR string to set the root account from + */ + #initFromUR(ur: string | SerializedUR): void { + const source = this.#decodeUR(ur); + const fingerprint = getFingerprintFromSource(source); + + if (source instanceof CryptoAccount) { + const { name, xfp, paths, keyringAccount } = + readCryptoAccountOutputDescriptors(source); + this.#pairedDevice = { + keyringMode: DeviceMode.ACCOUNT, + keyringAccount, + name, + xfp, + paths, + indexes: {}, + }; + } else { + const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; + this.#pairedDevice = { + keyringMode: DeviceMode.HD, + keyringAccount: getNote(), + name: getName(), + xfp: fingerprint, + hdPath: `m/${getOrigin().getPath()}`, + childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, + xpub: getBip32Key(), + indexes: {}, + }; + } + } + + /** + * Request a signature for a transaction or message. + * + * @param request - The signature request containing the data to sign. + * @returns The signature as an object containing r, s, and v values. + */ + async #requestSignature( + request: QrSignatureRequest, + ): Promise<{ r: Buffer; s: Buffer; v: Buffer }> { + const response = await this.#requestScan({ + type: QrScanRequestType.SIGN, + request, + }); + const signatureEnvelope = ETHSignature.fromCBOR( + Buffer.from(response.cbor, 'hex'), + ); + const signature = signatureEnvelope.getSignature(); + const requestId = signatureEnvelope.getRequestId(); + + if (!requestId) { + throw new Error('Signature request ID is missing.'); + } + + if (request.requestId !== stringify(requestId)) { + throw new Error( + `Signature request ID mismatch. Expected: ${ + request.requestId + }, received: ${requestId.toString('hex')}`, + ); + } + + return { + r: signature.subarray(0, 32), + s: signature.subarray(32, 64), + v: signature.subarray(64), + }; + } + + /** + * Decodes a UR + * + * @param ur - The UR to decode + * @returns The decoded CryptoAccount or CryptoHDKey + * @throws Will throw an error if the UR type is not supported + */ + #decodeUR(ur: string | SerializedUR): CryptoAccount | CryptoHDKey { + const decodedUR = + typeof ur === 'string' + ? URRegistryDecoder.decode(ur) + : { + type: ur.type, + cbor: Buffer.from(ur.cbor, 'hex'), + }; + + const { type, cbor } = decodedUR; + + switch (type) { + case SUPPORTED_UR_TYPE.CRYPTO_HDKEY: + return CryptoHDKey.fromCBOR(cbor); + case SUPPORTED_UR_TYPE.CRYPTO_ACCOUNT: + return CryptoAccount.fromCBOR(cbor); + default: + throw new Error('Unsupported UR type'); + } + } +} diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index ee003d3dc..c4f1e9e86 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -5,7 +5,7 @@ import type { Hex } from '@metamask/utils'; import type { QrKeyringBridge, SerializedQrKeyringState } from '.'; import { QrKeyring } from '.'; -import { KeyringMode } from './airgapped-signer'; +import { DeviceMode } from './device'; const KNOWN_HDKEY_UR = 'UR:CRYPTO-HDKEY/ONAXHDCLAXPYSTPELBTLSNGWTTWPGSBSIOWKRFOXHSWSRHURSAHGMYMNTEFLTEBGADAHAXKTNBAAHDCXIODLDNDNZCONGOGMYKJLGHCLTDRYBEJTGOWZWLGLJKEOKIHEFHCLNLAMQDNDZSGEAMTAADDYOEADLNCSDWYKCSFNYKAEYKAOCYIHCHGSOYAYCYIHIAECEYASJNFPINJPFLHSJOCXDPCXJYIHJKJYINDAAAEC'; @@ -33,7 +33,7 @@ const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', ], keyringAccount: 'account.standard', - keyringMode: KeyringMode.HD, + keyringMode: DeviceMode.HD, name: 'AirGap - test', version: 1, xfp: '65174ca1', @@ -55,7 +55,7 @@ const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { initialized: true, name: 'AirGap - test', - keyringMode: KeyringMode.HD, + keyringMode: DeviceMode.HD, keyringAccount: KNOWN_HDKEY.getNote(), xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), xpub: KNOWN_HDKEY.getBip32Key(), @@ -290,7 +290,7 @@ describe('QrKeyring', () => { }); await expect(keyring.addAccounts(1)).rejects.toThrow( - 'UR not initialized', + 'Device not paired', ); }); }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index eb721e1ce..033649e44 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,35 +1,17 @@ -import { RLP } from '@ethereumjs/rlp'; -import { - type FeeMarketEIP1559Transaction, - TransactionFactory, - TransactionType, - type TypedTransaction, - type TypedTxData, -} from '@ethereumjs/tx'; -import { - DataType, - ETHSignature, - EthSignRequest, -} from '@keystonehq/bc-ur-registry-eth'; +import { type TypedTransaction, type TypedTxData } from '@ethereumjs/tx'; import type { TypedMessage, MessageTypes } from '@metamask/eth-sig-util'; import type { Keyring } from '@metamask/keyring-utils'; -import { type Hex, add0x, getChecksumAddress, remove0x } from '@metamask/utils'; -import { stringify, v4 as uuidv4 } from 'uuid'; +import { type Hex, add0x, getChecksumAddress } from '@metamask/utils'; import { - type AirgappedSignerDetails, + type DeviceDetails, type IndexedAddress, - AirgappedSigner, - KeyringMode, -} from './airgapped-signer'; + Device, + DeviceMode, +} from './device'; export const QR_KEYRING_TYPE = 'QR Hardware Wallet Device'; -const DEFAULT_SCAN_REQUEST_TITLE = 'Scan with your hardware wallet'; - -const DEFAULT_SCAN_REQUEST_DESCRIPTION = - 'After your device has scanned this QR code, click on "Scan" to receive the information.'; - export enum QrScanRequestType { /** * Request a scan for a QR code containing a UR @@ -84,7 +66,7 @@ export type SerializedQrKeyringState = { } | ({ initialized: true; - } & AirgappedSignerDetails) + } & DeviceDetails) ); /** @@ -115,7 +97,7 @@ export class QrKeyring implements Keyring { readonly bridge: QrKeyringBridge; - readonly #signer: AirgappedSigner = new AirgappedSigner(); + readonly #device: Device; #accounts: Hex[] = []; @@ -126,6 +108,8 @@ export class QrKeyring implements Keyring { constructor(options: QrKeyringOptions) { this.bridge = options.bridge; + this.#device = new Device(this.bridge.requestScan.bind(this.bridge)); + if (options?.ur) { this.submitUR(options.ur); } @@ -137,43 +121,43 @@ export class QrKeyring implements Keyring { * @returns The serialized state */ async serialize(): Promise { - const source = this.#signer.getSourceDetails(); + const deviceDetails = this.#device.getDeviceDetails(); if ( - !source || - ![KeyringMode.HD, KeyringMode.ACCOUNT].includes(source.keyringMode) + !deviceDetails || + ![DeviceMode.HD, DeviceMode.ACCOUNT].includes(deviceDetails.keyringMode) ) { - // the keyring has not initialized with a device source yet + // the keyring has not initialized with a deviceDetails device yet return getDefaultSerializedQrKeyringState(); } const accounts = this.#accounts.slice(); - if (source.keyringMode === KeyringMode.HD) { + if (deviceDetails.keyringMode === DeviceMode.HD) { // These properties are only relevant for HD Keys return { initialized: true, - name: source.name, - keyringMode: KeyringMode.HD, - keyringAccount: source.keyringAccount, - xfp: source.xfp, - xpub: source.xpub, - hdPath: source.hdPath, - childrenPath: source.childrenPath, + name: deviceDetails.name, + keyringMode: DeviceMode.HD, + keyringAccount: deviceDetails.keyringAccount, + xfp: deviceDetails.xfp, + xpub: deviceDetails.xpub, + hdPath: deviceDetails.hdPath, + childrenPath: deviceDetails.childrenPath, accounts, - indexes: source.indexes, + indexes: deviceDetails.indexes, }; } // These properties are only relevant for Account Keys return { initialized: true, - name: source.name, - keyringMode: KeyringMode.ACCOUNT, - keyringAccount: source.keyringAccount, - xfp: source.xfp, - paths: source.paths, + name: deviceDetails.name, + keyringMode: DeviceMode.ACCOUNT, + keyringAccount: deviceDetails.keyringAccount, + xfp: deviceDetails.xfp, + paths: deviceDetails.paths, accounts, - indexes: source.indexes, + indexes: deviceDetails.indexes, }; } @@ -185,11 +169,11 @@ export class QrKeyring implements Keyring { async deserialize(state: SerializedQrKeyringState): Promise { if (!state.initialized) { this.#accounts = []; - this.#signer.clear(); + this.#device.clear(); return; } - this.#signer.init(state); + this.#device.init(state); this.#accounts = (state.accounts ?? []).map(normalizeAddress); } @@ -203,12 +187,12 @@ export class QrKeyring implements Keyring { const lastAccount = this.#accounts[this.#accounts.length - 1]; const startIndex = this.#accountToUnlock ?? - (lastAccount ? this.#signer.indexFromAddress(lastAccount) : 0); + (lastAccount ? this.#device.indexFromAddress(lastAccount) : 0); const newAccounts: Hex[] = []; for (let i = 0; i < accountsToAdd; i++) { const index = startIndex + i; - const address = this.#signer.addressFromIndex(index); + const address = this.#device.addressFromIndex(index); if (this.#accounts.includes(address)) { continue; @@ -249,7 +233,7 @@ export class QrKeyring implements Keyring { * @param ur - The CBOR encoded UR */ submitUR(ur: string | SerializedUR): void { - this.#signer.init(ur); + this.#device.init(ur); } /** @@ -268,7 +252,7 @@ export class QrKeyring implements Keyring { * @returns The name of the paired device or the keyring type. */ getName(): string { - const source = this.#signer.getSourceDetails(); + const source = this.#device.getDeviceDetails(); return source?.name ?? QR_KEYRING_TYPE; } @@ -316,18 +300,18 @@ export class QrKeyring implements Keyring { * @returns The current page of accounts as an array of IndexedAddress objects. */ async getCurrentPage(): Promise { - if (!this.#signer.isInitialized()) { + if (!this.#device.isInitialized()) { await this.#scanAndInitialize(); } - return this.#signer.getAddressesPage(this.#currentPage); + return this.#device.getAddressesPage(this.#currentPage); } /** * Clear the keyring state and forget any paired device or accounts. */ async forgetDevice(): Promise { - this.#signer.clear(); + this.#device.clear(); this.#accounts = []; this.#accountToUnlock = undefined; this.#currentPage = 0; @@ -346,54 +330,7 @@ export class QrKeyring implements Keyring { address: Hex, transaction: TypedTransaction, ): Promise { - const signerSource = this.#signer.getSourceDetails(); - if (!signerSource?.xfp) { - throw new Error('Keyring is not initialized. Please scan a QR code.'); - } - - const dataType = - transaction.type === TransactionType.Legacy - ? DataType.transaction - : DataType.typedTransaction; - - const messageToSign = Buffer.from( - transaction.type === TransactionType.Legacy - ? RLP.encode(transaction.getMessageToSign()) - : (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), - ); - - const requestId = uuidv4(); - const ethSignRequestUR = EthSignRequest.constructETHRequest( - messageToSign, - dataType, - this.#signer.pathFromAddress(address), - signerSource.xfp, - requestId, - Number(transaction.common.chainId()), - ).toUR(); - - const { r, s, v } = await this.#requestSignature({ - requestId, - payload: { - type: ethSignRequestUR.type, - cbor: ethSignRequestUR.cbor.toString('hex'), - }, - requestTitle: 'Scan with your hardware wallet', - requestDescription: - 'After your device has signed this message, click on "Scan" to receive the signature', - }); - - return TransactionFactory.fromTxData( - { - ...transaction.toJSON(), - r, - s, - v, - }, - { - common: transaction.common, - }, - ); + return this.#device.signTransaction(address, transaction); } /** @@ -408,39 +345,7 @@ export class QrKeyring implements Keyring { address: Hex, data: TypedMessage, ): Promise { - const signerSource = this.#signer.getSourceDetails(); - if (!signerSource?.xfp) { - throw new Error('Keyring is not initialized. Please scan a QR code.'); - } - - const requestId = uuidv4(); - const ethSignRequestUR = EthSignRequest.constructETHRequest( - Buffer.from(JSON.stringify(data), 'utf8'), - DataType.typedData, - this.#signer.pathFromAddress(address), - signerSource.xfp, - requestId, - undefined, - address, - ).toUR(); - - const { r, s, v } = await this.#requestSignature({ - requestId, - payload: { - type: ethSignRequestUR.type, - cbor: ethSignRequestUR.cbor.toString('hex'), - }, - requestTitle: DEFAULT_SCAN_REQUEST_TITLE, - requestDescription: DEFAULT_SCAN_REQUEST_DESCRIPTION, - }); - - return add0x( - Buffer.concat([ - Uint8Array.from(r), - Uint8Array.from(s), - Uint8Array.from(v), - ]).toString('hex'), - ); + return this.#device.signTypedData(address, data); } /** @@ -456,37 +361,7 @@ export class QrKeyring implements Keyring { * @returns The signed message. */ async signPersonalMessage(address: Hex, message: Hex): Promise { - const signerSource = this.#signer.getSourceDetails(); - if (!signerSource?.xfp) { - throw new Error('Keyring is not initialized. Please scan a QR code.'); - } - - const requestId = uuidv4(); - const ethSignRequestUR = EthSignRequest.constructETHRequest( - Buffer.from(remove0x(message), 'hex'), - DataType.personalMessage, - this.#signer.pathFromAddress(address), - signerSource.xfp, - requestId, - undefined, - address, - ).toUR(); - - const { r, s, v } = await this.#requestSignature({ - requestId, - payload: { - type: ethSignRequestUR.type, - cbor: ethSignRequestUR.cbor.toString('hex'), - }, - }); - - return add0x( - Buffer.concat([ - Uint8Array.from(r), - Uint8Array.from(s), - Uint8Array.from(v), - ]).toString('hex'), - ); + return this.#device.signPersonalMessage(address, message); } /** @@ -498,42 +373,4 @@ export class QrKeyring implements Keyring { await this.bridge.requestScan({ type: QrScanRequestType.PAIR }), ); } - - /** - * Request a signature for a transaction or message. - * - * @param request - The signature request containing the data to sign. - * @returns The signature as an object containing r, s, and v values. - */ - async #requestSignature( - request: QrSignatureRequest, - ): Promise<{ r: Buffer; s: Buffer; v: Buffer }> { - const response = await this.bridge.requestScan({ - type: QrScanRequestType.SIGN, - request, - }); - const signatureEnvelope = ETHSignature.fromCBOR( - Buffer.from(response.cbor, 'hex'), - ); - const signature = signatureEnvelope.getSignature(); - const requestId = signatureEnvelope.getRequestId(); - - if (!requestId) { - throw new Error('Signature request ID is missing.'); - } - - if (request.requestId !== stringify(requestId)) { - throw new Error( - `Signature request ID mismatch. Expected: ${ - request.requestId - }, received: ${requestId.toString('hex')}`, - ); - } - - return { - r: signature.subarray(0, 32), - s: signature.subarray(32, 64), - v: signature.subarray(64), - }; - } } diff --git a/yarn.lock b/yarn.lock index 21b9a5718..c633b6769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1789,7 +1789,7 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-utils": "workspace:^" + "@metamask/keyring-utils": "npm:^3.0.0" "@metamask/utils": "npm:^11.1.0" "@types/hdkey": "npm:^2.0.1" "@types/jest": "npm:^29.5.12" @@ -2149,7 +2149,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-utils@workspace:^, @metamask/keyring-utils@workspace:packages/keyring-utils": +"@metamask/keyring-utils@npm:^3.0.0, @metamask/keyring-utils@workspace:^, @metamask/keyring-utils@workspace:packages/keyring-utils": version: 0.0.0-use.local resolution: "@metamask/keyring-utils@workspace:packages/keyring-utils" dependencies: From 307c0d39542a31d5445c2a48b17db258b3b30729 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Fri, 27 Jun 2025 13:32:26 +0200 Subject: [PATCH 25/46] update `yarn.lock` --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index c633b6769..21b9a5718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1789,7 +1789,7 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.2.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/keyring-utils": "npm:^3.0.0" + "@metamask/keyring-utils": "workspace:^" "@metamask/utils": "npm:^11.1.0" "@types/hdkey": "npm:^2.0.1" "@types/jest": "npm:^29.5.12" @@ -2149,7 +2149,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/keyring-utils@npm:^3.0.0, @metamask/keyring-utils@workspace:^, @metamask/keyring-utils@workspace:packages/keyring-utils": +"@metamask/keyring-utils@workspace:^, @metamask/keyring-utils@workspace:packages/keyring-utils": version: 0.0.0-use.local resolution: "@metamask/keyring-utils@workspace:packages/keyring-utils" dependencies: From 3eee499c0b710e22fc1d9a8ad583c9d928208450 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 2 Jul 2025 12:51:12 +0200 Subject: [PATCH 26/46] test: add coverage for signing methods --- packages/keyring-eth-qr/jest.config.js | 8 +- packages/keyring-eth-qr/package.json | 1 + .../src/qr-keyring-scanner-bridge.test.ts | 24 + .../keyring-eth-qr/src/qr-keyring.test.ts | 505 +++++++++++++++++- yarn.lock | 1 + 5 files changed, 511 insertions(+), 28 deletions(-) create mode 100644 packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.test.ts diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index d9d85df73..fdb194d0c 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 46.03, - functions: 51.16, - lines: 49.51, - statements: 50, + branches: 68.25, + functions: 95.34, + lines: 80.28, + statements: 80.66, }, }, }); diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 27f013e6e..8b13e82c4 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -59,6 +59,7 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@ethereumjs/common": "^4.4.0", "@keystonehq/base-eth-keyring": "^0.15.1", "@keystonehq/metamask-airgapped-keyring": "^0.15.2", "@lavamoat/allow-scripts": "^3.2.1", diff --git a/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.test.ts b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.test.ts new file mode 100644 index 000000000..752d8dcdf --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.test.ts @@ -0,0 +1,24 @@ +import { + type QrScanRequest, + QrScanRequestType, + QrKeyringScannerBridge, +} from '.'; + +describe('QrKeyringScannerBridge', () => { + describe('requestScan', () => { + it('forwards the request to the constructor hook', async () => { + const request: QrScanRequest = { + type: QrScanRequestType.PAIR, + }; + const mockRequestScan = jest.fn(); + const hooks = { + requestScan: mockRequestScan, + }; + + const qrKeyringScannerBridge = new QrKeyringScannerBridge(hooks); + await qrKeyringScannerBridge.requestScan(request); + + expect(mockRequestScan).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index c4f1e9e86..748904513 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,10 +1,12 @@ +import { Common } from '@ethereumjs/common'; +import { TransactionFactory } from '@ethereumjs/tx'; import type { StoredKeyring } from '@keystonehq/base-eth-keyring'; -import { CryptoHDKey } from '@keystonehq/bc-ur-registry-eth'; +import { CryptoHDKey, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; -import type { Hex } from '@metamask/utils'; +import * as uuid from 'uuid'; import type { QrKeyringBridge, SerializedQrKeyringState } from '.'; -import { QrKeyring } from '.'; +import { QrKeyring, QrScanRequestType } from '.'; import { DeviceMode } from './device'; const KNOWN_HDKEY_UR = @@ -17,11 +19,18 @@ const KNOWN_HDKEY: CryptoHDKey = CryptoHDKey.fromCBOR( Buffer.from(KNOWN_CBOR, 'hex'), ); -const EXPECTED_ACCOUNTS: Hex[] = [ +const EXPECTED_ACCOUNTS = [ '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', -]; + '0xd28Fc67A38378f9Cc39eC4017fBBC490724aF17c', + '0x141458Edda4dD05e63097278920C0C5da45418FC', + '0x9a6517E1BD06C4b03A41944A561634CF6E4C796C', + '0x8f6B4E6C58859f1E2D0525973AEc85277FB89664', + '0x46A5F3444E0e3bF7a47469AfAb7C765A8f0DC05F', + '0x62311fBCcC7bc2731648CBDf70Bb8c5426fa546f', + '0xd708B935f25BabE7789D8229114d93f2c88De307', +] as const; // Coming from an extension installation using `@keystonehq/metamask-airgapped-keyring` // and with a paired QR-based Device configured with the above known SRP @@ -64,6 +73,96 @@ const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { childrenPath: '0/*', }; +const SERIALIZED_QR_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { + ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + accounts: EXPECTED_ACCOUNTS.slice(0, 3), + indexes: { + [EXPECTED_ACCOUNTS[0]]: 0, + [EXPECTED_ACCOUNTS[1]]: 1, + [EXPECTED_ACCOUNTS[2]]: 2, + }, +}; + +const TRANSACTION = TransactionFactory.fromTxData({ + accessList: [], + chainId: '0x1', + data: '0x', + gasLimit: '0x5208', + maxFeePerGas: '0x2540be400', + maxPriorityFeePerGas: '0x3b9aca00', + nonce: '0x68', + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + value: '0x0', + type: 2, +}); + +const LEGACY_TRANSACTION = TransactionFactory.fromTxData( + { + chainId: '0xaa36a7', + data: '0x', + gasLimit: '0x5208', + gasPrice: '0x2540be400', + nonce: '0x68', + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + value: '0x0', + }, + { + common: new Common({ chain: 'sepolia' }), + }, +); + +const TYPED_MESSAGE = { + domain: { + chainId: 1, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1', + salt: new TextEncoder().encode('hello'), + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallets: [ + '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + ], + }, + to: [ + { + name: 'Bob', + wallets: [ + '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + '0xB0B0b0b0b0b0B000000000000000000000000000', + ], + }, + ], + }, + primaryType: 'Mail' as const, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Group: [ + { name: 'name', type: 'string' }, + { name: 'members', type: 'Person[]' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person[]' }, + { name: 'contents', type: 'string' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallets', type: 'address[]' }, + ], + }, +}; + /** * Get the xpub from a keyring. * @@ -102,6 +201,30 @@ function getMockBridge(): QrKeyringBridge { }; } +/** + * Mocks the bridge scan resolution for a given request ID and signature. + * + * @param bridge - The QrKeyringBridge instance to mock. + * @param signature - The signature to return in the mocked scan resolution. + */ +function mockBridgeScanResolution( + bridge: QrKeyringBridge, + signature: string, +): void { + jest.spyOn(bridge, 'requestScan').mockImplementationOnce(async (request) => { + const signatureUR = new ETHSignature( + Buffer.from(signature, 'hex'), + Buffer.from( + Uint8Array.from(uuid.parse(request.request?.requestId ?? '')), + ), + ).toUR(); + return { + type: signatureUR.type, + cbor: signatureUR.cbor.toString('hex'), + }; + }); +} + describe('QrKeyring', () => { describe('constructor', () => { it('can be constructed with the bridge only', () => { @@ -154,10 +277,8 @@ describe('QrKeyring', () => { ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]], indexes: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [EXPECTED_ACCOUNTS[0]!]: 0, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [EXPECTED_ACCOUNTS[1]!]: 1, + [EXPECTED_ACCOUNTS[0]]: 0, + [EXPECTED_ACCOUNTS[1]]: 1, }, }); }); @@ -214,7 +335,7 @@ describe('QrKeyring', () => { }); describe('addAccounts', () => { - describe('when the keyring is initialized with a UR', () => { + describe('when the keyring is paired with a device', () => { it('returns new accounts added', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), @@ -232,11 +353,14 @@ describe('QrKeyring', () => { bridge: getMockBridge(), ur: KNOWN_HDKEY_UR, }); + const numberOfAccountsToAdd = 3; - const accounts = await keyring.addAccounts(3); + const accounts = await keyring.addAccounts(numberOfAccountsToAdd); expect(accounts).toHaveLength(3); - expect(accounts).toStrictEqual(EXPECTED_ACCOUNTS); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); }); it('recovers indexes if they are not present in the serialized state', async () => { @@ -256,12 +380,9 @@ describe('QrKeyring', () => { const serialized = await keyring.serialize(); expect(await keyring.getAccounts()).toHaveLength(3); expect('indexes' in serialized && serialized.indexes).toStrictEqual({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [EXPECTED_ACCOUNTS[0]!]: 0, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [EXPECTED_ACCOUNTS[1]!]: 1, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [EXPECTED_ACCOUNTS[2]!]: 2, + [EXPECTED_ACCOUNTS[0]]: 0, + [EXPECTED_ACCOUNTS[1]]: 1, + [EXPECTED_ACCOUNTS[2]]: 2, }); }); @@ -283,7 +404,7 @@ describe('QrKeyring', () => { }); }); - describe('when the keyring is not initialized with a UR', () => { + describe('when the keyring is not paired with a device', () => { it('throws an error', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), @@ -313,11 +434,16 @@ describe('QrKeyring', () => { bridge: getMockBridge(), ur: KNOWN_HDKEY_UR, }); + const numberOfAccountsToAdd = 3; - const accounts = await keyring.addAccounts(3); + const accounts = await keyring.addAccounts(numberOfAccountsToAdd); - expect(await keyring.getAccounts()).toStrictEqual(EXPECTED_ACCOUNTS); - expect(accounts).toStrictEqual(EXPECTED_ACCOUNTS); + expect(await keyring.getAccounts()).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); }); }); }); @@ -330,8 +456,7 @@ describe('QrKeyring', () => { }); await keyring.addAccounts(3); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - keyring.removeAccount(EXPECTED_ACCOUNTS[1]!); + keyring.removeAccount(EXPECTED_ACCOUNTS[1]); expect(await keyring.getAccounts()).toStrictEqual([ EXPECTED_ACCOUNTS[0], @@ -354,6 +479,31 @@ describe('QrKeyring', () => { }); }); + describe('getName', () => { + describe('when the keyring is paired with a device', () => { + it('returns the name set by the paired device', () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + + expect(keyring.getName()).toBe( + SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS.name, + ); + }); + }); + + describe('when the keyring is not paired with a device', () => { + it('returns the keyring type', () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + expect(keyring.getName()).toBe(QrKeyring.type); + }); + }); + }); + describe('setAccountToUnlock', () => { it('sets an arbitrary account index to unlock', async () => { const keyring = new QrKeyring({ @@ -389,4 +539,311 @@ describe('QrKeyring', () => { expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme'); }); }); + + describe('getFirstPage', () => { + describe('when the keyring is not paired with a device', () => { + it('requests a scan through the bridge', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + await keyring.getFirstPage(); + + expect(mockBridge.requestScan).toHaveBeenCalledWith({ + type: QrScanRequestType.PAIR, + }); + }); + }); + + it('returns the first page of accounts', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + const accounts = await keyring.getFirstPage(); + + expect(accounts).toHaveLength(5); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, 5).map((account, index) => ({ + address: account, + index, + })), + ); + }); + }); + + describe('getNextPage', () => { + describe('when the keyring is not paired with a device', () => { + it('requests a scan through the bridge', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + await keyring.getNextPage(); + + expect(mockBridge.requestScan).toHaveBeenCalledWith({ + type: QrScanRequestType.PAIR, + }); + }); + }); + + it('returns the next (second) page of accounts', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + const accounts = await keyring.getNextPage(); + + expect(accounts).toHaveLength(5); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.map((account, index) => ({ + address: account, + index, + })).slice(5, 10), + ); + }); + }); + + describe('getPreviousPage', () => { + describe('when the keyring is not paired with a device', () => { + it('requests a scan through the bridge', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + await keyring.getPreviousPage(); + + expect(mockBridge.requestScan).toHaveBeenCalledWith({ + type: QrScanRequestType.PAIR, + }); + }); + }); + + it('returns the first page of accounts when on the first page', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + + const accounts = await keyring.getPreviousPage(); + + expect(accounts).toHaveLength(5); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, 5).map((account, index) => ({ + address: account, + index, + })), + ); + }); + + it('returns the previous page of accounts when on page > 0', async () => { + const mockBridge = getMockBridge(); + jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ + type: 'crypto-hdkey', + cbor: KNOWN_CBOR, + }); + const keyring = new QrKeyring({ + bridge: mockBridge, + }); + // move cursor to the third page + await keyring.getNextPage(); + await keyring.getNextPage(); + + const accounts = await keyring.getPreviousPage(); + + expect(accounts).toHaveLength(5); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.map((account, index) => ({ + address: account, + index, + })) + // we expect to receive the accounts from the second page + .slice(5, 10), + ); + }); + }); + + describe('forgetDevice', () => { + it('forgets the device', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + await keyring.deserialize(SERIALIZED_QR_KEYRING_WITH_ACCOUNTS); + // let's make sure we have an xpub set + expect(await keyring.getAccounts()).toStrictEqual( + SERIALIZED_QR_KEYRING_WITH_ACCOUNTS.accounts, + ); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + SERIALIZED_QR_KEYRING_WITH_ACCOUNTS.xpub, + ); + + await keyring.forgetDevice(); + + expect(await keyring.getAccounts()).toStrictEqual([]); + expect(await getXPUBFromKeyring(keyring)).toBeUndefined(); + }); + }); + + describe('signTransaction', () => { + describe('when the keyring is not paired with a device', () => { + it('throws an error', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + await expect( + keyring.signTransaction(EXPECTED_ACCOUNTS[0], TRANSACTION), + ).rejects.toThrow('No device paired'); + }); + }); + + describe('when the keyring is paired with a device', () => { + it('signs a transaction', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + mockBridgeScanResolution( + keyring.bridge, + '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', + ); + + const signedTx = await keyring.signTransaction( + EXPECTED_ACCOUNTS[0], + TRANSACTION, + ); + + expect(signedTx.r).toBeDefined(); + expect(signedTx.s).toBeDefined(); + expect(signedTx.v).toBeDefined(); + expect(signedTx.to).toStrictEqual(TRANSACTION.to); + expect(signedTx.nonce).toBe(TRANSACTION.nonce); + }); + + it('signs a legacy transaction', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + mockBridgeScanResolution( + keyring.bridge, + 'c28a6a8eeaedeef76bce7d1b2c255c6ee624feadd3b56797d88198c28853b25573221eb94f4ca5e2bce21d99c7c56823e22c7847064961f2e05d526037393fe701546d71', + ); + + const signedTx = await keyring.signTransaction( + EXPECTED_ACCOUNTS[0], + LEGACY_TRANSACTION, + ); + + expect(signedTx.r).toBeDefined(); + expect(signedTx.s).toBeDefined(); + expect(signedTx.v).toBeDefined(); + expect(signedTx.to).toStrictEqual(LEGACY_TRANSACTION.to); + expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce); + }); + }); + }); + + describe('signTypedData', () => { + describe('when the keyring is not paired with a device', () => { + it('throws an error', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + await expect( + keyring.signTypedData(EXPECTED_ACCOUNTS[0], TYPED_MESSAGE), + ).rejects.toThrow('No device paired'); + }); + }); + + describe('when the keyring is paired with a device', () => { + it('signs a typed data message', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + mockBridgeScanResolution( + keyring.bridge, + '1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', + ); + + const signedMessage = await keyring.signTypedData( + EXPECTED_ACCOUNTS[0], + TYPED_MESSAGE, + ); + + expect(signedMessage).toBeDefined(); + expect(signedMessage).toBe( + '0x1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', + ); + }); + }); + }); + + describe('signPersonalMessage', () => { + describe('when the keyring is not paired with a device', () => { + it('throws an error', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + await expect( + keyring.signPersonalMessage(EXPECTED_ACCOUNTS[0], '0x1234'), + ).rejects.toThrow('No device paired'); + }); + }); + + describe('when the keyring is paired with a device', () => { + it('signs a personal message', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + mockBridgeScanResolution( + keyring.bridge, + 'b1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', + ); + + const signedMessage = await keyring.signPersonalMessage( + EXPECTED_ACCOUNTS[0], + '0x1234', + ); + + expect(signedMessage).toBeDefined(); + expect(signedMessage).toBe( + '0xb1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', + ); + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 21b9a5718..6dba3140a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1780,6 +1780,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" dependencies: + "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/rlp": "npm:^5.0.2" "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^9.1.0" From 0b3231ee629854e0cb8fd28b6fdf645f9e86d532 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 2 Jul 2025 14:44:20 +0200 Subject: [PATCH 27/46] test: add coverage for `CryptoAccount` mode --- packages/keyring-eth-qr/jest.config.js | 8 +- .../keyring-eth-qr/src/qr-keyring.test.ts | 454 ++++++++++++------ 2 files changed, 301 insertions(+), 161 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index fdb194d0c..9d164753e 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 68.25, - functions: 95.34, - lines: 80.28, - statements: 80.66, + branches: 85.71, + functions: 100, + lines: 93.26, + statements: 93.39, }, }, }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 748904513..1bfda3018 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,35 +1,50 @@ import { Common } from '@ethereumjs/common'; import { TransactionFactory } from '@ethereumjs/tx'; import type { StoredKeyring } from '@keystonehq/base-eth-keyring'; -import { CryptoHDKey, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; +import { + CryptoAccount, + CryptoHDKey, + CryptoOutput, + ETHSignature, + ScriptExpressions, +} from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; import * as uuid from 'uuid'; import type { QrKeyringBridge, SerializedQrKeyringState } from '.'; import { QrKeyring, QrScanRequestType } from '.'; import { DeviceMode } from './device'; +import { Hex } from '@metamask/utils'; -const KNOWN_HDKEY_UR = - 'UR:CRYPTO-HDKEY/ONAXHDCLAXPYSTPELBTLSNGWTTWPGSBSIOWKRFOXHSWSRHURSAHGMYMNTEFLTEBGADAHAXKTNBAAHDCXIODLDNDNZCONGOGMYKJLGHCLTDRYBEJTGOWZWLGLJKEOKIHEFHCLNLAMQDNDZSGEAMTAADDYOEADLNCSDWYKCSFNYKAEYKAOCYIHCHGSOYAYCYIHIAECEYASJNFPINJPFLHSJOCXDPCXJYIHJKJYINDAAAEC'; - -const KNOWN_CBOR = - 'a503582103abc7af7fd5cd4fd1ec4c0f67f4bca461efb9dfc2578f8ed347d31201050377a0045820672f2b2bfda55552f56f5421d2bd106e55f2e94e73337d5f3f219906b39bfa4a06d90130a20186182cf5183cf500f5021a65174ca1081a65633532096d416972476170202d2074657374'; +// This CBOR and the the following test fixtures are all derived from this SRP: +// abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about +const KNOWN_HDKEY_CBOR = + 'a503582102eae4b876a8696134b868f88cc2f51f715f2dbedb7446b8e6edf3d4541c4eb67b045820d882718b7a42806803eeb17f7483f20620611adb88fc943c898dc5aba94c281906d90130a20186182cf5183cf500f5021ad32e4508081ad32e45080972696d546f6b656e2d4163636f756e74203031'; const KNOWN_HDKEY: CryptoHDKey = CryptoHDKey.fromCBOR( - Buffer.from(KNOWN_CBOR, 'hex'), + Buffer.from(KNOWN_HDKEY_CBOR, 'hex'), ); +const KNOWN_HDKEY_UR = KNOWN_HDKEY.toUREncoder(2000).nextPart(); + +const KNOWN_CRYPTO_ACCOUNT = new CryptoAccount(Buffer.from('37b5eed4', 'hex'), [ + new CryptoOutput([ScriptExpressions.WITNESS_SCRIPT_HASH], KNOWN_HDKEY), +]); + +const KNOWN_CRYPTO_ACCOUNT_UR = + KNOWN_CRYPTO_ACCOUNT.toUREncoder(2000).nextPart(); + const EXPECTED_ACCOUNTS = [ - '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', - '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', - '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', - '0xd28Fc67A38378f9Cc39eC4017fBBC490724aF17c', - '0x141458Edda4dD05e63097278920C0C5da45418FC', - '0x9a6517E1BD06C4b03A41944A561634CF6E4C796C', - '0x8f6B4E6C58859f1E2D0525973AEc85277FB89664', - '0x46A5F3444E0e3bF7a47469AfAb7C765A8f0DC05F', - '0x62311fBCcC7bc2731648CBDf70Bb8c5426fa546f', - '0xd708B935f25BabE7789D8229114d93f2c88De307', + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', + '0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E', + '0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA', + '0xA40cFBFc8534FFC84E20a7d8bBC3729B26a35F6f', + '0xB191a13bfE648B61002F2e2135867015B71816a6', + '0x593814d3309e2dF31D112824F0bb5aa7Cb0D7d47', + '0xB14c391e2bf19E5a26941617ab546FA620A4f163', + '0x4C1C56443AbFe6dD33de31dAaF0a6E929DBc4971', ] as const; // Coming from an extension installation using `@keystonehq/metamask-airgapped-keyring` @@ -37,22 +52,22 @@ const EXPECTED_ACCOUNTS = [ const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { initialized: true, accounts: [ - '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5', - '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B', - '0xC64D05CD3582531f19dcB16e5FA9652B281fA018', + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', ], keyringAccount: 'account.standard', keyringMode: DeviceMode.HD, - name: 'AirGap - test', + name: 'imToken-Account 01', version: 1, xfp: '65174ca1', - xpub: 'xpub6CPzTSQVcBw9QiZT3wcB6bUVW53m1ica5uByu5ktss4aHSqdj85GitSLT9uarxQgoyfaedxkWZKAeSjSSMNSursAd7eiFpc5ywjnkS1Aur4', + xpub: 'xpub6DCoCpSuQZB2jawqnGMEPS63ePKWkwWPH4TU45Q7LPXWuNd8TMtVxRrgjtEshuqpK3mdhaWHPFsBngh5GFZaM6si3yZdUsT8ddYM3PwnATt', hdPath: "m/44'/60'/0'", childrenPath: '0/*', indexes: { - '0x8DC309e828CE024b1ae7a9AA7882D37AD18181d5': 0, - '0x98396D8bF756F419eF6CDba819e9DF00E6F2B51B': 1, - '0xC64D05CD3582531f19dcB16e5FA9652B281fA018': 2, + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94': 0, + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0': 1, + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A': 2, }, paths: {}, // These last properties are not used by `@metamask/eth-qr-keyring` @@ -61,9 +76,9 @@ const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { perPage: 5, }; -const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { +const HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { initialized: true, - name: 'AirGap - test', + name: 'imToken-Account 01', keyringMode: DeviceMode.HD, keyringAccount: KNOWN_HDKEY.getNote(), xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), @@ -73,8 +88,8 @@ const SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { childrenPath: '0/*', }; -const SERIALIZED_QR_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, +const HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, accounts: EXPECTED_ACCOUNTS.slice(0, 3), indexes: { [EXPECTED_ACCOUNTS[0]]: 0, @@ -83,6 +98,26 @@ const SERIALIZED_QR_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { }, }; +const ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { + initialized: true, + name: 'imToken-Account 01', + keyringMode: DeviceMode.ACCOUNT, + keyringAccount: KNOWN_HDKEY.getNote(), + xfp: '37b5eed4', + paths: { + '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': "M/44'/60'/0'", + }, + indexes: {}, +}; + +const ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { + ...ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: ['0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3'], + indexes: { + '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': 0, + }, +}; + const TRANSACTION = TransactionFactory.fromTxData({ accessList: [], chainId: '0x1', @@ -232,15 +267,53 @@ describe('QrKeyring', () => { expect(keyring).toBeInstanceOf(QrKeyring); }); - it('can be constructed with a bridge and a UR string', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + describe('when a UR is provided', () => { + it('can be constructed with a UR of type `crypto-hdkey`', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + + expect(await keyring.serialize()).toStrictEqual({ + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: [], + }); + }); + + it('can be constructed with a UR of type `crypto-account`', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_CRYPTO_ACCOUNT_UR, + }); + + expect(await keyring.serialize()).toStrictEqual({ + ...ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: [], + }); + }); + + it('throws an error if the UR is a `crypto-account` but with no descriptors', () => { + const faultyUR = new CryptoAccount(Buffer.from('37b5eed4', 'hex'), []) + .toUREncoder(2000) + .nextPart(); + + expect( + () => + new QrKeyring({ + bridge: getMockBridge(), + ur: faultyUR, + }), + ).toThrow('No output descriptors found in CryptoAccount'); }); - expect(await keyring.serialize()).toStrictEqual({ - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, - accounts: [], + it('throws an error if the UR is not of type `crypto-hdkey` or `crypto-account`', () => { + expect( + () => + new QrKeyring({ + bridge: getMockBridge(), + ur: 'invalid-ur', + }), + ).toThrow('Invalid Scheme'); }); }); }); @@ -256,7 +329,7 @@ describe('QrKeyring', () => { const serialized = await keyring.serialize(); expect(serialized).toStrictEqual({ - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, accounts: [], indexes: {}, }); @@ -274,7 +347,7 @@ describe('QrKeyring', () => { const serialized = await keyring.serialize(); expect(serialized).toStrictEqual({ - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]], indexes: { [EXPECTED_ACCOUNTS[0]]: 0, @@ -305,11 +378,11 @@ describe('QrKeyring', () => { bridge: getMockBridge(), }); - await keyring.deserialize(SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS); + await keyring.deserialize(HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS); expect(await keyring.getAccounts()).toStrictEqual([]); expect(await getXPUBFromKeyring(keyring)).toStrictEqual( - SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS.xpub, + HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS.xpub, ); }); }); @@ -335,19 +408,48 @@ describe('QrKeyring', () => { }); describe('addAccounts', () => { - describe('when the keyring is paired with a device', () => { - it('returns new accounts added', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + describe.each([ + ['crypto-hdkey', KNOWN_HDKEY_UR, EXPECTED_ACCOUNTS], + [ + 'crypto-account', + KNOWN_CRYPTO_ACCOUNT_UR, + ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts as Hex[], + ], + ])( + 'when the keyring is paired with a device of type `%s`', + (_, ur, expectedAccounts) => { + it('returns new accounts added', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + + const accounts = await keyring.addAccounts(1); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toBe(expectedAccounts[0]); }); - const accounts = await keyring.addAccounts(1); + it('does not add accounts that already exist', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + const firstAddition = await keyring.addAccounts(1); + keyring.setAccountToUnlock(0); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toBe(EXPECTED_ACCOUNTS[0]); - }); + const secondAddition = await keyring.addAccounts(1); + expect(firstAddition).toStrictEqual(expectedAccounts.slice(0, 1)); + expect(secondAddition).toStrictEqual([]); + expect(await keyring.getAccounts()).toStrictEqual( + expectedAccounts.slice(0, 1), + ); + }); + }, + ); + + describe('when the keyring is paired with a device of type `crypto-hdkey`', () => { it('adds multiple accounts', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), @@ -368,7 +470,7 @@ describe('QrKeyring', () => { bridge: getMockBridge(), }); await keyring.deserialize({ - ...SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS, + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, accounts: EXPECTED_ACCOUNTS.slice(0, 3), }); @@ -385,21 +487,18 @@ describe('QrKeyring', () => { [EXPECTED_ACCOUNTS[2]]: 2, }); }); + }); - it('does not add accounts that already exist', async () => { + describe('when the keyring is paired with a device of type `crypto-account`', () => { + it('throws an error if the index is not included in the UR descriptors', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + ur: KNOWN_CRYPTO_ACCOUNT_UR, }); - const firstAddition = await keyring.addAccounts(1); - keyring.setAccountToUnlock(0); + keyring.setAccountToUnlock(1); - const secondAddition = await keyring.addAccounts(1); - - expect(firstAddition).toStrictEqual(EXPECTED_ACCOUNTS.slice(0, 1)); - expect(secondAddition).toStrictEqual([]); - expect(await keyring.getAccounts()).toStrictEqual( - EXPECTED_ACCOUNTS.slice(0, 1), + await expect(keyring.addAccounts(1)).rejects.toThrow( + 'Address not found for index 1', ); }); }); @@ -488,7 +587,7 @@ describe('QrKeyring', () => { }); expect(keyring.getName()).toBe( - SERIALIZED_QR_KEYRING_WITH_NO_ACCOUNTS.name, + HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS.name, ); }); }); @@ -536,8 +635,22 @@ describe('QrKeyring', () => { const keyring = new QrKeyring({ bridge: getMockBridge(), }); + expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme'); }); + + it('throws an error if the UR is not of type `crypto-hdkey` or `crypto-account`', () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + expect(() => + keyring.submitUR({ + type: 'invalid-type', + cbor: 'a0', + }), + ).toThrow('Unsupported UR type'); + }); }); describe('getFirstPage', () => { @@ -546,7 +659,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -564,7 +677,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -588,7 +701,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -606,7 +719,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -630,7 +743,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -648,7 +761,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -669,7 +782,7 @@ describe('QrKeyring', () => { const mockBridge = getMockBridge(); jest.spyOn(mockBridge, 'requestScan').mockResolvedValue({ type: 'crypto-hdkey', - cbor: KNOWN_CBOR, + cbor: KNOWN_HDKEY_CBOR, }); const keyring = new QrKeyring({ bridge: mockBridge, @@ -697,13 +810,13 @@ describe('QrKeyring', () => { const keyring = new QrKeyring({ bridge: getMockBridge(), }); - await keyring.deserialize(SERIALIZED_QR_KEYRING_WITH_ACCOUNTS); + await keyring.deserialize(HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS); // let's make sure we have an xpub set expect(await keyring.getAccounts()).toStrictEqual( - SERIALIZED_QR_KEYRING_WITH_ACCOUNTS.accounts, + HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts, ); expect(await getXPUBFromKeyring(keyring)).toStrictEqual( - SERIALIZED_QR_KEYRING_WITH_ACCOUNTS.xpub, + HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS.xpub, ); await keyring.forgetDevice(); @@ -726,51 +839,58 @@ describe('QrKeyring', () => { }); }); - describe('when the keyring is paired with a device', () => { - it('signs a transaction', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, - }); - mockBridgeScanResolution( - keyring.bridge, - '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', - ); - - const signedTx = await keyring.signTransaction( - EXPECTED_ACCOUNTS[0], - TRANSACTION, - ); - - expect(signedTx.r).toBeDefined(); - expect(signedTx.s).toBeDefined(); - expect(signedTx.v).toBeDefined(); - expect(signedTx.to).toStrictEqual(TRANSACTION.to); - expect(signedTx.nonce).toBe(TRANSACTION.nonce); - }); - - it('signs a legacy transaction', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + describe.each([ + ['crypto-hdkey', KNOWN_HDKEY_UR, EXPECTED_ACCOUNTS[0]], + [ + 'crypto-account', + KNOWN_CRYPTO_ACCOUNT_UR, + ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts?.[0] as Hex, + ], + ])( + 'when the keyring is paired with a device of type `%s`', + (_, ur, from) => { + it('signs a transaction', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + mockBridgeScanResolution( + keyring.bridge, + '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', + ); + + const signedTx = await keyring.signTransaction(from, TRANSACTION); + + expect(signedTx.r).toBeDefined(); + expect(signedTx.s).toBeDefined(); + expect(signedTx.v).toBeDefined(); + expect(signedTx.to).toStrictEqual(TRANSACTION.to); + expect(signedTx.nonce).toBe(TRANSACTION.nonce); + }); + + it('signs a legacy transaction', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + mockBridgeScanResolution( + keyring.bridge, + 'c28a6a8eeaedeef76bce7d1b2c255c6ee624feadd3b56797d88198c28853b25573221eb94f4ca5e2bce21d99c7c56823e22c7847064961f2e05d526037393fe701546d71', + ); + + const signedTx = await keyring.signTransaction( + EXPECTED_ACCOUNTS[0], + LEGACY_TRANSACTION, + ); + + expect(signedTx.r).toBeDefined(); + expect(signedTx.s).toBeDefined(); + expect(signedTx.v).toBeDefined(); + expect(signedTx.to).toStrictEqual(LEGACY_TRANSACTION.to); + expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce); }); - mockBridgeScanResolution( - keyring.bridge, - 'c28a6a8eeaedeef76bce7d1b2c255c6ee624feadd3b56797d88198c28853b25573221eb94f4ca5e2bce21d99c7c56823e22c7847064961f2e05d526037393fe701546d71', - ); - - const signedTx = await keyring.signTransaction( - EXPECTED_ACCOUNTS[0], - LEGACY_TRANSACTION, - ); - - expect(signedTx.r).toBeDefined(); - expect(signedTx.s).toBeDefined(); - expect(signedTx.v).toBeDefined(); - expect(signedTx.to).toStrictEqual(LEGACY_TRANSACTION.to); - expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce); - }); - }); + }, + ); }); describe('signTypedData', () => { @@ -786,28 +906,38 @@ describe('QrKeyring', () => { }); }); - describe('when the keyring is paired with a device', () => { - it('signs a typed data message', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + describe.each([ + ['crypto-hdkey', KNOWN_HDKEY_UR, EXPECTED_ACCOUNTS[0]], + [ + 'crypto-account', + KNOWN_CRYPTO_ACCOUNT_UR, + ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts?.[0] as Hex, + ], + ])( + 'when the keyring is paired with a device of type `%s`', + (_, ur, from) => { + it('signs a typed data message', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + mockBridgeScanResolution( + keyring.bridge, + '1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', + ); + + const signedMessage = await keyring.signTypedData( + from, + TYPED_MESSAGE, + ); + + expect(signedMessage).toBeDefined(); + expect(signedMessage).toBe( + '0x1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', + ); }); - mockBridgeScanResolution( - keyring.bridge, - '1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', - ); - - const signedMessage = await keyring.signTypedData( - EXPECTED_ACCOUNTS[0], - TYPED_MESSAGE, - ); - - expect(signedMessage).toBeDefined(); - expect(signedMessage).toBe( - '0x1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', - ); - }); - }); + }, + ); }); describe('signPersonalMessage', () => { @@ -823,27 +953,37 @@ describe('QrKeyring', () => { }); }); - describe('when the keyring is paired with a device', () => { - it('signs a personal message', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - ur: KNOWN_HDKEY_UR, + describe.each([ + ['crypto-hdkey', KNOWN_HDKEY_UR, EXPECTED_ACCOUNTS[0]], + [ + 'crypto-account', + KNOWN_CRYPTO_ACCOUNT_UR, + ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts?.[0] as Hex, + ], + ])( + 'when the keyring is paired with a device of type `%s`', + (_, ur, from) => { + it('signs a personal message', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + mockBridgeScanResolution( + keyring.bridge, + 'b1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', + ); + + const signedMessage = await keyring.signPersonalMessage( + from, + '0x1234', + ); + + expect(signedMessage).toBeDefined(); + expect(signedMessage).toBe( + '0xb1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', + ); }); - mockBridgeScanResolution( - keyring.bridge, - 'b1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', - ); - - const signedMessage = await keyring.signPersonalMessage( - EXPECTED_ACCOUNTS[0], - '0x1234', - ); - - expect(signedMessage).toBeDefined(); - expect(signedMessage).toBe( - '0xb1c3f8d2e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', - ); - }); - }); + }, + ); }); }); From 08b6fa06b22c8e5b54d519c30365527d3dfd1ed9 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Wed, 2 Jul 2025 15:23:38 +0200 Subject: [PATCH 28/46] test: move fixtures to separate file --- .../keyring-eth-qr/src/qr-keyring.test.ts | 212 ++---------------- packages/keyring-eth-qr/test/fixtures.ts | 202 +++++++++++++++++ packages/keyring-eth-qr/tsconfig.json | 2 +- 3 files changed, 220 insertions(+), 196 deletions(-) create mode 100644 packages/keyring-eth-qr/test/fixtures.ts diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 1bfda3018..6c91ea524 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,202 +1,24 @@ -import { Common } from '@ethereumjs/common'; -import { TransactionFactory } from '@ethereumjs/tx'; -import type { StoredKeyring } from '@keystonehq/base-eth-keyring'; -import { - CryptoAccount, - CryptoHDKey, - CryptoOutput, - ETHSignature, - ScriptExpressions, -} from '@keystonehq/bc-ur-registry-eth'; +import { CryptoAccount, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; +import type { Hex } from '@metamask/utils'; import * as uuid from 'uuid'; -import type { QrKeyringBridge, SerializedQrKeyringState } from '.'; +import type { QrKeyringBridge } from '.'; import { QrKeyring, QrScanRequestType } from '.'; -import { DeviceMode } from './device'; -import { Hex } from '@metamask/utils'; - -// This CBOR and the the following test fixtures are all derived from this SRP: -// abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about -const KNOWN_HDKEY_CBOR = - 'a503582102eae4b876a8696134b868f88cc2f51f715f2dbedb7446b8e6edf3d4541c4eb67b045820d882718b7a42806803eeb17f7483f20620611adb88fc943c898dc5aba94c281906d90130a20186182cf5183cf500f5021ad32e4508081ad32e45080972696d546f6b656e2d4163636f756e74203031'; - -const KNOWN_HDKEY: CryptoHDKey = CryptoHDKey.fromCBOR( - Buffer.from(KNOWN_HDKEY_CBOR, 'hex'), -); - -const KNOWN_HDKEY_UR = KNOWN_HDKEY.toUREncoder(2000).nextPart(); - -const KNOWN_CRYPTO_ACCOUNT = new CryptoAccount(Buffer.from('37b5eed4', 'hex'), [ - new CryptoOutput([ScriptExpressions.WITNESS_SCRIPT_HASH], KNOWN_HDKEY), -]); - -const KNOWN_CRYPTO_ACCOUNT_UR = - KNOWN_CRYPTO_ACCOUNT.toUREncoder(2000).nextPart(); - -const EXPECTED_ACCOUNTS = [ - '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', - '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', - '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', - '0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E', - '0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA', - '0xA40cFBFc8534FFC84E20a7d8bBC3729B26a35F6f', - '0xB191a13bfE648B61002F2e2135867015B71816a6', - '0x593814d3309e2dF31D112824F0bb5aa7Cb0D7d47', - '0xB14c391e2bf19E5a26941617ab546FA620A4f163', - '0x4C1C56443AbFe6dD33de31dAaF0a6E929DBc4971', -] as const; - -// Coming from an extension installation using `@keystonehq/metamask-airgapped-keyring` -// and with a paired QR-based Device configured with the above known SRP -const SERIALIZED_KEYSTONE_KEYRING: StoredKeyring = { - initialized: true, - accounts: [ - '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', - '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', - '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', - ], - keyringAccount: 'account.standard', - keyringMode: DeviceMode.HD, - name: 'imToken-Account 01', - version: 1, - xfp: '65174ca1', - xpub: 'xpub6DCoCpSuQZB2jawqnGMEPS63ePKWkwWPH4TU45Q7LPXWuNd8TMtVxRrgjtEshuqpK3mdhaWHPFsBngh5GFZaM6si3yZdUsT8ddYM3PwnATt', - hdPath: "m/44'/60'/0'", - childrenPath: '0/*', - indexes: { - '0x9858EfFD232B4033E47d90003D41EC34EcaEda94': 0, - '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0': 1, - '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A': 2, - }, - paths: {}, - // These last properties are not used by `@metamask/eth-qr-keyring` - currentAccount: 0, - page: 0, - perPage: 5, -}; - -const HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { - initialized: true, - name: 'imToken-Account 01', - keyringMode: DeviceMode.HD, - keyringAccount: KNOWN_HDKEY.getNote(), - xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), - xpub: KNOWN_HDKEY.getBip32Key(), - indexes: {}, - hdPath: "m/44'/60'/0'", - childrenPath: '0/*', -}; - -const HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { - ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, - accounts: EXPECTED_ACCOUNTS.slice(0, 3), - indexes: { - [EXPECTED_ACCOUNTS[0]]: 0, - [EXPECTED_ACCOUNTS[1]]: 1, - [EXPECTED_ACCOUNTS[2]]: 2, - }, -}; - -const ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS: SerializedQrKeyringState = { - initialized: true, - name: 'imToken-Account 01', - keyringMode: DeviceMode.ACCOUNT, - keyringAccount: KNOWN_HDKEY.getNote(), - xfp: '37b5eed4', - paths: { - '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': "M/44'/60'/0'", - }, - indexes: {}, -}; - -const ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS: SerializedQrKeyringState = { - ...ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, - accounts: ['0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3'], - indexes: { - '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': 0, - }, -}; - -const TRANSACTION = TransactionFactory.fromTxData({ - accessList: [], - chainId: '0x1', - data: '0x', - gasLimit: '0x5208', - maxFeePerGas: '0x2540be400', - maxPriorityFeePerGas: '0x3b9aca00', - nonce: '0x68', - to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', - value: '0x0', - type: 2, -}); - -const LEGACY_TRANSACTION = TransactionFactory.fromTxData( - { - chainId: '0xaa36a7', - data: '0x', - gasLimit: '0x5208', - gasPrice: '0x2540be400', - nonce: '0x68', - to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', - value: '0x0', - }, - { - common: new Common({ chain: 'sepolia' }), - }, -); - -const TYPED_MESSAGE = { - domain: { - chainId: 1, - name: 'Ether Mail', - verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', - version: '1', - salt: new TextEncoder().encode('hello'), - }, - message: { - contents: 'Hello, Bob!', - from: { - name: 'Cow', - wallets: [ - '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', - '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', - ], - }, - to: [ - { - name: 'Bob', - wallets: [ - '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', - '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', - '0xB0B0b0b0b0b0B000000000000000000000000000', - ], - }, - ], - }, - primaryType: 'Mail' as const, - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Group: [ - { name: 'name', type: 'string' }, - { name: 'members', type: 'Person[]' }, - ], - Mail: [ - { name: 'from', type: 'Person' }, - { name: 'to', type: 'Person[]' }, - { name: 'contents', type: 'string' }, - ], - Person: [ - { name: 'name', type: 'string' }, - { name: 'wallets', type: 'address[]' }, - ], - }, -}; +import { + ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS, + ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + EXPECTED_ACCOUNTS, + HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS, + HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + KNOWN_CRYPTO_ACCOUNT_UR, + KNOWN_HDKEY_CBOR, + KNOWN_HDKEY_UR, + LEGACY_TRANSACTION, + SERIALIZED_KEYSTONE_KEYRING, + TRANSACTION, + TYPED_MESSAGE, +} from '../test/fixtures'; /** * Get the xpub from a keyring. diff --git a/packages/keyring-eth-qr/test/fixtures.ts b/packages/keyring-eth-qr/test/fixtures.ts new file mode 100644 index 000000000..cd8232eda --- /dev/null +++ b/packages/keyring-eth-qr/test/fixtures.ts @@ -0,0 +1,202 @@ +/** + * fixtures.ts + * + * Contains test fixtures with predefined inputs and expected outputs + * for use in unit and integration tests. These fixtures provide + * deterministic and reusable test data to ensure test consistency. + * + * The constants in this file are all derived from this SRP: + * `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about` + */ + +import { Common } from '@ethereumjs/common'; +import { TransactionFactory } from '@ethereumjs/tx'; +import { + CryptoAccount, + CryptoHDKey, + CryptoOutput, + ScriptExpressions, +} from '@keystonehq/bc-ur-registry-eth'; + +import { DeviceMode } from '../src/device'; + +export const KNOWN_HDKEY_CBOR = + 'a503582102eae4b876a8696134b868f88cc2f51f715f2dbedb7446b8e6edf3d4541c4eb67b045820d882718b7a42806803eeb17f7483f20620611adb88fc943c898dc5aba94c281906d90130a20186182cf5183cf500f5021ad32e4508081ad32e45080972696d546f6b656e2d4163636f756e74203031'; + +export const KNOWN_HDKEY: CryptoHDKey = CryptoHDKey.fromCBOR( + Buffer.from(KNOWN_HDKEY_CBOR, 'hex'), +); + +export const KNOWN_HDKEY_UR = KNOWN_HDKEY.toUREncoder(2000).nextPart(); + +export const KNOWN_CRYPTO_ACCOUNT = new CryptoAccount( + Buffer.from('37b5eed4', 'hex'), + [new CryptoOutput([ScriptExpressions.WITNESS_SCRIPT_HASH], KNOWN_HDKEY)], +); + +export const KNOWN_CRYPTO_ACCOUNT_UR = + KNOWN_CRYPTO_ACCOUNT.toUREncoder(2000).nextPart(); + +export const EXPECTED_ACCOUNTS = [ + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', + '0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E', + '0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA', + '0xA40cFBFc8534FFC84E20a7d8bBC3729B26a35F6f', + '0xB191a13bfE648B61002F2e2135867015B71816a6', + '0x593814d3309e2dF31D112824F0bb5aa7Cb0D7d47', + '0xB14c391e2bf19E5a26941617ab546FA620A4f163', + '0x4C1C56443AbFe6dD33de31dAaF0a6E929DBc4971', +] as const; + +// Coming from an extension installation using `@keystonehq/metamask-airgapped-keyring` +// and with a paired QR-based Device configured with the above known SRP +export const SERIALIZED_KEYSTONE_KEYRING = { + initialized: true, + accounts: [ + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0', + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A', + ], + keyringAccount: 'account.standard', + keyringMode: 'hd', + name: 'imToken-Account 01', + version: 1, + xfp: '65174ca1', + xpub: 'xpub6DCoCpSuQZB2jawqnGMEPS63ePKWkwWPH4TU45Q7LPXWuNd8TMtVxRrgjtEshuqpK3mdhaWHPFsBngh5GFZaM6si3yZdUsT8ddYM3PwnATt', + hdPath: "m/44'/60'/0'", + childrenPath: '0/*', + indexes: { + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94': 0, + '0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0': 1, + '0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A': 2, + }, + paths: {}, + // These last properties are not used by `@metamask/eth-qr-keyring` + currentAccount: 0, + page: 0, + perPage: 5, +}; + +export const HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS = { + initialized: true, + name: 'imToken-Account 01', + keyringMode: DeviceMode.HD, + keyringAccount: KNOWN_HDKEY.getNote(), + xfp: KNOWN_HDKEY.getParentFingerprint()?.toString('hex'), + xpub: KNOWN_HDKEY.getBip32Key(), + indexes: {}, + hdPath: "m/44'/60'/0'", + childrenPath: '0/*', +} as const; + +export const HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS = { + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: EXPECTED_ACCOUNTS.slice(0, 3), + indexes: { + [EXPECTED_ACCOUNTS[0]]: 0, + [EXPECTED_ACCOUNTS[1]]: 1, + [EXPECTED_ACCOUNTS[2]]: 2, + }, +}; + +export const ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS = { + initialized: true, + name: 'imToken-Account 01', + keyringMode: DeviceMode.ACCOUNT, + keyringAccount: KNOWN_HDKEY.getNote(), + xfp: '37b5eed4', + paths: { + '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': "M/44'/60'/0'", + }, + indexes: {}, +}; + +export const ACCOUNT_SERIALIZED_KEYRING_WITH_ACCOUNTS = { + ...ACCOUNT_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: ['0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3'], + indexes: { + '0x2043858DA83bCD92Ae342C1bAaD4D5F5B5C328B3': 0, + }, +}; + +export const TRANSACTION = TransactionFactory.fromTxData({ + accessList: [], + chainId: '0x1', + data: '0x', + gasLimit: '0x5208', + maxFeePerGas: '0x2540be400', + maxPriorityFeePerGas: '0x3b9aca00', + nonce: '0x68', + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + value: '0x0', + type: 2, +}); + +export const LEGACY_TRANSACTION = TransactionFactory.fromTxData( + { + chainId: '0xaa36a7', + data: '0x', + gasLimit: '0x5208', + gasPrice: '0x2540be400', + nonce: '0x68', + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + value: '0x0', + }, + { + common: new Common({ chain: 'sepolia' }), + }, +); + +export const TYPED_MESSAGE = { + domain: { + chainId: 1, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1', + salt: new TextEncoder().encode('hello'), + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallets: [ + '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + ], + }, + to: [ + { + name: 'Bob', + wallets: [ + '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + '0xB0B0b0b0b0b0B000000000000000000000000000', + ], + }, + ], + }, + primaryType: 'Mail' as const, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Group: [ + { name: 'name', type: 'string' }, + { name: 'members', type: 'Person[]' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person[]' }, + { name: 'contents', type: 'string' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallets', type: 'address[]' }, + ], + }, +}; diff --git a/packages/keyring-eth-qr/tsconfig.json b/packages/keyring-eth-qr/tsconfig.json index 3b734c355..f6a8db2fb 100644 --- a/packages/keyring-eth-qr/tsconfig.json +++ b/packages/keyring-eth-qr/tsconfig.json @@ -4,6 +4,6 @@ "baseUrl": "./" }, "references": [{ "path": "../keyring-utils" }], - "include": ["./src"], + "include": ["./src", "./test"], "exclude": ["./dist/**/*"] } From 2078f0d1e696bddf67193b8cbc584323e30055b6 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 3 Jul 2025 15:10:10 +0200 Subject: [PATCH 29/46] refactor: move `Device` initialization to constructor --- packages/keyring-eth-qr/jest.config.js | 6 +- packages/keyring-eth-qr/src/device.ts | 118 +++++++----------- .../keyring-eth-qr/src/qr-keyring.test.ts | 56 +++++++-- packages/keyring-eth-qr/src/qr-keyring.ts | 57 ++++++--- 4 files changed, 138 insertions(+), 99 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 9d164753e..9a4645d3a 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 85.71, + branches: 91.8, functions: 100, - lines: 93.26, - statements: 93.39, + lines: 95.04, + statements: 95.14, }, }, }); diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index 9e29aa2dc..fbad3c2d8 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -121,6 +121,21 @@ export type IndexedAddress = { export type DeviceDetails = CommonDeviceDetails & (HDModeDeviceDetails | AccountModeDeviceDetails); +export type DeviceOptions = { + /** + * The requestScan function to scan the QR code + */ + requestScan: QrKeyringBridge['requestScan']; + /** + * The source of the device, which can be of type `DeviceDetails`, + * `string`, or `SerializedUR`. + * + * When a `string` or `SerializedUR` is provided, the Device will + * initialize itself from the UR. + */ + source: DeviceDetails | string | SerializedUR; +}; + /** * Get the fingerprint of the source CryptoAccount or CryptoHDKey * @@ -189,32 +204,23 @@ function readCryptoAccountOutputDescriptors(source: CryptoAccount): { export class Device { readonly #requestScan: QrKeyringBridge['requestScan']; - #pairedDevice?: DeviceDetails | undefined; - - constructor(requestScan: QrKeyringBridge['requestScan']) { - this.#requestScan = requestScan; - } + readonly #pairedDevice: DeviceDetails; /** - * Initialize the Device with a source. + * Create a new Device instance. * - * @param source - The account source, which can be a CryptoAccount, CryptoHDKey, or a UR string. + * @param options - The options for the Device, including the requestScan function + * and the source of the device details. + * @param options.requestScan - The function to request a scan of the QR code. + * @param options.source - The source of the device details, which can be a + * UR string, a `SerializedUR`, or a `DeviceDetails` object. */ - init(source: DeviceDetails | string | SerializedUR): void { - if (typeof source === 'string' || 'cbor' in source) { - this.#initFromUR(source); - } else { - this.#pairedDevice = source; - } - } - - /** - * Check if this class instance is paired with a device - * - * @returns True if the Device is initialized, false otherwise - */ - isInitialized(): boolean { - return this.#pairedDevice !== undefined; + constructor({ requestScan, source }: DeviceOptions) { + this.#requestScan = requestScan; + this.#pairedDevice = + typeof source === 'string' || 'cbor' in source + ? this.#deviceDetailsFromUR(source) + : source; } /** @@ -225,10 +231,6 @@ export class Device { * @throws Will throw an error if the source is not initialized */ addressFromIndex(index: number): Hex { - if (!this.#pairedDevice) { - throw new Error('Device not paired'); - } - if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { const address = Object.keys(this.#pairedDevice.paths)[index]; if (!address) { @@ -262,10 +264,6 @@ export class Device { * @returns The path of the address */ pathFromAddress(address: Hex): string { - if (!this.#pairedDevice) { - throw new Error('UR not initialized'); - } - const normalizedAddress = getChecksumAddress(add0x(address)); if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { @@ -289,10 +287,6 @@ export class Device { * @returns The index of the address */ indexFromAddress(address: Hex): number { - if (!this.#pairedDevice) { - throw new Error('UR not initialized'); - } - const cachedIndex = this.#pairedDevice.indexes[address]; if (cachedIndex !== undefined) { return Number(cachedIndex); @@ -344,21 +338,14 @@ export class Device { } /** - * Retrieve the details of the paired device, if any. + * Retrieve the details of the paired device. * - * @returns Thea paired device details, or undefined if not initialized + * @returns Thea paired device details */ - getDeviceDetails(): DeviceDetails | undefined { + getDeviceDetails(): DeviceDetails { return this.#pairedDevice; } - /** - * Clear the paired device details. - */ - clear(): void { - this.#pairedDevice = undefined; - } - /** * Sign a transaction. This is equivalent to the `eth_signTransaction` * Ethereum JSON-RPC method. See the Ethereum JSON-RPC API documentation for @@ -372,10 +359,6 @@ export class Device { address: Hex, transaction: TypedTransaction, ): Promise { - if (!this.#pairedDevice?.xfp) { - throw new Error('No device paired.'); - } - const dataType = transaction.type === TransactionType.Legacy ? DataType.transaction @@ -433,10 +416,6 @@ export class Device { address: Hex, data: TypedMessage, ): Promise { - if (!this.#pairedDevice?.xfp) { - throw new Error('No device paired.'); - } - const requestId = uuidv4(); const ethSignRequestUR = EthSignRequest.constructETHRequest( Buffer.from(JSON.stringify(data), 'utf8'), @@ -478,10 +457,6 @@ export class Device { * @returns The signed message. */ async signPersonalMessage(address: Hex, message: Hex): Promise { - if (!this.#pairedDevice?.xfp) { - throw new Error('No device paired.'); - } - const requestId = uuidv4(); const ethSignRequestUR = EthSignRequest.constructETHRequest( Buffer.from(remove0x(message), 'hex'), @@ -511,18 +486,19 @@ export class Device { } /** - * Set the paired device from a UR string + * Derive the device details from a UR string * * @param ur - The UR string to set the root account from + * @returns The device details derived from the UR */ - #initFromUR(ur: string | SerializedUR): void { + #deviceDetailsFromUR(ur: string | SerializedUR): DeviceDetails { const source = this.#decodeUR(ur); const fingerprint = getFingerprintFromSource(source); if (source instanceof CryptoAccount) { const { name, xfp, paths, keyringAccount } = readCryptoAccountOutputDescriptors(source); - this.#pairedDevice = { + return { keyringMode: DeviceMode.ACCOUNT, keyringAccount, name, @@ -530,19 +506,19 @@ export class Device { paths, indexes: {}, }; - } else { - const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; - this.#pairedDevice = { - keyringMode: DeviceMode.HD, - keyringAccount: getNote(), - name: getName(), - xfp: fingerprint, - hdPath: `m/${getOrigin().getPath()}`, - childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, - xpub: getBip32Key(), - indexes: {}, - }; } + + const { getBip32Key, getOrigin, getChildren, getName, getNote } = source; + return { + keyringMode: DeviceMode.HD, + keyringAccount: getNote(), + name: getName(), + xfp: fingerprint, + hdPath: `m/${getOrigin().getPath()}`, + childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH, + xpub: getBip32Key(), + indexes: {}, + }; } /** diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 6c91ea524..8aa14cdda 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -63,17 +63,23 @@ function getMockBridge(): QrKeyringBridge { * * @param bridge - The QrKeyringBridge instance to mock. * @param signature - The signature to return in the mocked scan resolution. + * @param requestId - Optional request ID to use in the signature UR. */ function mockBridgeScanResolution( bridge: QrKeyringBridge, signature: string, + requestId?: string | null, ): void { jest.spyOn(bridge, 'requestScan').mockImplementationOnce(async (request) => { const signatureUR = new ETHSignature( Buffer.from(signature, 'hex'), - Buffer.from( - Uint8Array.from(uuid.parse(request.request?.requestId ?? '')), - ), + requestId === null + ? undefined + : Buffer.from( + Uint8Array.from( + uuid.parse(requestId ?? request.request?.requestId ?? ''), + ), + ), ).toUR(); return { type: signatureUR.type, @@ -332,7 +338,7 @@ describe('QrKeyring', () => { }); await expect(keyring.addAccounts(1)).rejects.toThrow( - 'Device not paired', + 'No device paired.', ); }); }); @@ -440,13 +446,13 @@ describe('QrKeyring', () => { }); }); - describe('submitUR', () => { - it('initializes the QrKeyring with a UR', async () => { + describe('pairDevice', () => { + it('pairs a device to the QrKeyring with a UR', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), }); - keyring.submitUR(KNOWN_HDKEY_UR); + keyring.pairDevice(KNOWN_HDKEY_UR); expect(await getXPUBFromKeyring(keyring)).toStrictEqual( SERIALIZED_KEYSTONE_KEYRING.xpub, @@ -458,7 +464,7 @@ describe('QrKeyring', () => { bridge: getMockBridge(), }); - expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme'); + expect(() => keyring.pairDevice('invalid-ur')).toThrow('Invalid Scheme'); }); it('throws an error if the UR is not of type `crypto-hdkey` or `crypto-account`', () => { @@ -467,7 +473,7 @@ describe('QrKeyring', () => { }); expect(() => - keyring.submitUR({ + keyring.pairDevice({ type: 'invalid-type', cbor: 'a0', }), @@ -711,6 +717,38 @@ describe('QrKeyring', () => { expect(signedTx.to).toStrictEqual(LEGACY_TRANSACTION.to); expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce); }); + + it('throws an error if the signature scan `requestId` is wrong', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + mockBridgeScanResolution( + keyring.bridge, + '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', + '92fdd736-e03e-4650-9992-ff3c70f16f4e', + ); + + await expect( + keyring.signTransaction(from, TRANSACTION), + ).rejects.toThrow(/Signature request ID mismatch./u); + }); + + it('throws an error if the signature scan `requestId` is missing', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + mockBridgeScanResolution( + keyring.bridge, + '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', + null, + ); + + await expect( + keyring.signTransaction(from, TRANSACTION), + ).rejects.toThrow('Signature request ID is missing.'); + }); }, ); }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 033649e44..9c3b01aaf 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -1,7 +1,7 @@ import { type TypedTransaction, type TypedTxData } from '@ethereumjs/tx'; import type { TypedMessage, MessageTypes } from '@metamask/eth-sig-util'; import type { Keyring } from '@metamask/keyring-utils'; -import { type Hex, add0x, getChecksumAddress } from '@metamask/utils'; +import { type Hex, add0x, assert, getChecksumAddress } from '@metamask/utils'; import { type DeviceDetails, @@ -97,7 +97,7 @@ export class QrKeyring implements Keyring { readonly bridge: QrKeyringBridge; - readonly #device: Device; + #device?: Device | undefined; #accounts: Hex[] = []; @@ -108,10 +108,8 @@ export class QrKeyring implements Keyring { constructor(options: QrKeyringOptions) { this.bridge = options.bridge; - this.#device = new Device(this.bridge.requestScan.bind(this.bridge)); - if (options?.ur) { - this.submitUR(options.ur); + this.pairDevice(options.ur); } } @@ -121,7 +119,9 @@ export class QrKeyring implements Keyring { * @returns The serialized state */ async serialize(): Promise { - const deviceDetails = this.#device.getDeviceDetails(); + const deviceDetails = this.#device + ? this.#device.getDeviceDetails() + : undefined; if ( !deviceDetails || @@ -169,11 +169,14 @@ export class QrKeyring implements Keyring { async deserialize(state: SerializedQrKeyringState): Promise { if (!state.initialized) { this.#accounts = []; - this.#device.clear(); + this.#device = undefined; return; } - this.#device.init(state); + this.#device = new Device({ + requestScan: this.bridge.requestScan.bind(this.bridge), + source: state, + }); this.#accounts = (state.accounts ?? []).map(normalizeAddress); } @@ -184,6 +187,10 @@ export class QrKeyring implements Keyring { * @returns The accounts added */ async addAccounts(accountsToAdd: number): Promise { + if (!this.#device) { + throw new Error('No device paired.'); + } + const lastAccount = this.#accounts[this.#accounts.length - 1]; const startIndex = this.#accountToUnlock ?? @@ -228,12 +235,15 @@ export class QrKeyring implements Keyring { } /** - * Submits a CBOR encoded UR to the QrKeyring + * Pair a QR-based Hardware Device from a CBOR encoded UR to the QrKeyring * * @param ur - The CBOR encoded UR */ - submitUR(ur: string | SerializedUR): void { - this.#device.init(ur); + pairDevice(ur: string | SerializedUR): void { + this.#device = new Device({ + requestScan: this.bridge.requestScan.bind(this.bridge), + source: ur, + }); } /** @@ -252,8 +262,11 @@ export class QrKeyring implements Keyring { * @returns The name of the paired device or the keyring type. */ getName(): string { + if (!this.#device) { + return QR_KEYRING_TYPE; + } const source = this.#device.getDeviceDetails(); - return source?.name ?? QR_KEYRING_TYPE; + return source.name; } /** @@ -300,10 +313,13 @@ export class QrKeyring implements Keyring { * @returns The current page of accounts as an array of IndexedAddress objects. */ async getCurrentPage(): Promise { - if (!this.#device.isInitialized()) { + if (!this.#device) { await this.#scanAndInitialize(); } - + assert( + this.#device, + 'A device is expected to be paired before fetching accounts.', + ); return this.#device.getAddressesPage(this.#currentPage); } @@ -311,7 +327,7 @@ export class QrKeyring implements Keyring { * Clear the keyring state and forget any paired device or accounts. */ async forgetDevice(): Promise { - this.#device.clear(); + this.#device = undefined; this.#accounts = []; this.#accountToUnlock = undefined; this.#currentPage = 0; @@ -330,6 +346,9 @@ export class QrKeyring implements Keyring { address: Hex, transaction: TypedTransaction, ): Promise { + if (!this.#device) { + throw new Error('No device paired.'); + } return this.#device.signTransaction(address, transaction); } @@ -345,6 +364,9 @@ export class QrKeyring implements Keyring { address: Hex, data: TypedMessage, ): Promise { + if (!this.#device) { + throw new Error('No device paired.'); + } return this.#device.signTypedData(address, data); } @@ -361,6 +383,9 @@ export class QrKeyring implements Keyring { * @returns The signed message. */ async signPersonalMessage(address: Hex, message: Hex): Promise { + if (!this.#device) { + throw new Error('No device paired.'); + } return this.#device.signPersonalMessage(address, message); } @@ -369,7 +394,7 @@ export class QrKeyring implements Keyring { * scanned UR. */ async #scanAndInitialize(): Promise { - this.submitUR( + this.pairDevice( await this.bridge.requestScan({ type: QrScanRequestType.PAIR }), ); } From 261268a3e5f94bbadd1d16288c828394c6d2ccde Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 3 Jul 2025 15:21:55 +0200 Subject: [PATCH 30/46] remove unused devDependency --- packages/keyring-eth-qr/package.json | 1 - yarn.lock | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 8b13e82c4..84f82f5b4 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -60,7 +60,6 @@ }, "devDependencies": { "@ethereumjs/common": "^4.4.0", - "@keystonehq/base-eth-keyring": "^0.15.1", "@keystonehq/metamask-airgapped-keyring": "^0.15.2", "@lavamoat/allow-scripts": "^3.2.1", "@metamask/auto-changelog": "^3.4.4", diff --git a/yarn.lock b/yarn.lock index 6dba3140a..fd2bc1d44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1252,7 +1252,7 @@ __metadata: languageName: node linkType: hard -"@keystonehq/base-eth-keyring@npm:0.15.1, @keystonehq/base-eth-keyring@npm:^0.15.1": +"@keystonehq/base-eth-keyring@npm:0.15.1": version: 0.15.1 resolution: "@keystonehq/base-eth-keyring@npm:0.15.1" dependencies: @@ -1784,7 +1784,6 @@ __metadata: "@ethereumjs/rlp": "npm:^5.0.2" "@ethereumjs/tx": "npm:^5.4.0" "@ethereumjs/util": "npm:^9.1.0" - "@keystonehq/base-eth-keyring": "npm:^0.15.1" "@keystonehq/bc-ur-registry-eth": "npm:^0.19.1" "@keystonehq/metamask-airgapped-keyring": "npm:^0.15.2" "@lavamoat/allow-scripts": "npm:^3.2.1" From f88fa3943985e4c2ee1acc497b4d7c360284bfc2 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 3 Jul 2025 15:51:28 +0200 Subject: [PATCH 31/46] refactor: simplify `serialize()` --- packages/keyring-eth-qr/jest.config.js | 8 +++---- packages/keyring-eth-qr/src/qr-keyring.ts | 29 +++-------------------- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 9a4645d3a..9baf774e1 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -15,15 +15,15 @@ module.exports = merge(baseConfig, { displayName, // An array of regexp pattern strings used to skip coverage collection - coveragePathIgnorePatterns: ['./src/tests'], + coveragePathIgnorePatterns: [], // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 91.8, + branches: 91.66, functions: 100, - lines: 95.04, - statements: 95.14, + lines: 94.97, + statements: 95.07, }, }, }); diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 9c3b01aaf..9d19f95fc 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -131,33 +131,10 @@ export class QrKeyring implements Keyring { return getDefaultSerializedQrKeyringState(); } - const accounts = this.#accounts.slice(); - - if (deviceDetails.keyringMode === DeviceMode.HD) { - // These properties are only relevant for HD Keys - return { - initialized: true, - name: deviceDetails.name, - keyringMode: DeviceMode.HD, - keyringAccount: deviceDetails.keyringAccount, - xfp: deviceDetails.xfp, - xpub: deviceDetails.xpub, - hdPath: deviceDetails.hdPath, - childrenPath: deviceDetails.childrenPath, - accounts, - indexes: deviceDetails.indexes, - }; - } - // These properties are only relevant for Account Keys return { + ...deviceDetails, initialized: true, - name: deviceDetails.name, - keyringMode: DeviceMode.ACCOUNT, - keyringAccount: deviceDetails.keyringAccount, - xfp: deviceDetails.xfp, - paths: deviceDetails.paths, - accounts, - indexes: deviceDetails.indexes, + accounts: this.#accounts.slice(), }; } @@ -219,7 +196,7 @@ export class QrKeyring implements Keyring { * @returns The accounts in the QrKeyring */ async getAccounts(): Promise { - return Array.from(this.#accounts.values()); + return this.#accounts.slice(); } /** From 0e5165f4b1904f991049ddf49a4031bd1bfb8292 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 3 Jul 2025 16:14:13 +0200 Subject: [PATCH 32/46] refactor: `indexFromAddress` and `pathFromAddress` --- packages/keyring-eth-qr/jest.config.js | 6 +-- packages/keyring-eth-qr/src/device.ts | 39 +++++++++---------- .../keyring-eth-qr/src/qr-keyring.test.ts | 14 ++++++- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index 9baf774e1..e05cead58 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 91.66, + branches: 96.66, functions: 100, - lines: 94.97, - statements: 95.07, + lines: 98.99, + statements: 99.01, }, }, }); diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index fbad3c2d8..d8ee5cb08 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -274,7 +274,20 @@ export class Device { return path; } - const index = this.indexFromAddress(normalizedAddress); + let index = this.#pairedDevice.indexes[normalizedAddress]; + if (index === undefined) { + for (let i = 0; i < MAX_INDEX; i++) { + const derivedAddress = this.addressFromIndex(i); + if (derivedAddress === address) { + index = i; + break; + } + } + if (index === undefined) { + throw new Error(`Unknown address ${normalizedAddress}`); + } + } + return `${this.#pairedDevice.hdPath}/${this.#pairedDevice.childrenPath .replace('*', index.toString()) .replace(/\*/gu, '0')}`; @@ -292,28 +305,14 @@ export class Device { return Number(cachedIndex); } - if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) { - const path = this.#pairedDevice.paths[address]; - if (path === undefined) { - throw new Error(`Unknown address`); - } - - const index = path.split('/').pop(); - if (index === undefined) { - throw new Error(`Invalid path for address ${address}`); - } - - return Number(index); + const index = this.pathFromAddress(address).split('/').pop(); + if (index === undefined) { + throw new Error(`Invalid path for address ${address}`); } - for (let i = 0; i < MAX_INDEX; i++) { - const derivedAddress = this.addressFromIndex(i); - if (derivedAddress === address) { - return i; - } - } + this.#pairedDevice.indexes[address] = Number(index); - throw new Error(`Address ${address} not found`); + return Number(index); } /** diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 8aa14cdda..824612ea4 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -1,6 +1,6 @@ import { CryptoAccount, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; -import type { Hex } from '@metamask/utils'; +import { add0x, type Hex } from '@metamask/utils'; import * as uuid from 'uuid'; import type { QrKeyringBridge } from '.'; @@ -718,6 +718,18 @@ describe('QrKeyring', () => { expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce); }); + it('throws an error if the address is not found in the keyring', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur, + }); + const unknownAddress = add0x('0'.repeat(40)); + + await expect( + keyring.signTransaction(unknownAddress, TRANSACTION), + ).rejects.toThrow(`Unknown address ${unknownAddress}`); + }); + it('throws an error if the signature scan `requestId` is wrong', async () => { const keyring = new QrKeyring({ bridge: getMockBridge(), From 5ab4c618aa828680375c54d17557ab952a9da0a3 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 3 Jul 2025 16:16:15 +0200 Subject: [PATCH 33/46] set package version to `0.0.0` --- packages/keyring-eth-qr/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 84f82f5b4..aaf83ee34 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/eth-qr-keyring", - "version": "1.0.0", + "version": "0.0.0", "description": "A simple standard interface for a series of Ethereum private keys.", "keywords": [ "ethereum", From 423f79c03c220a7d217114980ba1725f8ea11326 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 8 Jul 2025 10:57:59 +0200 Subject: [PATCH 34/46] refactor: remove `indexFromAccount` --- packages/keyring-eth-qr/jest.config.js | 6 ++--- packages/keyring-eth-qr/src/device.ts | 22 ------------------ .../keyring-eth-qr/src/qr-keyring.test.ts | 23 ------------------- packages/keyring-eth-qr/src/qr-keyring.ts | 8 ++----- 4 files changed, 5 insertions(+), 54 deletions(-) diff --git a/packages/keyring-eth-qr/jest.config.js b/packages/keyring-eth-qr/jest.config.js index e05cead58..7eb95a87d 100644 --- a/packages/keyring-eth-qr/jest.config.js +++ b/packages/keyring-eth-qr/jest.config.js @@ -20,10 +20,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 96.66, + branches: 100, functions: 100, - lines: 98.99, - statements: 99.01, + lines: 100, + statements: 100, }, }, }); diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index d8ee5cb08..edc244990 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -293,28 +293,6 @@ export class Device { .replace(/\*/gu, '0')}`; } - /** - * Retrieve the index of an address from the source - * - * @param address - The normalized address to retrieve the index for - * @returns The index of the address - */ - indexFromAddress(address: Hex): number { - const cachedIndex = this.#pairedDevice.indexes[address]; - if (cachedIndex !== undefined) { - return Number(cachedIndex); - } - - const index = this.pathFromAddress(address).split('/').pop(); - if (index === undefined) { - throw new Error(`Invalid path for address ${address}`); - } - - this.#pairedDevice.indexes[address] = Number(index); - - return Number(index); - } - /** * Get a page of addresses derived from the source. * diff --git a/packages/keyring-eth-qr/src/qr-keyring.test.ts b/packages/keyring-eth-qr/src/qr-keyring.test.ts index 824612ea4..ed8ad011c 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.test.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -292,29 +292,6 @@ describe('QrKeyring', () => { EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), ); }); - - it('recovers indexes if they are not present in the serialized state', async () => { - const keyring = new QrKeyring({ - bridge: getMockBridge(), - }); - await keyring.deserialize({ - ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, - accounts: EXPECTED_ACCOUNTS.slice(0, 3), - }); - - // This function call will create the third account starting - // from the last account's index. - // This will require the keyring to recover indexes for the first two accounts - await keyring.addAccounts(1); - - const serialized = await keyring.serialize(); - expect(await keyring.getAccounts()).toHaveLength(3); - expect('indexes' in serialized && serialized.indexes).toStrictEqual({ - [EXPECTED_ACCOUNTS[0]]: 0, - [EXPECTED_ACCOUNTS[1]]: 1, - [EXPECTED_ACCOUNTS[2]]: 2, - }); - }); }); describe('when the keyring is paired with a device of type `crypto-account`', () => { diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index 9d19f95fc..b21b72ce3 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -168,14 +168,10 @@ export class QrKeyring implements Keyring { throw new Error('No device paired.'); } - const lastAccount = this.#accounts[this.#accounts.length - 1]; - const startIndex = - this.#accountToUnlock ?? - (lastAccount ? this.#device.indexFromAddress(lastAccount) : 0); const newAccounts: Hex[] = []; for (let i = 0; i < accountsToAdd; i++) { - const index = startIndex + i; + const index = this.#accountToUnlock ?? this.#accounts.length + i; const address = this.#device.addressFromIndex(index); if (this.#accounts.includes(address)) { @@ -184,9 +180,9 @@ export class QrKeyring implements Keyring { this.#accounts.push(address); newAccounts.push(address); + this.setAccountToUnlock(index + 1); } - this.#accountToUnlock = startIndex + accountsToAdd; return newAccounts; } From ebd35b47cc3703524431271c848ef5e43cc3f259 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 8 Jul 2025 10:59:19 +0200 Subject: [PATCH 35/46] fix: compare to `normalizedAddress` in `pathFromAddress` --- packages/keyring-eth-qr/src/device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index edc244990..f98d01b35 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -278,7 +278,7 @@ export class Device { if (index === undefined) { for (let i = 0; i < MAX_INDEX; i++) { const derivedAddress = this.addressFromIndex(i); - if (derivedAddress === address) { + if (derivedAddress === normalizedAddress) { index = i; break; } From a9b0f29c36e4279e0f2743060d3295f3a50d4c2d Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 8 Jul 2025 11:00:36 +0200 Subject: [PATCH 36/46] fix changelog validate script --- packages/keyring-eth-qr/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index aaf83ee34..8078551a8 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -38,8 +38,8 @@ "build:clean": "rimraf dist && yarn build", "build:docs": "typedoc", "build:force": "tsc --build tsconfig.build.json --force", - "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-simple-keyring", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-simple-keyring", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-qr-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-qr-keyring", "publish:preview": "yarn npm publish --tag preview", "sample": "ts-node src/sample.ts", "test": "jest", From 36c61e71d742da4611a87a5ba5d9e5b033735b5a Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 15 Jul 2025 12:57:21 +0200 Subject: [PATCH 37/46] feat: add `QrKeyringDeferredPromiseBridge` --- packages/keyring-eth-qr/package.json | 1 + ...qr-keyring-deferred-promise-bridge.test.ts | 90 ++++++++++++++++++ .../src/qr-keyring-deferred-promise-bridge.ts | 95 +++++++++++++++++++ yarn.lock | 1 + 4 files changed, 187 insertions(+) create mode 100644 packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.test.ts create mode 100644 packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts diff --git a/packages/keyring-eth-qr/package.json b/packages/keyring-eth-qr/package.json index 8078551a8..558dfd317 100644 --- a/packages/keyring-eth-qr/package.json +++ b/packages/keyring-eth-qr/package.json @@ -55,6 +55,7 @@ "@metamask/eth-sig-util": "^8.2.0", "@metamask/keyring-utils": "workspace:^", "@metamask/utils": "^11.1.0", + "async-mutex": "^0.5.0", "hdkey": "^2.1.0", "uuid": "^9.0.1" }, diff --git a/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.test.ts b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.test.ts new file mode 100644 index 000000000..8db9f7ab9 --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.test.ts @@ -0,0 +1,90 @@ +import { type QrScanRequest, QrScanRequestType } from './qr-keyring'; +import { QrKeyringDeferredPromiseBridge } from './qr-keyring-deferred-promise-bridge'; + +describe('QrKeyringDeferredPromiseBridge', () => { + describe('requestScan', () => { + it('calls `onScanRequested` if any', async () => { + const request: QrScanRequest = { + type: QrScanRequestType.PAIR, + }; + const onScanRequested = jest.fn(); + const bridge = new QrKeyringDeferredPromiseBridge({ + onScanRequested, + }); + onScanRequested.mockImplementation(() => { + bridge.resolvePendingScan({ + type: 'test', + cbor: 'testData', + }); + }); + + await bridge.requestScan(request); + + expect(onScanRequested).toHaveBeenCalledWith(request); + }); + }); + + describe('resolvePendingScan', () => { + it('resolves the pending scan with the given result', async () => { + const request: QrScanRequest = { + type: QrScanRequestType.PAIR, + }; + const result = { + type: 'test', + cbor: 'testData', + }; + const onScanRequested = jest.fn(); + const onScanResolved = jest.fn(); + const bridge = new QrKeyringDeferredPromiseBridge({ + onScanRequested, + onScanResolved, + }); + onScanRequested.mockImplementation(() => { + bridge.resolvePendingScan(result); + }); + + const pendingScan = await bridge.requestScan(request); + + expect(onScanResolved).toHaveBeenCalledWith(result); + expect(pendingScan).toStrictEqual(result); + }); + + it('throws an error if no pending scan exists', () => { + const bridge = new QrKeyringDeferredPromiseBridge(); + + expect(() => { + bridge.resolvePendingScan({ type: 'test', cbor: 'testData' }); + }).toThrow('No pending scan to resolve.'); + }); + }); + + describe('rejectPendingScan', () => { + it('calls `onScanRejected` if any', async () => { + const error = new Error('Test error'); + const onScanRejected = jest.fn(); + const onScanRequested = jest.fn(); + const bridge = new QrKeyringDeferredPromiseBridge({ + onScanRequested, + onScanRejected, + }); + onScanRequested.mockImplementation(() => { + bridge.rejectPendingScan(error); + }); + + await expect( + bridge.requestScan({ + type: QrScanRequestType.PAIR, + }), + ).rejects.toThrow(error); + expect(onScanRejected).toHaveBeenCalledWith(error); + }); + + it('throws an error if no pending scan exists', () => { + const bridge = new QrKeyringDeferredPromiseBridge(); + + expect(() => { + bridge.rejectPendingScan(new Error('Test error')); + }).toThrow('No pending scan to reject.'); + }); + }); +}); diff --git a/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts new file mode 100644 index 000000000..6b9a25375 --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts @@ -0,0 +1,95 @@ +import { type DeferredPromise, createDeferredPromise } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +import type { + QrKeyringBridge, + QrScanRequest, + SerializedUR, +} from './qr-keyring'; + +export type QrKeyringScannerOptions = { + /** + * Callback invoked when a scan request is made. + * This can be used to trigger the actual scanning process. + */ + onScanRequested?: (request: QrScanRequest) => void; + /** + * Callback invoked when a scan is successfully resolved. + * This can be used to handle the result of the scan. + */ + onScanResolved?: (result: SerializedUR) => void; + /** + * Callback invoked when a scan is rejected with an error. + * This can be used to handle errors that occur during the scan. + */ + onScanRejected?: (error: Error) => void; +}; + +/** + * A bridge that turns the scan request into a deferred promise, allowing + * the consumer to control the resolution and rejection of the scan. + */ +export class QrKeyringDeferredPromiseBridge implements QrKeyringBridge { + readonly #lock = new Mutex(); + + readonly #onScanRequested?: ((request: QrScanRequest) => void) | undefined; + + readonly #onScanResolved?: ((result: SerializedUR) => void) | undefined; + + readonly #onScanRejected?: ((error: Error) => void) | undefined; + + #pendingScan?: DeferredPromise | null; + + constructor({ + onScanRequested, + onScanResolved, + onScanRejected, + }: QrKeyringScannerOptions = {}) { + this.#onScanRequested = onScanRequested; + this.#onScanResolved = onScanResolved; + this.#onScanRejected = onScanRejected; + } + + /** + * Request a QR code scan, obtaining a CBOR and a type as response. + * + * @param request - The type of QR scan request. + * @returns A promise that resolves with the scanned data as a serialized UR. + */ + async requestScan(request: QrScanRequest): Promise { + return this.#lock.runExclusive(async () => { + const deferredPromise = createDeferredPromise(); + this.#pendingScan = deferredPromise; + this.#onScanRequested?.(request); + return deferredPromise.promise; + }); + } + + /** + * Resolve the pending scan with the given result. + * + * @param result - The scanned data as a serialized UR. + */ + resolvePendingScan(result: SerializedUR): void { + if (!this.#pendingScan) { + throw new Error('No pending scan to resolve.'); + } + this.#pendingScan.resolve(result); + this.#pendingScan = null; + this.#onScanResolved?.(result); + } + + /** + * Reject the pending scan with the given error. + * + * @param error - The error to reject the scan with. + */ + rejectPendingScan(error: Error): void { + if (!this.#pendingScan) { + throw new Error('No pending scan to reject.'); + } + this.#pendingScan.reject(error); + this.#pendingScan = null; + this.#onScanRejected?.(error); + } +} diff --git a/yarn.lock b/yarn.lock index d312d552b..8179b3e3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1732,6 +1732,7 @@ __metadata: "@types/hdkey": "npm:^2.0.1" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.12.12" + async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" depcheck: "npm:^1.4.7" hdkey: "npm:^2.1.0" From b39e51b7c517acc22a687cd0d1068515b6669ef7 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 15 Jul 2025 13:01:07 +0200 Subject: [PATCH 38/46] fix: add missing export --- packages/keyring-eth-qr/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts index 0acd0dc8d..0ff7acaff 100644 --- a/packages/keyring-eth-qr/src/index.ts +++ b/packages/keyring-eth-qr/src/index.ts @@ -1,2 +1,3 @@ export * from './qr-keyring-scanner-bridge'; +export * from './qr-keyring-deferred-promise-bridge'; export * from './qr-keyring'; From d2e69965779cfc349c02779dfee831efa2caffb5 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Tue, 15 Jul 2025 13:10:53 +0200 Subject: [PATCH 39/46] fix: setAccountToUnlock incremented also if account exists --- packages/keyring-eth-qr/src/qr-keyring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring.ts b/packages/keyring-eth-qr/src/qr-keyring.ts index b21b72ce3..f821ba975 100644 --- a/packages/keyring-eth-qr/src/qr-keyring.ts +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -173,6 +173,7 @@ export class QrKeyring implements Keyring { for (let i = 0; i < accountsToAdd; i++) { const index = this.#accountToUnlock ?? this.#accounts.length + i; const address = this.#device.addressFromIndex(index); + this.setAccountToUnlock(index + 1); if (this.#accounts.includes(address)) { continue; @@ -180,7 +181,6 @@ export class QrKeyring implements Keyring { this.#accounts.push(address); newAccounts.push(address); - this.setAccountToUnlock(index + 1); } return newAccounts; From 63d4b143653f15057a1d7f14e2a35343d5f1a7f6 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 11:25:25 +0200 Subject: [PATCH 40/46] refactor: remove type assertion --- packages/keyring-eth-qr/src/device.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index f98d01b35..7e30e8a99 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -341,15 +341,14 @@ export class Device { ? DataType.transaction : DataType.typedTransaction; - const messageToSign = Buffer.from( - transaction.type === TransactionType.Legacy - ? RLP.encode(transaction.getMessageToSign()) - : (transaction as FeeMarketEIP1559Transaction).getMessageToSign(), + const messageToSign = transaction.getMessageToSign(); + const rawTransaction = Buffer.from( + Array.isArray(messageToSign) ? RLP.encode(messageToSign) : messageToSign, ); const requestId = uuidv4(); const ethSignRequestUR = EthSignRequest.constructETHRequest( - messageToSign, + rawTransaction, dataType, this.pathFromAddress(address), this.#pairedDevice.xfp, From 838e8babec5996f99f0350b30bf48e25c2073be1 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 11:29:42 +0200 Subject: [PATCH 41/46] fix lint --- packages/keyring-eth-qr/src/device.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/keyring-eth-qr/src/device.ts b/packages/keyring-eth-qr/src/device.ts index 7e30e8a99..67ff88012 100644 --- a/packages/keyring-eth-qr/src/device.ts +++ b/packages/keyring-eth-qr/src/device.ts @@ -3,7 +3,6 @@ import { type TypedTransaction, TransactionType, type TypedTxData, - type FeeMarketEIP1559Transaction, TransactionFactory, } from '@ethereumjs/tx'; import { publicToAddress } from '@ethereumjs/util'; From 2aaa97f3d84c76adbd42dc75c17596eae58b68b2 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 11:45:21 +0200 Subject: [PATCH 42/46] refactor: rename `QrKeyringDeferredPromiseBridgeOptions` --- .../keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts index 6b9a25375..be520d263 100644 --- a/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts +++ b/packages/keyring-eth-qr/src/qr-keyring-deferred-promise-bridge.ts @@ -7,7 +7,7 @@ import type { SerializedUR, } from './qr-keyring'; -export type QrKeyringScannerOptions = { +export type QrKeyringDeferredPromiseBridgeOptions = { /** * Callback invoked when a scan request is made. * This can be used to trigger the actual scanning process. @@ -44,7 +44,7 @@ export class QrKeyringDeferredPromiseBridge implements QrKeyringBridge { onScanRequested, onScanResolved, onScanRejected, - }: QrKeyringScannerOptions = {}) { + }: QrKeyringDeferredPromiseBridgeOptions = {}) { this.#onScanRequested = onScanRequested; this.#onScanResolved = onScanResolved; this.#onScanRejected = onScanRejected; From 7bc40469abd75a65301d3681465a7a74bc93a81f Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 11:45:36 +0200 Subject: [PATCH 43/46] refactor: use named exports --- packages/keyring-eth-qr/src/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts index 0ff7acaff..66b00478f 100644 --- a/packages/keyring-eth-qr/src/index.ts +++ b/packages/keyring-eth-qr/src/index.ts @@ -1,3 +1,18 @@ -export * from './qr-keyring-scanner-bridge'; -export * from './qr-keyring-deferred-promise-bridge'; -export * from './qr-keyring'; +export { + QrKeyringScannerBridge, + QrKeyringScannerBridgeOptions, +} from './qr-keyring-scanner-bridge'; +export { + QrKeyringDeferredPromiseBridge, + QrKeyringDeferredPromiseBridgeOptions, +} from './qr-keyring-deferred-promise-bridge'; +export { + QrKeyring, + QrScanRequest, + QrKeyringBridge, + QrKeyringOptions, + QrSignatureRequest, + QrScanRequestType, + SerializedQrKeyringState, + QR_KEYRING_TYPE, +} from './qr-keyring'; From a694085755bcc2822953742fc29035b4e4cfd001 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 12:26:58 +0200 Subject: [PATCH 44/46] use `export type` for type named exports --- packages/keyring-eth-qr/src/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/keyring-eth-qr/src/index.ts b/packages/keyring-eth-qr/src/index.ts index 66b00478f..1d8a2a00d 100644 --- a/packages/keyring-eth-qr/src/index.ts +++ b/packages/keyring-eth-qr/src/index.ts @@ -1,18 +1,18 @@ export { QrKeyringScannerBridge, - QrKeyringScannerBridgeOptions, + type QrKeyringScannerBridgeOptions, } from './qr-keyring-scanner-bridge'; export { QrKeyringDeferredPromiseBridge, - QrKeyringDeferredPromiseBridgeOptions, + type QrKeyringDeferredPromiseBridgeOptions, } from './qr-keyring-deferred-promise-bridge'; export { QrKeyring, - QrScanRequest, - QrKeyringBridge, - QrKeyringOptions, - QrSignatureRequest, - QrScanRequestType, - SerializedQrKeyringState, QR_KEYRING_TYPE, + QrScanRequestType, + type QrScanRequest, + type QrKeyringBridge, + type QrKeyringOptions, + type QrSignatureRequest, + type SerializedQrKeyringState, } from './qr-keyring'; From 753e26ed94459d2ef0430484151dc310da227f13 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 12:35:51 +0200 Subject: [PATCH 45/46] update `cross-spawn` to `7.0.6` --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 564b7cf05..773e61620 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5857,13 +5857,13 @@ __metadata: linkType: hard "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" dependencies: path-key: "npm:^3.1.0" shebang-command: "npm:^2.0.0" which: "npm:^2.0.1" - checksum: 10/e1a13869d2f57d974de0d9ef7acbf69dc6937db20b918525a01dacb5032129bd552d290d886d981e99f1b624cb03657084cc87bd40f115c07ecf376821c729ce + checksum: 10/0d52657d7ae36eb130999dffff1168ec348687b48dd38e2ff59992ed916c88d328cf1d07ff4a4a10bc78de5e1c23f04b306d569e42f7a2293915c081e4dfee86 languageName: node linkType: hard From 5a3b4bced00074ec598d5541b74e9084a6123ce2 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 11 Aug 2025 14:57:13 +0200 Subject: [PATCH 46/46] update changelog --- packages/keyring-eth-qr/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/keyring-eth-qr/CHANGELOG.md b/packages/keyring-eth-qr/CHANGELOG.md index f4eb4815c..1a8c08392 100644 --- a/packages/keyring-eth-qr/CHANGELOG.md +++ b/packages/keyring-eth-qr/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#60](https://github.com/MetaMask/accounts/pull/60)) + [Unreleased]: https://github.com/MetaMask/accounts/