diff --git a/README.md b/README.md index 4e698629c..0b062de27 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository contains the following packages [^fn1]: - [`@metamask/account-api`](packages/account-api) - [`@metamask/eth-hd-keyring`](packages/keyring-eth-hd) - [`@metamask/eth-ledger-bridge-keyring`](packages/keyring-eth-ledger-bridge) +- [`@metamask/eth-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) @@ -39,6 +40,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"]); @@ -52,6 +54,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/CHANGELOG.md b/packages/keyring-eth-qr/CHANGELOG.md new file mode 100644 index 000000000..1a8c08392 --- /dev/null +++ b/packages/keyring-eth-qr/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release ([#60](https://github.com/MetaMask/accounts/pull/60)) + +[Unreleased]: https://github.com/MetaMask/accounts/ 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..7eb95a87d --- /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: [], + + // 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..558dfd317 --- /dev/null +++ b/packages/keyring-eth-qr/package.json @@ -0,0 +1,97 @@ +{ + "name": "@metamask/eth-qr-keyring", + "version": "0.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" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "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-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", + "test:clean": "jest --clearCache", + "test:verbose": "jest --verbose", + "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", + "async-mutex": "^0.5.0", + "hdkey": "^2.1.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@ethereumjs/common": "^4.4.0", + "@keystonehq/metamask-airgapped-keyring": "^0.15.2", + "@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": "~5.6.3" + }, + "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, + "@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/device.ts b/packages/keyring-eth-qr/src/device.ts new file mode 100644 index 000000000..67ff88012 --- /dev/null +++ b/packages/keyring-eth-qr/src/device.ts @@ -0,0 +1,564 @@ +import { RLP } from '@ethereumjs/rlp'; +import { + type TypedTransaction, + TransactionType, + type TypedTxData, + 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); + +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 + * + * @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']; + + readonly #pairedDevice: DeviceDetails; + + /** + * Create a new Device instance. + * + * @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. + */ + constructor({ requestScan, source }: DeviceOptions) { + this.#requestScan = requestScan; + this.#pairedDevice = + typeof source === 'string' || 'cbor' in source + ? this.#deviceDetailsFromUR(source) + : source; + } + + /** + * 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.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 { + 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; + } + + let index = this.#pairedDevice.indexes[normalizedAddress]; + if (index === undefined) { + for (let i = 0; i < MAX_INDEX; i++) { + const derivedAddress = this.addressFromIndex(i); + if (derivedAddress === normalizedAddress) { + 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')}`; + } + + /** + * 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. + * + * @returns Thea paired device details + */ + getDeviceDetails(): DeviceDetails { + return this.#pairedDevice; + } + + /** + * 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 dataType = + transaction.type === TransactionType.Legacy + ? DataType.transaction + : DataType.typedTransaction; + + const messageToSign = transaction.getMessageToSign(); + const rawTransaction = Buffer.from( + Array.isArray(messageToSign) ? RLP.encode(messageToSign) : messageToSign, + ); + + const requestId = uuidv4(); + const ethSignRequestUR = EthSignRequest.constructETHRequest( + rawTransaction, + 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 { + 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 { + 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'), + ); + } + + /** + * 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 + */ + #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); + return { + keyringMode: DeviceMode.ACCOUNT, + keyringAccount, + name, + xfp, + paths, + 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: {}, + }; + } + + /** + * 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/index.ts b/packages/keyring-eth-qr/src/index.ts new file mode 100644 index 000000000..1d8a2a00d --- /dev/null +++ b/packages/keyring-eth-qr/src/index.ts @@ -0,0 +1,18 @@ +export { + QrKeyringScannerBridge, + type QrKeyringScannerBridgeOptions, +} from './qr-keyring-scanner-bridge'; +export { + QrKeyringDeferredPromiseBridge, + type QrKeyringDeferredPromiseBridgeOptions, +} from './qr-keyring-deferred-promise-bridge'; +export { + QrKeyring, + QR_KEYRING_TYPE, + QrScanRequestType, + type QrScanRequest, + type QrKeyringBridge, + type QrKeyringOptions, + type QrSignatureRequest, + type SerializedQrKeyringState, +} from './qr-keyring'; 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..be520d263 --- /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 QrKeyringDeferredPromiseBridgeOptions = { + /** + * 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, + }: QrKeyringDeferredPromiseBridgeOptions = {}) { + 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/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-scanner-bridge.ts b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts new file mode 100644 index 000000000..6029ff398 --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring-scanner-bridge.ts @@ -0,0 +1,37 @@ +import type { + QrKeyringBridge, + QrScanRequest, + SerializedUR, +} from './qr-keyring'; + +/** + * Options for the QrKeyringScannerBridge. + */ +export type QrKeyringScannerBridgeOptions = { + /** + * The function that the bridge will use to initiate a QR scan request. + */ + requestScan: (request: QrScanRequest) => Promise; +}; + +/** + * 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']; + + constructor({ requestScan }: QrKeyringScannerBridgeOptions) { + this.#requestScan = requestScan; + } + + /** + * Request a QR code scan, obtaining a CBOR and a type as response. + * + * @param request - The type of QR scan request. + * @returns The scanned data as a serialized UR. + */ + async requestScan(request: QrScanRequest): Promise { + return this.#requestScan(request); + } +} 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..ed8ad011c --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring.test.ts @@ -0,0 +1,838 @@ +import { CryptoAccount, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; +import { MetaMaskKeyring as KeystoneKeyring } from '@keystonehq/metamask-airgapped-keyring'; +import { add0x, type Hex } from '@metamask/utils'; +import * as uuid from 'uuid'; + +import type { QrKeyringBridge } from '.'; +import { QrKeyring, QrScanRequestType } from '.'; +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. + * + * @param keyring - The keyring to get the xpub from. + * @returns The xpub of the keyring. + */ +async function getXPUBFromKeyring( + keyring: QrKeyring | KeystoneKeyring, +): Promise { + const serialized = await keyring.serialize(); + if (!('xpub' in serialized)) { + return undefined; + } + return serialized.xpub; +} + +/** + * 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; +} + +/** + * Get a mock bridge for the QrKeyring. + * + * @returns A mock bridge with a requestScan method. + */ +function getMockBridge(): QrKeyringBridge { + return { + requestScan: jest.fn(), + }; +} + +/** + * 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. + * @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'), + requestId === null + ? undefined + : Buffer.from( + Uint8Array.from( + uuid.parse(requestId ?? 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', () => { + const keyring = new QrKeyring({ bridge: getMockBridge() }); + expect(keyring).toBeInstanceOf(QrKeyring); + }); + + 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'); + }); + + 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'); + }); + }); + }); + + describe('serialize', () => { + describe('when the QrKeyring has no accounts', () => { + it('returns the serialized state', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + + const serialized = await keyring.serialize(); + + expect(serialized).toStrictEqual({ + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: [], + indexes: {}, + }); + }); + }); + + describe('when the QrKeyring has accounts', () => { + it('returns the serialized state including added accounts', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + await keyring.addAccounts(2); + + const serialized = await keyring.serialize(); + + expect(serialized).toStrictEqual({ + ...HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS, + accounts: [EXPECTED_ACCOUNTS[0], EXPECTED_ACCOUNTS[1]], + indexes: { + [EXPECTED_ACCOUNTS[0]]: 0, + [EXPECTED_ACCOUNTS[1]]: 1, + }, + }); + }); + }); + }); + + describe('deserialize', () => { + describe('when deserializing an empty state', () => { + it('deserializes the state with no accounts', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + 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({ + bridge: getMockBridge(), + }); + + await keyring.deserialize(HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS); + + expect(await keyring.getAccounts()).toStrictEqual([]); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + HDKEY_SERIALIZED_KEYRING_WITH_NO_ACCOUNTS.xpub, + ); + }); + }); + + 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({ + bridge: getMockBridge(), + }); + + // @ts-expect-error QrKeyring types are stricter than Keystone ones + await keyring.deserialize(await keystoneKeyring.serialize()); + + expect(await keystoneKeyring.getAccounts()).toStrictEqual( + await keystoneKeyring.getAccounts(), + ); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + await getXPUBFromKeyring(keystoneKeyring), + ); + }); + }); + }); + + describe('addAccounts', () => { + 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]); + }); + + 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); + + 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(), + ur: KNOWN_HDKEY_UR, + }); + const numberOfAccountsToAdd = 3; + + const accounts = await keyring.addAccounts(numberOfAccountsToAdd); + + expect(accounts).toHaveLength(3); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); + }); + }); + + 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_CRYPTO_ACCOUNT_UR, + }); + keyring.setAccountToUnlock(1); + + await expect(keyring.addAccounts(1)).rejects.toThrow( + 'Address not found for index 1', + ); + }); + }); + + describe('when the keyring is not paired with a device', () => { + it('throws an error', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + await expect(keyring.addAccounts(1)).rejects.toThrow( + 'No device paired.', + ); + }); + }); + }); + + describe('getAccounts', () => { + describe('when no accounts have been added', () => { + it('returns an empty array', async () => { + 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({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + const numberOfAccountsToAdd = 3; + + const accounts = await keyring.addAccounts(numberOfAccountsToAdd); + + expect(await keyring.getAccounts()).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); + expect(accounts).toStrictEqual( + EXPECTED_ACCOUNTS.slice(0, numberOfAccountsToAdd), + ); + }); + }); + }); + + describe('removeAccount', () => { + it('removes an account from the keyring', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + await keyring.addAccounts(3); + + 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({ + bridge: getMockBridge(), + 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('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( + HDKEY_SERIALIZED_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({ + bridge: getMockBridge(), + ur: KNOWN_HDKEY_UR, + }); + + keyring.setAccountToUnlock(2); + + expect(await keyring.addAccounts(1)).toStrictEqual([ + EXPECTED_ACCOUNTS[2], + ]); + }); + }); + + describe('pairDevice', () => { + it('pairs a device to the QrKeyring with a UR', async () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + keyring.pairDevice(KNOWN_HDKEY_UR); + + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + SERIALIZED_KEYSTONE_KEYRING.xpub, + ); + }); + + it('throws an error if the UR is invalid', () => { + const keyring = new QrKeyring({ + bridge: getMockBridge(), + }); + + expect(() => keyring.pairDevice('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.pairDevice({ + type: 'invalid-type', + cbor: 'a0', + }), + ).toThrow('Unsupported UR type'); + }); + }); + + 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_HDKEY_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_HDKEY_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_HDKEY_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_HDKEY_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_HDKEY_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_HDKEY_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_HDKEY_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(HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS); + // let's make sure we have an xpub set + expect(await keyring.getAccounts()).toStrictEqual( + HDKEY_SERIALIZED_KEYRING_WITH_ACCOUNTS.accounts, + ); + expect(await getXPUBFromKeyring(keyring)).toStrictEqual( + HDKEY_SERIALIZED_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.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); + }); + + 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(), + 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.'); + }); + }, + ); + }); + + 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.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', + ); + }); + }, + ); + }); + + 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.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', + ); + }); + }, + ); + }); +}); 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..f821ba975 --- /dev/null +++ b/packages/keyring-eth-qr/src/qr-keyring.ts @@ -0,0 +1,374 @@ +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, assert, getChecksumAddress } from '@metamask/utils'; + +import { + type DeviceDetails, + type IndexedAddress, + Device, + DeviceMode, +} from './device'; + +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 QrSignatureRequest = { + requestId: string; + payload: SerializedUR; + requestTitle?: string; + requestDescription?: string; +}; + +export type QrScanRequest = { + type: QrScanRequestType; + request?: QrSignatureRequest; +}; + +export type SerializedUR = { + type: string; + cbor: string; +}; + +export type QrKeyringBridge = { + requestScan: (request: QrScanRequest) => Promise; +}; + +export type QrKeyringOptions = { + ur?: string; + bridge: QrKeyringBridge; +}; + +/** + * The state of the QrKeyring + * + * @property accounts - The accounts in the QrKeyring + */ +export type SerializedQrKeyringState = { + version?: number; + accounts?: string[]; + currentAccount?: number; +} & ( + | { + initialized?: false; + } + | ({ + initialized: true; + } & DeviceDetails) +); + +/** + * Returns the default serialized state of the QrKeyring. + * + * @returns The default serialized state. + */ +export const getDefaultSerializedQrKeyringState = + (): SerializedQrKeyringState => ({ + initialized: false, + accounts: [], + }); + +/** + * 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)); +} + +export class QrKeyring implements Keyring { + static type = QR_KEYRING_TYPE; + + readonly type = QR_KEYRING_TYPE; + + readonly bridge: QrKeyringBridge; + + #device?: Device | undefined; + + #accounts: Hex[] = []; + + #accountToUnlock?: number | undefined; + + #currentPage: number = 0; + + constructor(options: QrKeyringOptions) { + this.bridge = options.bridge; + + if (options?.ur) { + this.pairDevice(options.ur); + } + } + + /** + * Serializes the QrKeyring state + * + * @returns The serialized state + */ + async serialize(): Promise { + const deviceDetails = this.#device + ? this.#device.getDeviceDetails() + : undefined; + + if ( + !deviceDetails || + ![DeviceMode.HD, DeviceMode.ACCOUNT].includes(deviceDetails.keyringMode) + ) { + // the keyring has not initialized with a deviceDetails device yet + return getDefaultSerializedQrKeyringState(); + } + + return { + ...deviceDetails, + initialized: true, + accounts: this.#accounts.slice(), + }; + } + + /** + * Deserializes the QrKeyring state + * + * @param state - The serialized state to deserialize + */ + async deserialize(state: SerializedQrKeyringState): Promise { + if (!state.initialized) { + this.#accounts = []; + this.#device = undefined; + return; + } + + this.#device = new Device({ + requestScan: this.bridge.requestScan.bind(this.bridge), + source: state, + }); + this.#accounts = (state.accounts ?? []).map(normalizeAddress); + } + + /** + * Adds accounts to the QrKeyring + * + * @param accountsToAdd - The number of accounts to add + * @returns The accounts added + */ + async addAccounts(accountsToAdd: number): Promise { + if (!this.#device) { + throw new Error('No device paired.'); + } + + const newAccounts: Hex[] = []; + + 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; + } + + this.#accounts.push(address); + newAccounts.push(address); + } + + return newAccounts; + } + + /** + * Gets the accounts in the QrKeyring + * + * @returns The accounts in the QrKeyring + */ + async getAccounts(): Promise { + return this.#accounts.slice(); + } + + /** + * 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 = this.#accounts.filter( + (account) => account !== normalizedAddress, + ); + } + + /** + * Pair a QR-based Hardware Device from a CBOR encoded UR to the QrKeyring + * + * @param ur - The CBOR encoded UR + */ + pairDevice(ur: string | SerializedUR): void { + this.#device = new Device({ + requestScan: this.bridge.requestScan.bind(this.bridge), + source: ur, + }); + } + + /** + * Sets the next account index to unlock + * + * @param index - The index of the account to unlock + */ + 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 { + if (!this.#device) { + return QR_KEYRING_TYPE; + } + const source = this.#device.getDeviceDetails(); + return source.name; + } + + /** + * 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.#device) { + await this.#scanAndInitialize(); + } + assert( + this.#device, + 'A device is expected to be paired before fetching accounts.', + ); + return this.#device.getAddressesPage(this.#currentPage); + } + + /** + * Clear the keyring state and forget any paired device or accounts. + */ + async forgetDevice(): Promise { + this.#device = undefined; + this.#accounts = []; + this.#accountToUnlock = undefined; + 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 { + if (!this.#device) { + throw new Error('No device paired.'); + } + return this.#device.signTransaction(address, transaction); + } + + /** + * 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.#device) { + throw new Error('No device paired.'); + } + return this.#device.signTypedData(address, data); + } + + /** + * 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.#device) { + throw new Error('No device paired.'); + } + return this.#device.signPersonalMessage(address, message); + } + + /** + * Scan for a QR code and initialize the keyring with the + * scanned UR. + */ + async #scanAndInitialize(): Promise { + this.pairDevice( + await this.bridge.requestScan({ type: QrScanRequestType.PAIR }), + ); + } +} 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.build.json b/packages/keyring-eth-qr/tsconfig.build.json new file mode 100644 index 000000000..08ef33751 --- /dev/null +++ b/packages/keyring-eth-qr/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "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 new file mode 100644 index 000000000..f6a8db2fb --- /dev/null +++ b/packages/keyring-eth-qr/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../keyring-utils" }], + "include": ["./src", "./test"], + "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 6f835f517..60377db1b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ { "path": "./packages/keyring-api/tsconfig.build.json" }, { "path": "./packages/keyring-internal-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-eth-hd/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 874a41515..773e61620 100644 --- a/yarn.lock +++ b/yarn.lock @@ -479,7 +479,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/common@npm:^4.4.0": +"@ethereumjs/common@npm:^4.1.0, @ethereumjs/common@npm:^4.4.0": version: 4.4.0 resolution: "@ethereumjs/common@npm:4.4.0" dependencies: @@ -506,7 +506,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: @@ -515,6 +515,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:^10.0.0": version: 10.0.0 resolution: "@ethereumjs/tx@npm:10.0.0" @@ -551,6 +568,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:^10.0.0": version: 10.0.0 resolution: "@ethereumjs/util@npm:10.0.0" @@ -561,7 +588,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: @@ -572,30 +599,20 @@ __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.8.0 - resolution: "@ethersproject/abi@npm:5.8.0" + version: 5.7.0 + resolution: "@ethersproject/abi@npm:5.7.0" dependencies: - "@ethersproject/address": "npm:^5.8.0" - "@ethersproject/bignumber": "npm:^5.8.0" - "@ethersproject/bytes": "npm:^5.8.0" - "@ethersproject/constants": "npm:^5.8.0" - "@ethersproject/hash": "npm:^5.8.0" - "@ethersproject/keccak256": "npm:^5.8.0" - "@ethersproject/logger": "npm:^5.8.0" - "@ethersproject/properties": "npm:^5.8.0" - "@ethersproject/strings": "npm:^5.8.0" - checksum: 10/a63ebc2c8ea795ceca5289abaf817bb402c83c330cffd0ae2d355be70c54050a21ddd408abd4fd0dce4c3fd5c5f091707be2095011c233022a52f2110e7012d6 + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/bytes": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/hash": "npm:^5.7.0" + "@ethersproject/keccak256": "npm:^5.7.0" + "@ethersproject/logger": "npm:^5.7.0" + "@ethersproject/properties": "npm:^5.7.0" + "@ethersproject/strings": "npm:^5.7.0" + checksum: 10/6ed002cbc61a7e21bc0182702345659c1984f6f8e6bad166e43aee76ea8f74766dd0f6236574a868e1b4600af27972bf25b973fae7877ae8da3afa90d3965cac languageName: node linkType: hard @@ -627,7 +644,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/address@npm:^5.8.0": +"@ethersproject/address@npm:^5.7.0, @ethersproject/address@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/address@npm:5.8.0" dependencies: @@ -649,7 +666,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bignumber@npm:^5.8.0": +"@ethersproject/bignumber@npm:^5.7.0, @ethersproject/bignumber@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/bignumber@npm:5.8.0" dependencies: @@ -660,7 +677,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/bytes@npm:^5.8.0": +"@ethersproject/bytes@npm:^5.7.0, @ethersproject/bytes@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/bytes@npm:5.8.0" dependencies: @@ -678,7 +695,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/hash@npm:^5.7.0, @ethersproject/hash@npm:^5.8.0": +"@ethersproject/hash@npm:^5.7.0": version: 5.8.0 resolution: "@ethersproject/hash@npm:5.8.0" dependencies: @@ -695,7 +712,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/keccak256@npm:^5.8.0": +"@ethersproject/keccak256@npm:^5.7.0, @ethersproject/keccak256@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/keccak256@npm:5.8.0" dependencies: @@ -705,7 +722,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/logger@npm:^5.8.0": +"@ethersproject/logger@npm:^5.7.0, @ethersproject/logger@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/logger@npm:5.8.0" checksum: 10/dab862d6cc3a4312f4c49d62b4a603f4b60707da8b8ff0fee6bdfee3cbed48b34ec8f23fedfef04dd3d24f2fa2d7ad2be753c775aa00fe24dcd400631d65004a @@ -721,7 +738,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/properties@npm:^5.8.0": +"@ethersproject/properties@npm:^5.7.0, @ethersproject/properties@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/properties@npm:5.8.0" dependencies: @@ -754,7 +771,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/strings@npm:^5.8.0": +"@ethersproject/strings@npm:^5.7.0, @ethersproject/strings@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/strings@npm:5.8.0" dependencies: @@ -1166,6 +1183,67 @@ __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/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" + dependencies: + "@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/7e64e6a754e6b66fc83a8f3880b54828c5b37f4eaaea3287eee31bd9d9b5ac0ba4cd4b8e751af9bd2f66e6f19291eaf02f46cd177d05ed9b30c1349cdd04572f + 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" + dependencies: + "@ngraveio/bc-ur": "npm:^1.1.5" + bs58check: "npm:^2.1.2" + tslib: "npm:^2.3.0" + checksum: 10/d4cdbefc14f3305543340d509564e1a795eb458327d46aad8665927999150df7e282939dcb714b81fea386061019e3b9f41eedbbb09a59d404355711c33159b2 + 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 + "@lavamoat/aa@npm:^4.3.0": version: 4.3.0 resolution: "@lavamoat/aa@npm:4.3.0" @@ -1664,6 +1742,37 @@ __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/common": "npm:^4.4.0" + "@ethereumjs/rlp": "npm:^5.0.2" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@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" + "@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" + jest: "npm:^29.5.0" + ts-jest: "npm:^29.0.5" + ts-node: "npm:^10.9.2" + typedoc: "npm:^0.25.13" + typescript: "npm:~5.6.3" + uuid: "npm:^9.0.1" + languageName: unknown + linkType: soft + "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -2054,6 +2163,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.6": version: 11.0.6 resolution: "@metamask/permission-controller@npm:11.0.6" @@ -2313,8 +2432,8 @@ __metadata: linkType: hard "@metamask/utils@npm:^9.0.0": - version: 9.3.0 - resolution: "@metamask/utils@npm:9.3.0" + version: 9.2.1 + resolution: "@metamask/utils@npm:9.2.1" dependencies: "@ethereumjs/tx": "npm:^4.2.0" "@metamask/superstruct": "npm:^3.1.0" @@ -2325,7 +2444,7 @@ __metadata: pony-cause: "npm:^2.1.10" semver: "npm:^7.5.4" uuid: "npm:^9.0.1" - checksum: 10/ed6648cd973bbf3b4eb0e862903b795a99d27784c820e19f62f0bc0ddf353e98c2858d7e9aaebc0249a586391b344e35b9249d13c08e3ea0c74b23dc1c6b1558 + checksum: 10/2192797afd91af19898e107afeaf63e89b61dc7285e0a75d0cc814b5b288e4cdfc856781b01904034c4d2c1efd9bdab512af24c7e4dfe7b77a03f1f3d9dec7e8 languageName: node linkType: hard @@ -2336,6 +2455,21 @@ __metadata: languageName: node linkType: hard +"@ngraveio/bc-ur@npm:^1.1.5": + 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/ciphers@npm:1.3.0": version: 1.3.0 resolution: "@noble/ciphers@npm:1.3.0" @@ -4678,6 +4812,19 @@ __metadata: languageName: node linkType: hard +"assert@npm:^2.0.0": + version: 2.1.0 + resolution: "assert@npm:2.1.0" + dependencies: + call-bind: "npm:^1.0.2" + is-nan: "npm:^1.3.2" + object-is: "npm:^1.1.5" + object.assign: "npm:^4.1.4" + util: "npm:^0.12.5" + checksum: 10/6b9d813c8eef1c0ac13feac5553972e4bd180ae16000d4eb5c0ded2489188737c75a5aacefc97a985008b37502f62fe1bad34da1a7481a54bbfabec3964c8aa7 + languageName: node + linkType: hard + "async-mutex@npm:^0.5.0": version: 0.5.0 resolution: "async-mutex@npm:0.5.0" @@ -4701,6 +4848,15 @@ __metadata: languageName: node linkType: hard +"available-typed-arrays@npm:^1.0.7": + version: 1.0.7 + resolution: "available-typed-arrays@npm:1.0.7" + dependencies: + possible-typed-array-names: "npm:^1.0.0" + checksum: 10/6c9da3a66caddd83c875010a1ca8ef11eac02ba15fb592dc9418b2b5e7b77b645fa7729380a92d9835c2f05f2ca1b6251f39b993e0feb3f1517c74fa1af02cab + languageName: node + linkType: hard + "axios@npm:1.7.7": version: 1.7.7 resolution: "axios@npm:1.7.7" @@ -4944,7 +5100,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.1.2, bignumber.js@npm:^9.3.0": +"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.0.1, bignumber.js@npm:^9.1.2, bignumber.js@npm:^9.3.0": version: 9.3.0 resolution: "bignumber.js@npm:9.3.0" checksum: 10/60b79efcf7b56b925fca8eebd10d1f4b70aa2bf6eade7f5af0266f0092226dd2abcd9a3ee315ecb39459750d5a630ce3980b707e5d7bea32c97ffd378e8cc159 @@ -5171,6 +5327,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 + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -5243,6 +5409,19 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + set-function-length: "npm:^1.2.1" + checksum: 10/cd6fe658e007af80985da5185bff7b55e12ef4c2b6f41829a26ed1eef254b1f1c12e3dfd5b2b068c6ba8b86aba62390842d81752e67dcbaec4f6f76e7113b6b7 + languageName: node + linkType: hard + "callsite@npm:^1.0.0": version: 1.0.0 resolution: "callsite@npm:1.0.0" @@ -5298,6 +5477,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" @@ -5592,6 +5778,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" @@ -5791,6 +5986,28 @@ __metadata: languageName: node linkType: hard +"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: 10/abdcb2505d80a53524ba871273e5da75e77e52af9e15b3aa65d8aad82b8a3a424dad7aee2cc0b71470ac7acf501e08defac362e8b6a73cdb4309f028061df4ae + languageName: node + linkType: hard + +"define-properties@npm:^1.1.3, define-properties@npm:^1.1.4, define-properties@npm:^1.2.1": + version: 1.2.1 + resolution: "define-properties@npm:1.2.1" + dependencies: + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" + checksum: 10/b4ccd00597dd46cb2d4a379398f5b19fca84a16f3374e2249201992f36b30f6835949a9429669ee6b41b6e837205a163eadd745e472069e70dfc10f03e5fcc12 + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -5983,7 +6200,7 @@ __metadata: languageName: node linkType: hard -"elliptic@npm:6.6.1, elliptic@npm:^6.4.0, elliptic@npm:^6.5.2, elliptic@npm:^6.5.7": +"elliptic@npm:6.6.1, elliptic@npm:^6.4.0, elliptic@npm:^6.5.2, elliptic@npm:^6.5.4": version: 6.6.1 resolution: "elliptic@npm:6.6.1" dependencies: @@ -6075,6 +6292,22 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: "npm:^1.2.4" + checksum: 10/f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10/96e65d640156f91b707517e8cdc454dd7d47c32833aa3e85d79f24f9eb7ea85f39b63e36216ef0114996581969b59fe609a94e30316b08f5f4df1d44134cf8d5 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.1.2": version: 3.1.2 resolution: "escalade@npm:3.1.2" @@ -6961,6 +7194,15 @@ __metadata: languageName: node linkType: hard +"for-each@npm:^0.3.3": + version: 0.3.3 + resolution: "for-each@npm:0.3.3" + dependencies: + is-callable: "npm:^1.1.3" + checksum: 10/fdac0cde1be35610bd635ae958422e8ce0cc1313e8d32ea6d34cfda7b60850940c1fd07c36456ad76bd9c24aef6ff5e03b02beb58c83af5ef6c968a64eada676 + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.0 resolution: "foreground-child@npm:3.3.0" @@ -7070,6 +7312,19 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.0" + checksum: 10/85bbf4b234c3940edf8a41f4ecbd4e25ce78e5e6ad4e24ca2f77037d983b9ef943fd72f00f3ee97a49ec622a506b67db49c36246150377efcda1c9eb03e5f06d + languageName: node + linkType: hard + "get-npm-tarball-url@npm:^2.0.3": version: 2.1.0 resolution: "get-npm-tarball-url@npm:2.1.0" @@ -7263,6 +7518,15 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.0.1": + version: 1.0.1 + resolution: "gopd@npm:1.0.1" + dependencies: + get-intrinsic: "npm:^1.1.3" + checksum: 10/5fbc7ad57b368ae4cd2f41214bd947b045c1a4be2f194a7be1778d71f8af9dbf4004221f3b6f23e30820eb0d052b4f819fe6ebe8221e2a3c6f0ee4ef173421ca + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -7291,6 +7555,38 @@ __metadata: languageName: node linkType: hard +"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: "npm:^1.0.0" + checksum: 10/2d8c9ab8cebb572e3362f7d06139a4592105983d4317e68f7adba320fe6ddfc8874581e0971e899e633fd5f72e262830edce36d5a0bc863dad17ad20572484b2 + languageName: node + linkType: hard + +"has-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "has-proto@npm:1.0.1" + checksum: 10/eab2ab0ed1eae6d058b9bbc4c1d99d2751b29717be80d02fd03ead8b62675488de0c7359bc1fdd4b87ef6fd11e796a9631ad4d7452d9324fdada70158c2e5be7 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3": + version: 1.0.3 + resolution: "has-symbols@npm:1.0.3" + checksum: 10/464f97a8202a7690dadd026e6d73b1ceeddd60fe6acfd06151106f050303eaa75855aaa94969df8015c11ff7c505f196114d22f7386b4a471038da5874cf5e9b + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10/c74c5f5ceee3c8a5b8bc37719840dc3749f5b0306d818974141dda2471a1a2ca6c8e46b9d6ac222c5345df7a901c9b6f350b1e6d62763fec877e26609a401bfe + languageName: node + linkType: hard + "has-unicode@npm:^2.0.1": version: 2.0.1 resolution: "has-unicode@npm:2.0.1" @@ -7319,7 +7615,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.2": +"hasown@npm:^2.0.0, hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -7328,7 +7624,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: @@ -7490,7 +7786,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 @@ -7609,6 +7905,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.0.4": + version: 1.1.1 + resolution: "is-arguments@npm:1.1.1" + dependencies: + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: 10/a170c7e26082e10de9be6e96d32ae3db4d5906194051b792e85fae3393b53cf2cb5b3557863e5c8ccbab55e2fd8f2f75aa643d437613f72052cf0356615c34be + languageName: node + linkType: hard + "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -7625,6 +7931,13 @@ __metadata: languageName: node linkType: hard +"is-callable@npm:^1.1.3": + version: 1.2.7 + resolution: "is-callable@npm:1.2.7" + checksum: 10/48a9297fb92c99e9df48706241a189da362bff3003354aea4048bd5f7b2eb0d823cd16d0a383cece3d76166ba16d85d9659165ac6fcce1ac12e6c649d66dbdb9 + languageName: node + linkType: hard + "is-ci@npm:^2.0.0": version: 2.0.0 resolution: "is-ci@npm:2.0.0" @@ -7666,6 +7979,15 @@ __metadata: languageName: node linkType: hard +"is-generator-function@npm:^1.0.7": + version: 1.0.10 + resolution: "is-generator-function@npm:1.0.10" + dependencies: + has-tostringtag: "npm:^1.0.0" + checksum: 10/499a3ce6361064c3bd27fbff5c8000212d48506ebe1977842bbd7b3e708832d0deb1f4cc69186ece3640770e8c4f1287b24d99588a0b8058b2dbdd344bc1f47f + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -7696,6 +8018,16 @@ __metadata: languageName: node linkType: hard +"is-nan@npm:^1.3.2": + version: 1.3.2 + resolution: "is-nan@npm:1.3.2" + dependencies: + call-bind: "npm:^1.0.0" + define-properties: "npm:^1.1.3" + checksum: 10/1f784d3472c09bc2e47acba7ffd4f6c93b0394479aa613311dc1d70f1bfa72eb0846c81350967722c959ba65811bae222204d6c65856fdce68f31986140c7b0e + languageName: node + linkType: hard + "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -7759,6 +8091,15 @@ __metadata: languageName: node linkType: hard +"is-typed-array@npm:^1.1.3": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" + dependencies: + which-typed-array: "npm:^1.1.14" + checksum: 10/f850ba08286358b9a11aee6d93d371a45e3c59b5953549ee1c1a9a55ba5c1dd1bd9952488ae194ad8f32a9cf5e79c8fa5f0cc4d78c00720aa0bbcf238b38062d + languageName: node + linkType: hard + "is-unicode-supported@npm:^0.1.0": version: 0.1.0 resolution: "is-unicode-supported@npm:0.1.0" @@ -8434,6 +8775,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" @@ -9211,15 +9559,6 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^5.0.0": - version: 5.1.0 - resolution: "node-addon-api@npm:5.1.0" - dependencies: - node-gyp: "npm:latest" - checksum: 10/595f59ffb4630564f587c502119cbd980d302e482781021f3b479f5fc7e41cf8f2f7280fdc2795f32d148e4f3259bd15043c52d4a3442796aa6f1ae97b959636 - languageName: node - linkType: hard - "node-addon-api@npm:^8.0.0": version: 8.1.0 resolution: "node-addon-api@npm:8.1.0" @@ -9449,6 +9788,35 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10/4f6f544773a595da21c69a7531e0e1d6250670f4e09c55f47eb02c516035cfcb1b46ceb744edfd3ecb362309dbccb6d7f88e43bf42e4d4595ac10a329061053a + languageName: node + linkType: hard + +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: 10/3d81d02674115973df0b7117628ea4110d56042e5326413e4b4313f0bcdf7dd78d4a3acef2c831463fa3796a66762c49daef306f4a0ea1af44877d7086d73bde + languageName: node + linkType: hard + +"object.assign@npm:^4.1.4": + version: 4.1.4 + resolution: "object.assign@npm:4.1.4" + dependencies: + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.1.4" + has-symbols: "npm:^1.0.3" + object-keys: "npm:^1.1.1" + checksum: 10/fd82d45289df0a952d772817622ecbaeb4ec933d3abb53267aede083ee38f6a395af8fadfbc569ee575115b0b7c9b286e7cfb2b7a2557b1055f7acbce513bc29 + languageName: node + linkType: hard + "old-hd-keyring@npm:@metamask/eth-hd-keyring@^4.0.1": version: 4.0.2 resolution: "@metamask/eth-hd-keyring@npm:4.0.2" @@ -9750,6 +10118,13 @@ __metadata: languageName: node linkType: hard +"possible-typed-array-names@npm:^1.0.0": + version: 1.0.0 + resolution: "possible-typed-array-names@npm:1.0.0" + checksum: 10/8ed3e96dfeea1c5880c1f4c9cb707e5fb26e8be22f14f82ef92df20fd2004e635c62ba47fbe8f2bb63bfd80dac1474be2fb39798da8c2feba2815435d1f749af + languageName: node + linkType: hard + "postcss@npm:^8.4.40": version: 8.4.41 resolution: "postcss@npm:8.4.41" @@ -10273,7 +10648,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: @@ -10284,6 +10659,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 + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -10349,14 +10733,14 @@ __metadata: linkType: hard "secp256k1@npm:^4.0.0, secp256k1@npm:^4.0.1": - version: 4.0.4 - resolution: "secp256k1@npm:4.0.4" + version: 4.0.3 + resolution: "secp256k1@npm:4.0.3" dependencies: - elliptic: "npm:^6.5.7" - node-addon-api: "npm:^5.0.0" + elliptic: "npm:^6.5.4" + node-addon-api: "npm:^2.0.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.2.0" - checksum: 10/45000f348c853df7c1e2b67c48efb062ae78c0620ab1a5cfb02fa20d3aad39c641f4e7a18b3de3b54a7c0cc1e0addeb8ecd9d88bc332e92df17a92b60c36122a + checksum: 10/8b45820cd90fd2f95cc8fdb9bf8a71e572de09f2311911ae461a951ffa9e30c99186a129d0f1afeb380dd67eca0c10493f8a7513c39063fda015e99995088e3b languageName: node linkType: hard @@ -10411,6 +10795,20 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.2.1": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.2" + checksum: 10/505d62b8e088468917ca4e3f8f39d0e29f9a563b97dbebf92f4bd2c3172ccfb3c5b8e4566d5fcd00784a00433900e7cb8fbc404e2dbd8c3818ba05bb9d4a8a6d + languageName: node + linkType: hard + "setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" @@ -10418,7 +10816,7 @@ __metadata: languageName: node linkType: hard -"sha.js@npm:^2.3.6, sha.js@npm:^2.4.0, sha.js@npm:^2.4.8": +"sha.js@npm:^2.3.6, 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: @@ -11207,7 +11605,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 @@ -11506,6 +11904,19 @@ __metadata: languageName: node linkType: hard +"util@npm:^0.12.5": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 10/61a10de7753353dd4d744c917f74cdd7d21b8b46379c1e48e1c4fd8e83f8190e6bd9978fc4e5102ab6a10ebda6019d1b36572fa4a325e175ec8b789a121f6147 + languageName: node + linkType: hard + "utility-types@npm:^3.10.0": version: 3.11.0 resolution: "utility-types@npm:3.11.0" @@ -11671,6 +12082,19 @@ __metadata: languageName: node linkType: hard +"which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.2": + version: 1.1.15 + resolution: "which-typed-array@npm:1.1.15" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.2" + checksum: 10/c3b6a99beadc971baa53c3ee5b749f2b9bdfa3b3b9a70650dd8511a48b61d877288b498d424712e9991d16019633086bd8b5923369460d93463c5825fa36c448 + languageName: node + linkType: hard + "which@npm:^1.2.14": version: 1.3.1 resolution: "which@npm:1.3.1"