diff --git a/README.md b/README.md index 4e5bf1f8..8f1b2da2 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-onekey-keyring`](packages/keyring-eth-onekey) - [`@metamask/eth-qr-keyring`](packages/keyring-eth-qr) - [`@metamask/eth-simple-keyring`](packages/keyring-eth-simple) - [`@metamask/eth-snap-keyring`](packages/keyring-snap-bridge) @@ -40,6 +41,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_onekey_keyring(["@metamask/eth-onekey-keyring"]); eth_qr_keyring(["@metamask/eth-qr-keyring"]); eth_simple_keyring(["@metamask/eth-simple-keyring"]); eth_trezor_keyring(["@metamask/eth-trezor-keyring"]); @@ -54,6 +56,7 @@ linkStyle default opacity:0.5 keyring_api --> keyring_utils; eth_hd_keyring --> keyring_utils; eth_ledger_bridge_keyring --> keyring_utils; + eth_onekey_keyring --> keyring_utils; eth_qr_keyring --> keyring_utils; eth_simple_keyring --> keyring_utils; eth_trezor_keyring --> keyring_utils; diff --git a/packages/keyring-eth-onekey/CHANGELOG.md b/packages/keyring-eth-onekey/CHANGELOG.md new file mode 100644 index 00000000..8202d1a4 --- /dev/null +++ b/packages/keyring-eth-onekey/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] + +### Changed + +- Add initial implementation of the OneKey keyring ([#353](https://github.com/MetaMask/accounts/pull/353)) + +[Unreleased]: https://github.com/MetaMask/accounts/ diff --git a/packages/keyring-eth-onekey/LICENSE b/packages/keyring-eth-onekey/LICENSE new file mode 100644 index 00000000..b5ed1b9c --- /dev/null +++ b/packages/keyring-eth-onekey/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-onekey/README.md b/packages/keyring-eth-onekey/README.md new file mode 100644 index 00000000..34cb0357 --- /dev/null +++ b/packages/keyring-eth-onekey/README.md @@ -0,0 +1,41 @@ +# eth-onekey-bridge-keyring + +An implementation of MetaMask's [Keyring interface](https://github.com/MetaMask/eth-simple-keyring#the-keyring-class-protocol), that uses a OneKey hardware wallet for all cryptographic operations. + +In most regards, it works in the same way as +[eth-hd-keyring](https://github.com/MetaMask/eth-hd-keyring), but using a OneKey +device. However there are a number of differences: + +- Because the keys are stored in the device, operations that rely on the device + will fail if there is no OneKey device attached, or a different OneKey device + is attached. + +- It does not support the `signMessage`, `signTypedData` or `exportAccount` + methods, because OneKey devices do not support these operations. + +- Because extensions have limited access to browser features, there's no easy way to interact wth the OneKey Hardware wallet from the MetaMask extension. This library implements a workaround to those restrictions by injecting (on demand) an iframe to the background page of the extension, + +## Usage + +In addition to all the known methods from the [Keyring class protocol](https://github.com/MetaMask/eth-simple-keyring#the-keyring-class-protocol), +there are a few others: + +- **isUnlocked** : Returns true if we have the public key in memory, which allows to generate the list of accounts at any time + +- **unlock** : Connects to the OneKey device and exports the extended public key, which is later used to read the available ethereum addresses inside the OneKey account. + +- **setAccountToUnlock** : the index of the account that you want to unlock in order to use with the signTransaction and signPersonalMessage methods + +- **getFirstPage** : returns the first ordered set of accounts from the OneKey account + +- **getNextPage** : returns the next ordered set of accounts from the OneKey account based on the current page + +- **getPreviousPage** : returns the previous ordered set of accounts from the OneKey account based on the current page + +- **forgetDevice** : removes all the device info from memory so the next interaction with the keyring will prompt the user to connect the OneKey device and export the account information + +## Testing and Linting + +Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. + +Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. diff --git a/packages/keyring-eth-onekey/jest.config.js b/packages/keyring-eth-onekey/jest.config.js new file mode 100644 index 00000000..a1b16998 --- /dev/null +++ b/packages/keyring-eth-onekey/jest.config.js @@ -0,0 +1,32 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['./src/tests'], + + // The glob patterns Jest uses to detect test files + testMatch: ['**/*.test.[jt]s?(x)'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 76.25, + functions: 98.59, + lines: 97.7, + statements: 97.71, + }, + }, +}); diff --git a/packages/keyring-eth-onekey/package.json b/packages/keyring-eth-onekey/package.json new file mode 100644 index 00000000..736e30c0 --- /dev/null +++ b/packages/keyring-eth-onekey/package.json @@ -0,0 +1,110 @@ +{ + "name": "@metamask/eth-onekey-keyring", + "version": "0.1.0", + "description": "A MetaMask compatible keyring, for OneKey hardware wallets", + "keywords": [ + "ethereum", + "keyring", + "onekey", + "metamask" + ], + "homepage": "https://github.com/MetaMask/accounts/blob/main/packages/keyring-eth-onekey/README.md", + "bugs": { + "url": "https://github.com/MetaMask/accounts/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/accounts.git" + }, + "license": "ISC", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references", + "build:clean": "yarn build --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-onekey-keyring", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-onekey-keyring", + "publish": "yarn npm publish", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest && jest-it-up", + "test:clean": "jest --clearCache", + "test:watch": "jest --watch" + }, + "dependencies": { + "@ethereumjs/tx": "^5.4.0", + "@ethereumjs/util": "^9.1.0", + "@metamask/eth-sig-util": "^8.2.0", + "@metamask/utils": "^11.1.0", + "@noble/hashes": "^1.7.0", + "@onekeyfe/hd-core": "1.1.17-patch.1", + "@onekeyfe/hd-shared": "1.1.17-patch.1", + "@onekeyfe/hd-transport": "1.1.17-patch.1", + "@onekeyfe/hd-web-sdk": "1.1.17-patch.1", + "hdkey": "^2.1.0" + }, + "devDependencies": { + "@ethereumjs/common": "^4.4.0", + "@lavamoat/allow-scripts": "^3.2.1", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-utils": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@types/bytebuffer": "^5.0.49", + "@types/ethereumjs-tx": "^1.0.1", + "@types/hdkey": "^2.0.1", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.12", + "@types/sinon": "^17.0.3", + "@types/w3c-web-usb": "^1.0.6", + "deepmerge": "^4.2.2", + "depcheck": "^1.4.7", + "ethereumjs-tx": "^1.3.7", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.7.0", + "jest-it-up": "^3.1.0", + "sinon": "^19.0.2", + "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/" + }, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "ethereumjs-tx>ethereumjs-util>keccak": false, + "ethereumjs-tx>ethereumjs-util>secp256k1": false, + "hdkey>secp256k1": false, + "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>keccak": false, + "ethereumjs-tx>ethereumjs-util>ethereum-cryptography>secp256k1": false, + "@onekeyfe/hd-transport>protobufjs": false, + "@onekeyfe/hd-core>@onekeyfe/hd-transport>protobufjs": false, + "@onekeyfe/hd-web-sdk>@onekeyfe/hd-core>@onekeyfe/hd-transport>protobufjs": false + } + } +} diff --git a/packages/keyring-eth-onekey/src/index.ts b/packages/keyring-eth-onekey/src/index.ts new file mode 100644 index 00000000..519a97af --- /dev/null +++ b/packages/keyring-eth-onekey/src/index.ts @@ -0,0 +1,3 @@ +export * from './onekey-keyring'; +export type * from './onekey-bridge'; +export * from './onekey-web-bridge'; diff --git a/packages/keyring-eth-onekey/src/onekey-bridge.ts b/packages/keyring-eth-onekey/src/onekey-bridge.ts new file mode 100644 index 00000000..afd8d62e --- /dev/null +++ b/packages/keyring-eth-onekey/src/onekey-bridge.ts @@ -0,0 +1,60 @@ +import type { + Params, + EVMSignedTx, + EVMSignTransactionParams, + EVMSignMessageParams, + EVMSignTypedDataParams, + EVMGetPublicKeyParams, +} from '@onekeyfe/hd-core'; +import type { EthereumMessageSignature } from '@onekeyfe/hd-transport'; + +type Unsuccessful = { + success: false; + payload: { + error: string; + code?: string | number; + }; +}; +type Success = { + success: true; + payload: TData; +}; +type Response = Promise | Unsuccessful>; + +/** + * Hardware UI event payload + */ +export type HardwareUIEvent = { + error: string; + code?: string | number; +}; + +export type OneKeyBridge = { + model?: string; + + init(): Promise; + + dispose(): Promise; + + updateTransportMethod(transportType: string): Promise; + + // OneKeySdk.getPublicKey has two overloads + // It is not possible to extract them from the library using utility types + getPublicKey( + params: Params, + ): Response<{ publicKey: string; chainCode: string }>; + + getPassphraseState(): Response; + + ethereumSignTransaction( + params: Params, + ): Response; + + ethereumSignMessage( + params: Params, + ): Response; + + ethereumSignTypedData( + params: Params, + ): Response; +}; diff --git a/packages/keyring-eth-onekey/src/onekey-keyring.test.ts b/packages/keyring-eth-onekey/src/onekey-keyring.test.ts new file mode 100644 index 00000000..fcaeafe5 --- /dev/null +++ b/packages/keyring-eth-onekey/src/onekey-keyring.test.ts @@ -0,0 +1,1333 @@ +/* eslint-disable jest/no-conditional-expect */ +import { Common, Chain, Hardfork } from '@ethereumjs/common'; +import { + TransactionFactory, + FeeMarketEIP1559Transaction, +} from '@ethereumjs/tx'; +import { Address } from '@ethereumjs/util'; +import { SignTypedDataVersion } from '@metamask/eth-sig-util'; +import * as ethSigUtil from '@metamask/eth-sig-util'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import type EthereumTx from 'ethereumjs-tx'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import HDKey from 'hdkey'; +import * as sinon from 'sinon'; + +import type { OneKeyBridge } from './onekey-bridge'; +import type { AccountDetails } from './onekey-keyring'; +import { OneKeyKeyring } from './onekey-keyring'; +import { OneKeyWebBridge } from './onekey-web-bridge'; + +// Mock @onekeyfe/hd-web-sdk to avoid browser environment dependencies +jest.mock('@onekeyfe/hd-web-sdk', () => ({ + PascalCase: true, + default: { + HardwareWebSdk: { + init: jest.fn(), + on: jest.fn(), + uiResponse: jest.fn(), + dispose: jest.fn(), + switchTransport: jest.fn(), + evmGetPublicKey: jest.fn(), + getPassphraseState: jest.fn(), + evmSignTransaction: jest.fn(), + evmSignMessage: jest.fn(), + evmSignTypedData: jest.fn(), + }, + HardwareSDKLowLevel: {}, + }, +})); + +jest.mock('@metamask/eth-sig-util', () => { + const actual = jest.requireActual('@metamask/eth-sig-util'); + return { + ...actual, + recoverPersonalSignature: jest.fn( + (...args: Parameters) => + actual.recoverPersonalSignature(...args), + ), + recoverTypedSignature: jest.fn( + (...args: Parameters) => + actual.recoverTypedSignature(...args), + ), + }; +}); + +const fakeAccounts = [ + '0x73d0385F4d8E00C5e6504C6030F47BF6212736A8', + '0xFA01a39f8Abaeb660c3137f14A310d0b414b2A15', + '0x574BbB36871bA6b78E27f4B4dCFb76eA0091880B', + '0xba98D6a5ac827632E3457De7512d211e4ff7e8bD', + '0x1f815D67006163E502b8eD4947C91ad0A62De24e', + '0xf69619a3dCAA63757A6BA0AF3628f5F6C42c50d2', + '0xA8664Df3D5E74BE57c19fC7005BBcd0F5328041e', + '0xf2252f414e727d652d5a488fE4BFf7e64478737F', + '0x5708Ae081b48ad7bA8c50ca3D4fa0238d544D6FA', + '0x12eF7dfb86f6D5E3e0521b72472ca02D2a3814F4', + '0x9115Fa64b8B9864D6545Fc00d62B6A9Cbb876be7', + '0x8B6cF2eA1A54E054EFC35E4244Ac507c479bb5F6', + '0x6C480ba4409dd5FF29Cbd3ED67152B791750a708', + '0x5f2E5ddEd3DBD431deCc406Ae999F277B625Ba25', + '0x8a143C4CCed2ce826DE598Dbbf7C706cD6DB0Ccd', +] as const; + +const fakeXPubKey = + 'xpub6CNFa58kEQJu2hwMVoofpDEKVVSg6gfwqBqE2zHAianaUnQkrJzJJ42iLDp7Dmg2aP88qCKoFZ4jidk3tECdQuF4567NGHDfe7iBRwHxgke'; +const fakeHdKey = HDKey.fromExtendedKey(fakeXPubKey); + +const recoverPersonalSignatureMock = + ethSigUtil.recoverPersonalSignature as jest.MockedFunction< + typeof ethSigUtil.recoverPersonalSignature + >; +const recoverTypedSignatureMock = + ethSigUtil.recoverTypedSignature as jest.MockedFunction< + typeof ethSigUtil.recoverTypedSignature + >; + +const common = new Common({ chain: 'mainnet' }); +const commonEIP1559 = new Common({ + chain: Chain.Mainnet, + hardfork: Hardfork.London, +}); +const newFakeTx = TransactionFactory.fromTxData( + { + nonce: '0x00', + gasPrice: '0x09184e72a000', + gasLimit: '0x2710', + to: '0x0000000000000000000000000000000000000000', + value: '0x00', + data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + }, + { common, freeze: false }, +); + +const contractDeploymentFakeTx = TransactionFactory.fromTxData( + { + nonce: '0x00', + gasPrice: '0x09184e72a000', + gasLimit: '0x2710', + value: '0x00', + data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + }, + { common, freeze: false }, +); + +const fakeTypeTwoTx = FeeMarketEIP1559Transaction.fromTxData( + { + nonce: '0x00', + maxFeePerGas: '0x19184e72a000', + maxPriorityFeePerGas: '0x09184e72a000', + gasLimit: '0x2710', + to: '0x0000000000000000000000000000000000000000', + value: '0x00', + data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + type: 2, + v: '0x01', + }, + { common: commonEIP1559, freeze: false }, +); + +describe('OneKeyKeyring', function () { + let keyring: OneKeyKeyring; + let bridge: OneKeyBridge; + + beforeEach(async function () { + bridge = new OneKeyWebBridge(); + keyring = new OneKeyKeyring({ bridge }); + keyring.hdk = fakeHdKey; + keyring.accountDetails = { + [fakeAccounts[0]]: { + index: 0, + hdPath: `m/44'/60'/0'/0/0`, + passphraseState: '', + }, + }; + }); + + afterEach(function () { + sinon.restore(); + jest.clearAllMocks(); + }); + + describe('Keyring.type', function () { + it('is a class property that returns the type string.', function () { + const { type } = OneKeyKeyring; + expect(typeof type).toBe('string'); + }); + + it('returns the correct value', function () { + const { type } = keyring; + const correct = OneKeyKeyring.type; + expect(type).toBe(correct); + }); + }); + + describe('constructor', function () { + it('constructs', async function () { + const keyringInstance = new OneKeyKeyring({ bridge }); + expect(typeof keyringInstance).toBe('object'); + const accounts = await keyringInstance.getAccounts(); + expect(Array.isArray(accounts)).toBe(true); + }); + + it('throws if a bridge is not provided', async function () { + expect( + () => + new OneKeyKeyring({ + bridge: undefined as unknown as OneKeyBridge, + }), + ).toThrow('Bridge is a required dependency for the keyring'); + }); + }); + + describe('destroy', function () { + it('calls dispose on bridge', async function () { + const disposeStub = sinon.stub().resolves(); + bridge.dispose = disposeStub; + + await keyring.destroy(); + + expect(disposeStub.calledOnce).toBe(true); + sinon.assert.calledWithExactly(disposeStub); + }); + }); + + describe('serialize', function () { + it('serializes an instance', async function () { + const output = await keyring.serialize(); + expect(output.page).toBe(0); + expect(output.hdPath).toBe(`m/44'/60'/0'/0`); + expect(Array.isArray(output.accounts)).toBe(true); + expect(output.accounts).toHaveLength(0); + }); + }); + + describe('deserialize', function () { + it('serializes what it deserializes', async function () { + const someHdPath = `m/44'/60'/0'/1`; + await keyring.deserialize({ + page: 10, + hdPath: someHdPath, + accounts: [], + }); + const serialized = await keyring.serialize(); + expect(serialized.accounts).toHaveLength(0); + expect(serialized.page).toBe(10); + expect(serialized.hdPath).toBe(someHdPath); + }); + }); + + describe('isUnlocked', function () { + it('should return true if we have a public key', function () { + expect(keyring.isUnlocked()).toBe(true); + }); + }); + + describe('unlock', function () { + it('should resolve if we have a public key', async function () { + expect(async () => { + await keyring.unlock(); + }).not.toThrow(); + }); + + it('should call OneKeyWebBridge.getPublicKey if we dont have a public key', async function () { + const getPassphraseStateStub = sinon.stub().resolves({ + success: true, + payload: '', + }); + const getPublicKeyStub = sinon.stub().resolves({ + success: true, + payload: { + publicKey: fakeHdKey.publicKey.toString('hex'), + chainCode: fakeHdKey.chainCode.toString('hex'), + }, + }); + bridge.getPassphraseState = getPassphraseStateStub; + bridge.getPublicKey = getPublicKeyStub; + + keyring.hdk = new HDKey(); + try { + await keyring.unlock(); + } catch { + // Since we only care about ensuring our function gets called, + // we want to ignore warnings due to stub data + } + + expect(getPublicKeyStub.calledOnce).toBe(true); + sinon.assert.calledWithExactly(getPublicKeyStub, { + showOnOneKey: false, + chainId: 1, + path: "m/44'/60'/0'", + passphraseState: '', + }); + }); + }); + + describe('setAccountToUnlock', function () { + it('should set unlockedAccount', function () { + keyring.setAccountToUnlock(3); + expect(keyring.unlockedAccount).toBe(3); + }); + }); + + describe('addAccounts', function () { + describe('with no arguments', function () { + it('returns a single account', async function () { + keyring.setAccountToUnlock(0); + const accounts = await keyring.addAccounts(1); + expect(accounts).toHaveLength(1); + }); + + it('returns the custom accounts desired', async function () { + keyring.setAccountToUnlock(0); + await keyring.addAccounts(1); + keyring.setAccountToUnlock(2); + await keyring.addAccounts(1); + + const accounts = await keyring.getAccounts(); + expect(accounts[0]).toBe(fakeAccounts[0]); + expect(accounts[1]).toBe(fakeAccounts[2]); + }); + }); + + describe('with a numeric argument', function () { + it('returns that number of accounts', async function () { + keyring.setAccountToUnlock(0); + const firstBatch = await keyring.addAccounts(3); + keyring.setAccountToUnlock(3); + const secondBatch = await keyring.addAccounts(2); + + expect(firstBatch).toHaveLength(3); + expect(secondBatch).toHaveLength(2); + }); + + it('returns the expected accounts', async function () { + keyring.setAccountToUnlock(0); + const firstBatch = await keyring.addAccounts(3); + keyring.setAccountToUnlock(3); + const secondBatch = await keyring.addAccounts(2); + + expect(firstBatch).toStrictEqual([ + fakeAccounts[0], + fakeAccounts[1], + fakeAccounts[2], + ]); + expect(secondBatch).toStrictEqual([fakeAccounts[3], fakeAccounts[4]]); + }); + }); + }); + + describe('removeAccount', function () { + describe('if the account exists', function () { + it('should remove that account', async function () { + keyring.setAccountToUnlock(0); + const accounts = await keyring.addAccounts(1); + expect(accounts).toHaveLength(1); + keyring.removeAccount(fakeAccounts[0]); + const accountsAfterRemoval = await keyring.getAccounts(); + expect(accountsAfterRemoval).toHaveLength(0); + }); + + it('should remove only the account requested', async function () { + keyring.setAccountToUnlock(0); + await keyring.addAccounts(1); + keyring.setAccountToUnlock(1); + await keyring.addAccounts(1); + + let accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(2); + + keyring.removeAccount(fakeAccounts[0]); + accounts = await keyring.getAccounts(); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toBe(fakeAccounts[1]); + }); + }); + + describe('if the account does not exist', function () { + it('should throw an error', function () { + const unexistingAccount = '0x0000000000000000000000000000000000000000'; + expect(() => { + keyring.removeAccount(unexistingAccount); + }).toThrow(`Address ${unexistingAccount} not found in this keyring`); + }); + }); + }); + + describe('getFirstPage', function () { + it('should set the currentPage to 1', async function () { + await keyring.getFirstPage(); + expect(keyring.page).toBe(1); + }); + + it('should return the list of accounts for current page', async function () { + const accounts = await keyring.getFirstPage(); + + expect(accounts).toHaveLength(keyring.perPage); + expect(accounts[0]?.address).toBe(fakeAccounts[0]); + expect(accounts[1]?.address).toBe(fakeAccounts[1]); + expect(accounts[2]?.address).toBe(fakeAccounts[2]); + expect(accounts[3]?.address).toBe(fakeAccounts[3]); + expect(accounts[4]?.address).toBe(fakeAccounts[4]); + }); + }); + + describe('getNextPage', function () { + it('should return the list of accounts for current page', async function () { + const accounts = await keyring.getNextPage(); + expect(accounts).toHaveLength(keyring.perPage); + expect(accounts[0]?.address).toBe(fakeAccounts[0]); + expect(accounts[1]?.address).toBe(fakeAccounts[1]); + expect(accounts[2]?.address).toBe(fakeAccounts[2]); + expect(accounts[3]?.address).toBe(fakeAccounts[3]); + expect(accounts[4]?.address).toBe(fakeAccounts[4]); + }); + + it('should be able to advance to the next page', async function () { + // manually advance 1 page + await keyring.getNextPage(); + + const accounts = await keyring.getNextPage(); + expect(accounts).toHaveLength(keyring.perPage); + expect(accounts[0]?.address).toBe(fakeAccounts[keyring.perPage + 0]); + expect(accounts[1]?.address).toBe(fakeAccounts[keyring.perPage + 1]); + expect(accounts[2]?.address).toBe(fakeAccounts[keyring.perPage + 2]); + expect(accounts[3]?.address).toBe(fakeAccounts[keyring.perPage + 3]); + expect(accounts[4]?.address).toBe(fakeAccounts[keyring.perPage + 4]); + }); + }); + + describe('getPreviousPage', function () { + it('should return the list of accounts for current page', async function () { + // manually advance 1 page + await keyring.getNextPage(); + const accounts = await keyring.getPreviousPage(); + + expect(accounts).toHaveLength(keyring.perPage); + expect(accounts[0]?.address).toBe(fakeAccounts[0]); + expect(accounts[1]?.address).toBe(fakeAccounts[1]); + expect(accounts[2]?.address).toBe(fakeAccounts[2]); + expect(accounts[3]?.address).toBe(fakeAccounts[3]); + expect(accounts[4]?.address).toBe(fakeAccounts[4]); + }); + + it('should be able to go back to the previous page', async function () { + // manually advance 1 page + await keyring.getNextPage(); + const accounts = await keyring.getPreviousPage(); + + expect(accounts).toHaveLength(keyring.perPage); + expect(accounts[0]?.address).toBe(fakeAccounts[0]); + expect(accounts[1]?.address).toBe(fakeAccounts[1]); + expect(accounts[2]?.address).toBe(fakeAccounts[2]); + expect(accounts[3]?.address).toBe(fakeAccounts[3]); + expect(accounts[4]?.address).toBe(fakeAccounts[4]); + }); + }); + + describe('getAccounts', function () { + const accountIndex = 5; + let accounts: string[] = []; + beforeEach(async function () { + keyring.setAccountToUnlock(accountIndex); + await keyring.addAccounts(1); + accounts = (await keyring.getAccounts()) as string[]; + }); + + it('returns an array of accounts', function () { + expect(Array.isArray(accounts)).toBe(true); + expect(accounts).toHaveLength(1); + }); + + it('returns the expected', function () { + const expectedAccount = fakeAccounts[accountIndex]; + expect(accounts[0]).toBe(expectedAccount); + }); + }); + + describe('signTransaction', function () { + it('should pass serialized newer transaction to onekey and return signed tx', async function () { + sinon.stub(TransactionFactory, 'fromTxData').callsFake(() => { + // without having a private key/public key pair in this test, we have + // mock out this method and return the original tx because we can't + // replicate r and s values without the private key. + return newFakeTx; + }); + + const ethereumSignTransactionStub = sinon.stub().resolves({ + success: true, + payload: { v: '0x25', r: '0x0', s: '0x0' }, + }); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + + sinon + .stub(newFakeTx, 'getSenderAddress') + .callsFake(() => Address.fromString(fakeAccounts[0])); + sinon.stub(newFakeTx, 'verifySignature').callsFake(() => true); + + const returnedTx = await keyring.signTransaction( + fakeAccounts[0], + newFakeTx, + ); + // ensure we get a new version transaction back + // @ts-expect-error: intentionally using an old library that doesn't comply with TypedTransaction + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((returnedTx as EthereumTx).getChainId).toBeUndefined(); + expect(returnedTx.common.chainId().toString(16)).toBe('1'); + expect(ethereumSignTransactionStub.calledOnce).toBe(true); + }); + + it('should pass serialized contract deployment transaction to onekey and return signed tx', async function () { + sinon.stub(TransactionFactory, 'fromTxData').callsFake(() => { + // without having a private key/public key pair in this test, we have + // mock out this method and return the original tx because we can't + // replicate r and s values without the private key. + return contractDeploymentFakeTx; + }); + + const ethereumSignTransactionStub = sinon.stub().resolves({ + success: true, + payload: { v: '0x25', r: '0x0', s: '0x0' }, + }); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + + sinon + .stub(contractDeploymentFakeTx, 'getSenderAddress') + .callsFake(() => Address.fromString(fakeAccounts[0])); + + sinon + .stub(contractDeploymentFakeTx, 'verifySignature') + .callsFake(() => true); + + const returnedTx = await keyring.signTransaction( + fakeAccounts[0], + contractDeploymentFakeTx, + ); + // ensure we get a new version transaction back + // @ts-expect-error: intentionally using an old library that doesn't comply with TypedTransaction + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((returnedTx as EthereumTx).getChainId).toBeUndefined(); + expect(returnedTx.common.chainId().toString(16)).toBe('1'); + expect(ethereumSignTransactionStub.calledOnce).toBe(true); + expect(ethereumSignTransactionStub.getCall(0).args[0]).toStrictEqual({ + passphraseState: '', + useEmptyPassphrase: true, + path: `m/44'/60'/0'/0/0`, + transaction: { + ...contractDeploymentFakeTx.toJSON(), + to: '0x', + chainId: 1, + }, + }); + }); + + it('should pass correctly encoded EIP1559 transaction to onekey and return signed tx', async function () { + // Copied from @MetaMask/eth-ledger-bridge-keyring + // Generated by signing fakeTypeTwoTx with an unknown private key + const expectedRSV = { + v: '0x0', + r: '0x5ffb3adeaec80e430e7a7b02d95c5108b6f09a0bdf3cf69869dc1b38d0fb8d3a', + s: '0x28b234a5403d31564e18258df84c51a62683e3f54fa2b106fdc1a9058006a112', + }; + // Override actual address of 0x391535104b6e0Ea6dDC2AD0158aB3Fbd7F04ed1B + const fromTxDataStub = sinon.stub(TransactionFactory, 'fromTxData'); + fromTxDataStub.callsFake((...args) => { + const tx = fromTxDataStub.wrappedMethod(...args); + sinon + .stub(tx, 'getSenderAddress') + .returns(Address.fromString(fakeAccounts[0])); + return tx; + }); + + const ethereumSignTransactionStub = sinon.stub().resolves({ + success: true, + payload: expectedRSV, + }); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + + const returnedTx = await keyring.signTransaction( + fakeAccounts[0], + fakeTypeTwoTx, + ); + + expect(ethereumSignTransactionStub.calledOnce).toBe(true); + sinon.assert.calledWithExactly(ethereumSignTransactionStub, { + passphraseState: '', + useEmptyPassphrase: true, + path: "m/44'/60'/0'/0/0", + transaction: { + type: '0x2', + chainId: 1, + nonce: '0x0', + maxPriorityFeePerGas: '0x9184e72a000', + maxFeePerGas: '0x19184e72a000', + gasLimit: '0x2710', + to: '0x0000000000000000000000000000000000000000', + value: '0x0', + data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + accessList: [], + v: '0x1', + r: undefined, + s: undefined, + }, + }); + + expect(returnedTx.toJSON()).toStrictEqual({ + ...fakeTypeTwoTx.toJSON(), + ...expectedRSV, + }); + }); + + it('should throw when transaction is signed with the wrong address', async function () { + sinon.stub(TransactionFactory, 'fromTxData').callsFake(() => newFakeTx); + const ethereumSignTransactionStub = sinon.stub().resolves({ + success: true, + payload: { v: '0x1', r: '0x2', s: '0x3' }, + }); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + sinon + .stub(newFakeTx, 'getSenderAddress') + .callsFake(() => Address.fromString(fakeAccounts[1])); + sinon.stub(newFakeTx, 'verifySignature').callsFake(() => true); + + try { + await keyring.signTransaction(fakeAccounts[0], newFakeTx); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain( + "signature doesn't match the right address", + ); + } + }); + + it('should throw when the hardware returns an error during signing', async function () { + sinon.stub(TransactionFactory, 'fromTxData').callsFake(() => newFakeTx); + const ethereumSignTransactionStub = sinon.stub().resolves({ + success: false, + payload: { error: 'Transaction failed' }, + }); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + + try { + await keyring.signTransaction(fakeAccounts[0], newFakeTx); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Transaction failed'); + } + }); + + it('should surface transport rejection errors during signing', async function () { + sinon.stub(TransactionFactory, 'fromTxData').callsFake(() => newFakeTx); + const ethereumSignTransactionStub = sinon + .stub() + .rejects(new Error('Transport down')); + bridge.ethereumSignTransaction = ethereumSignTransactionStub; + + try { + await keyring.signTransaction(fakeAccounts[0], newFakeTx); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Transport down'); + } + }); + }); + + describe('signMessage', function () { + it('should call onekeyConnect.ethereumSignMessage', async function () { + const ethereumSignMessageStub = sinon.stub().resolves({}); + bridge.ethereumSignMessage = ethereumSignMessageStub; + + try { + await keyring.signMessage(fakeAccounts[0], 'some msg'); + } catch { + // Since we only care about ensuring our function gets called, + // we want to ignore warnings due to stub data + } + + expect(ethereumSignMessageStub.calledOnce).toBe(true); + }); + }); + + describe('signPersonalMessage', function () { + it('should call onekeyConnect.ethereumSignMessage', async function () { + const ethereumSignMessageStub = sinon.stub().resolves({}); + bridge.ethereumSignMessage = ethereumSignMessageStub; + + try { + await keyring.signPersonalMessage(fakeAccounts[0], 'some msg'); + } catch { + // Since we only care about ensuring our function gets called, + // we want to ignore warnings due to stub data + } + + expect(ethereumSignMessageStub.calledOnce).toBe(true); + }); + }); + + describe('signTypedData', function () { + it('should call onekeyConnect.ethereumSignTypedData', async function () { + recoverTypedSignatureMock.mockImplementationOnce(() => fakeAccounts[0]); + const ethereumSignTypedDataStub = sinon.stub().resolves({ + success: true, + payload: { signature: '00', address: fakeAccounts[0] }, + }); + bridge.ethereumSignTypedData = ethereumSignTypedDataStub; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore next-line + // eslint-disable-next-line no-invalid-this + this.timeout = 60000; + await keyring.signTypedData( + fakeAccounts[0], + // Message with missing data that @metamask/eth-sig-util accepts + { + types: { EIP712Domain: [], EmptyMessage: [] }, + primaryType: 'EmptyMessage', + domain: {}, + message: {}, + }, + { version: SignTypedDataVersion.V4 }, + ); + + expect(ethereumSignTypedDataStub.calledOnce).toBe(true); + sinon.assert.calledWithExactly(ethereumSignTypedDataStub, { + passphraseState: '', + useEmptyPassphrase: true, + path: "m/44'/60'/0'/0/0", + data: { + // Empty message that onekey-connect/EIP-712 spec accepts + types: { EIP712Domain: [], EmptyMessage: [] }, + primaryType: 'EmptyMessage', + domain: {}, + message: {}, + }, + metamaskV4Compat: true, + domainHash: + '6192106f129ce05c9075d319c1fa6ea9b3ae37cbd0c1ef92e2be7137bb07baa1', + messageHash: + 'c9e71eb57cf9fa86ec670283b58cb15326bb6933c8d8e2ecb2c0849021b3ef42', + }); + }); + + it('should throw when typed data signing fails', async function () { + const ethereumSignTypedDataStub = sinon.stub().resolves({ + success: false, + payload: { error: 'Typed data failed' }, + }); + bridge.ethereumSignTypedData = ethereumSignTypedDataStub; + + try { + await keyring.signTypedData( + fakeAccounts[0], + { + types: { EIP712Domain: [], EmptyMessage: [] }, + primaryType: 'EmptyMessage', + domain: {}, + message: {}, + }, + { version: SignTypedDataVersion.V4 }, + ); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Typed data failed'); + } + }); + + it('should throw when typed data address mismatches', async function () { + recoverTypedSignatureMock.mockImplementationOnce(() => fakeAccounts[1]); + const ethereumSignTypedDataStub = sinon.stub().resolves({ + success: true, + payload: { + signature: '1122', + address: fakeAccounts[1], + }, + }); + bridge.ethereumSignTypedData = ethereumSignTypedDataStub; + + try { + await keyring.signTypedData( + fakeAccounts[0], + { + types: { EIP712Domain: [], EmptyMessage: [] }, + primaryType: 'EmptyMessage', + domain: {}, + message: {}, + }, + { version: SignTypedDataVersion.V4 }, + ); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain( + 'signature doesnt match the right address', + ); + } + }); + }); + + describe('misc utility coverage', function () { + it('should expose model info and reset HDKey on lock', function () { + bridge.model = 'OneKey Pro'; + expect(keyring.getModel()).toBe('OneKey Pro'); + + keyring.hdk.publicKey = fakeHdKey.publicKey; + expect(keyring.isUnlocked()).toBe(true); + keyring.lock(); + expect(keyring.isUnlocked()).toBe(false); + }); + + it('should delegate updateTransportMethod to the bridge', async function () { + const updateStub = sinon.stub().resolves(); + bridge.updateTransportMethod = updateStub; + + await keyring.updateTransportMethod('webusb'); + + expect(updateStub.calledOnce).toBe(true); + sinon.assert.calledWithExactly(updateStub, 'webusb'); + }); + }); + + describe('forgetDevice', function () { + it('should clear the content of the keyring', async function () { + // Add an account + keyring.setAccountToUnlock(0); + await keyring.addAccounts(1); + + // Wipe the keyring + keyring.forgetDevice(); + + const accounts = await keyring.getAccounts(); + + expect(keyring.isUnlocked()).toBe(false); + expect(accounts).toHaveLength(0); + }); + }); + + describe('setHdPath', function () { + const initialProperties = { + hdPath: `m/44'/60'/0'/0` as const, + accounts: [fakeAccounts[0]], + page: 2, + }; + + // hdPath?: string; + // accounts?: string[]; + // accountDetails?: Readonly>; + // page?: number; + // passphraseState?: string; + + const accountToUnlock = 1; + + const mockPaths: Record = { + '0x123': { + index: 0, + hdPath: `m/44'/60'/0'/0`, + passphraseState: '123', + }, + }; + + beforeEach(async function () { + await keyring.deserialize(initialProperties); + // eslint-disable-next-line require-atomic-updates + keyring.accountDetails = mockPaths; + keyring.setAccountToUnlock(accountToUnlock); + }); + + it('should do nothing if passed an hdPath equal to the current hdPath', async function () { + keyring.setHdPath(initialProperties.hdPath); + expect(keyring.hdPath).toBe(initialProperties.hdPath); + expect(keyring.accounts).toStrictEqual(initialProperties.accounts); + expect(keyring.page).toBe(initialProperties.page); + expect(keyring.hdk.publicKey.toString('hex')).toBe( + fakeHdKey.publicKey.toString('hex'), + ); + expect(keyring.unlockedAccount).toBe(accountToUnlock); + expect(keyring.accountDetails).toStrictEqual(mockPaths); + }); + + it('should update the hdPath and reset account and page properties if passed a new hdPath', async function () { + const ledgerLegacyHdPathString = `m/44'/60'/0'/x`; + + keyring.setHdPath(ledgerLegacyHdPathString); + + expect(keyring.hdPath).toBe(ledgerLegacyHdPathString); + expect(keyring.accounts).toStrictEqual([]); + expect(keyring.page).toBe(0); + expect(keyring.perPage).toBe(5); + expect(keyring.hdk.publicKey).toBeNull(); + expect(keyring.unlockedAccount).toBe(0); + expect(keyring.accountDetails).toStrictEqual({}); + }); + + it('should throw an error if passed an ledger live hdPath', async function () { + const unsupportedPath = "m/44'/60'/x'/0/0"; + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore next-line + keyring.setHdPath(unsupportedPath); + }).toThrow(`Unknown HD path`); + }); + + it('should throw an error if passed an unsupported hdPath', async function () { + const unsupportedPath = 'unsupported hdPath'; + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore next-line + keyring.setHdPath(unsupportedPath); + }).toThrow(`Unknown HD path`); + }); + }); + + describe('error handling and edge cases', function () { + it('should handle message signing errors', async function () { + await keyring.addAccounts(1); + const errorResponse = { + success: false, + payload: { error: 'Message signing failed' }, + }; + bridge.ethereumSignMessage = sinon.stub().resolves(errorResponse); + + try { + await keyring.signPersonalMessage(fakeAccounts[0], '0x68656c6c6f'); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Message signing failed'); + } + }); + + it('should handle address verification mismatch in signing', async function () { + await keyring.addAccounts(1); + const wrongAddress = '0x1234567890123456789012345678901234567890'; + recoverPersonalSignatureMock.mockImplementationOnce(() => wrongAddress); + const successResponse = { + success: true, + payload: { + signature: + '0xda70d2075651160d9171f4a1a3bd9723871282e17be7a8e870556027c98c74f75fe69d0e897e7c5b5d1cde915e885002b62e2dfc80c9e9142b6d3b2070778d2d1c', + v: '0x1', + r: '0x0', + s: '0x0', + address: wrongAddress, + }, + }; + bridge.ethereumSignMessage = sinon.stub().resolves(successResponse); + + try { + await keyring.signPersonalMessage(fakeAccounts[0], '0x68656c6c6f'); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain( + 'signature doesnt match the right address', + ); + } + }); + + it('should propagate transport rejection when signing personal messages', async function () { + await keyring.addAccounts(1); + bridge.ethereumSignMessage = sinon + .stub() + .rejects(new Error('transport failed')); + + try { + await keyring.signPersonalMessage(fakeAccounts[0], '0x68656c6c6f'); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('transport failed'); + } + }); + + it('should throw when account details are missing', async function () { + try { + await keyring.signPersonalMessage(fakeAccounts[1], '0x68656c6c6f'); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Unknown address'); + } + }); + + it('should handle getPreviousPage when already on first page', async function () { + keyring.page = 0; + const accounts = await keyring.getPreviousPage(); + expect(accounts).toHaveLength(keyring.perPage); + expect(keyring.page).toBe(1); // When page <= 0, it gets set to 1 + }); + + it('should handle unlock getPublicKey failure', async function () { + const getPassphraseStateStub = sinon.stub().resolves({ + success: true, + payload: '', + }); + const getPublicKeyStub = sinon.stub().resolves({ + success: false, + payload: { error: 'Failed to get public key' }, + }); + bridge.getPassphraseState = getPassphraseStateStub; + bridge.getPublicKey = getPublicKeyStub; + + keyring.hdk = new HDKey(); + + try { + await keyring.unlock(); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('getPublicKey failed'); + } + }); + + it('should handle unlock getPassphraseState failure', async function () { + const getPassphraseStateStub = sinon.stub().resolves({ + success: false, + payload: { error: 'Failed to get passphrase state' }, + }); + bridge.getPassphraseState = getPassphraseStateStub; + + keyring.hdk = new HDKey(); + + try { + await keyring.unlock(); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain( + 'Failed to get passphrase state', + ); + } + }); + + it('should handle unlock getPublicKey exception', async function () { + const getPassphraseStateStub = sinon.stub().resolves({ + success: true, + payload: '', + }); + const getPublicKeyStub = sinon.stub().rejects(new Error('Network error')); + bridge.getPassphraseState = getPassphraseStateStub; + bridge.getPublicKey = getPublicKeyStub; + + keyring.hdk = new HDKey(); + + try { + await keyring.unlock(); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Network error'); + } + }); + + it('should handle unlock getPassphraseState exception', async function () { + const getPassphraseStateStub = sinon + .stub() + .rejects(new Error('Connection error')); + bridge.getPassphraseState = getPassphraseStateStub; + + keyring.hdk = new HDKey(); + + try { + await keyring.unlock(); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Connection error'); + } + }); + }); + + describe('HD path validation edge cases', function () { + it('should handle Ledger Live HD path correctly', function () { + const ledgerLiveHdPath = "m/44'/60'/0'/x"; + keyring.setHdPath(ledgerLiveHdPath); + expect(keyring.hdPath).toBe(ledgerLiveHdPath); + }); + + it('should handle standard BIP44 HD path correctly', function () { + const standardHdPath = "m/44'/60'/0'/0/x"; + keyring.setHdPath(standardHdPath); + expect(keyring.hdPath).toBe(standardHdPath); + }); + + it('should handle default HD path correctly', function () { + const defaultPath = "m/44'/60'/0'/0"; + keyring.setHdPath(defaultPath); + expect(keyring.hdPath).toBe(defaultPath); + }); + + it('should handle different HD path formats', function () { + // Test different HD path validations + const ledgerLiveHdPath = "m/44'/60'/0'/x"; + keyring.setHdPath(ledgerLiveHdPath); + expect(keyring.hdPath).toBe(ledgerLiveHdPath); + + const standardHdPath = "m/44'/60'/0'/0/x"; + keyring.setHdPath(standardHdPath); + expect(keyring.hdPath).toBe(standardHdPath); + }); + }); + + describe('account filtering', function () { + beforeEach(async function () { + keyring.setAccountToUnlock(0); + await keyring.addAccounts(5); + }); + + it('should handle removeAccount with all accounts removed', function () { + const allAccounts = [...keyring.accounts]; + + allAccounts.forEach((account) => { + keyring.removeAccount(account); + }); + + expect(keyring.accounts).toHaveLength(0); + expect(Object.keys(keyring.accountDetails)).toHaveLength(0); + }); + + it('should handle removeAccount with non-existent account', function () { + const nonExistentAccount = '0x1234567890123456789012345678901234567890'; + + expect(() => { + keyring.removeAccount(nonExistentAccount); + }).toThrow( + 'Address 0x1234567890123456789012345678901234567890 not found in this keyring', + ); + }); + }); + + describe('transaction serialization edge cases', function () { + it('should test hex prefix utilities', async function () { + // Test the serialize method + const serialized = await keyring.serialize(); + expect(serialized.hdPath).toMatch(/^m\//u); // Should start with m/ + }); + }); + + describe('HD path private method coverage', function () { + it('should handle standard BIP44 path variations', async function () { + keyring.setHdPath("m/44'/60'/0'/0/x"); + await keyring.unlock(); + await keyring.addAccounts(1); + expect(keyring.accounts).toHaveLength(1); + + keyring.setHdPath("m/44'/60'/0'/0"); + await keyring.unlock(); + await keyring.addAccounts(1); + expect(keyring.accounts.length).toBeGreaterThan(0); + }); + + it('should handle HD path comparison logic', async function () { + expect(keyring.hdPath).toBe("m/44'/60'/0'/0"); + expect(keyring.accounts).toHaveLength(0); + + await keyring.addAccounts(2); + keyring.setHdPath("m/44'/60'/0'/0/x"); + + expect(keyring.hdPath).toBe("m/44'/60'/0'/0/x"); + expect(keyring.accounts).toHaveLength(2); + + keyring.setHdPath("m/44'/60'/0'/0"); + expect(keyring.hdPath).toBe("m/44'/60'/0'/0"); + expect(keyring.accounts).toHaveLength(2); + + keyring.setHdPath("m/44'/60'/0'/x"); + expect(keyring.hdPath).toBe("m/44'/60'/0'/x"); + expect(keyring.accounts).toHaveLength(0); + }); + }); + + describe('additional edge cases for coverage', function () { + it('should handle forgetDevice', function () { + keyring.forgetDevice(); + expect(keyring.accounts).toHaveLength(0); + expect(keyring.page).toBe(0); + expect(keyring.unlockedAccount).toBe(0); + expect(Object.keys(keyring.accountDetails)).toHaveLength(0); + }); + + it('should handle isUnlocked when not unlocked', function () { + keyring.hdk = new HDKey(); + expect(keyring.isUnlocked()).toBe(false); + }); + + it('should handle different passphrase states', function () { + keyring.passphraseState = ''; + expect(keyring.passphraseState).toBe(''); + + keyring.passphraseState = undefined; + expect(keyring.passphraseState).toBeUndefined(); + }); + + it('should handle addHexPrefix utility function', function () { + const testMessage = 'hello world'; + const messageHex = Buffer.from(testMessage, 'utf8').toString('hex'); + + // These calls will use add hex prefix indirectly + // eslint-disable-next-line jest/no-restricted-matchers + expect(messageHex).toBeTruthy(); + }); + + it('should handle getName method', function () { + expect(keyring.getName()).toBe('OneKey Hardware'); + }); + + it('should handle init bridge method', async function () { + const initSpy = sinon.stub(bridge, 'init').resolves(); + + await keyring.init(); + expect(initSpy.calledOnce).toBe(true); + + // Test destroy method by calling keyring destroy + await keyring.destroy(); + }); + + it('should handle getNextPage and getPreviousPage correctly', async function () { + const nextPageAccounts = await keyring.getNextPage(); + expect(nextPageAccounts).toHaveLength(keyring.perPage); + expect(keyring.page).toBeGreaterThan(0); + + const prevPageAccounts = await keyring.getPreviousPage(); + expect(prevPageAccounts).toHaveLength(keyring.perPage); + }); + + it('should handle signMessage method', async function () { + await keyring.addAccounts(1); + const expectedSignature = '0xsignature123'; + const signPersonalMessageStub = sinon + .stub(keyring, 'signPersonalMessage') + .resolves(expectedSignature); + + const result = await keyring.signMessage(fakeAccounts[0], '0x68656c6c6f'); + expect(result).toBe(expectedSignature); + expect( + signPersonalMessageStub.calledWith(fakeAccounts[0], '0x68656c6c6f'), + ).toBe(true); + }); + + it('should handle basic unlock scenarios', async function () { + const accounts = await keyring.getAccounts(); + expect(accounts).toStrictEqual([]); + }); + + it('should handle addAccounts error scenarios', async function () { + const unlockStub = sinon + .stub(keyring, 'unlock') + .rejects(new Error('Unlock failed')); + + try { + await keyring.addAccounts(1); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect((error as Error).message).toContain('Unlock failed'); + } + + unlockStub.restore(); + }); + + it('should handle bridge constructor error', function () { + expect(() => { + // eslint-disable-next-line no-new + new OneKeyKeyring({ bridge: undefined as unknown as OneKeyBridge }); + }).toThrow('Bridge is a required dependency for the keyring'); + }); + + describe('HD path variations', function () { + it('should handle Ledger Legacy HD path in setHdPath', function () { + // Cover branches in #isSameHdPath for Ledger Legacy + keyring.setHdPath("m/44'/60'/0'/x"); + expect(keyring.hdPath).toBe("m/44'/60'/0'/x"); + + // Setting the same path should trigger #isSameHdPath but not reset + const originalHdk = keyring.hdk; + keyring.setHdPath("m/44'/60'/0'/x"); // This should call #isSameHdPath and return true + expect(keyring.hdk).toBe(originalHdk); // HDKey should not be reset + }); + + it('should handle path comparison between different Ledger Legacy paths', function () { + // Cover line 687-688 in #isSameHdPath + keyring.setHdPath("m/44'/60'/0'/x"); + + // Change to different Ledger Legacy path - should reset HDKey + keyring.setHdPath("m/44'/60'/0'/x"); + expect(keyring.hdPath).toBe("m/44'/60'/0'/x"); + }); + + it('should handle Standard BIP44 path variations in setHdPath', function () { + // Cover branches in #isSameHdPath for standard BIP44 + keyring.setHdPath("m/44'/60'/0'/0/x"); + expect(keyring.hdPath).toBe("m/44'/60'/0'/0/x"); + + // Test equivalence with defaultHdPath - should trigger #isSameHdPath + keyring.setHdPath("m/44'/60'/0'/0"); // Should be considered same as m/44'/60'/0'/0/x + expect(keyring.hdPath).toBe("m/44'/60'/0'/0"); + }); + + it('should handle default path comparison in #isSameHdPath', function () { + // Cover line 694: return this.hdPath === newHdPath; + // Directly set hdPath to test custom path logic + keyring.hdPath = "m/44'/60'/1'/2/3"; // Custom path not in predefined categories + + const originalHdk = keyring.hdk; + keyring.setHdPath("m/44'/60'/0'/0"); // Different path should reset + expect(keyring.hdPath).toBe("m/44'/60'/0'/0"); + expect(keyring.hdk).not.toBe(originalHdk); // HDKey should be reset + }); + + it('should handle Ledger Legacy path in addAccounts workflow', async function () { + // This will trigger #getPathForIndex with Ledger Legacy path (line 660) + keyring.setHdPath("m/44'/60'/0'/x"); + + // Set up successful unlock + const unlockResult = 'unlocked'; + const unlockSpy = sinon.stub(keyring, 'unlock').resolves(unlockResult); + + keyring.hdk = fakeHdKey; + + const accounts = await keyring.addAccounts(1); + expect(accounts).toHaveLength(1); + + unlockSpy.restore(); + }); + + it('should handle custom path in getPathForIndex', async function () { + // Cover line 668: return `${this.hdPath}/${index}`; + // Directly set hdPath to bypass ALLOWED_HD_PATHS check + keyring.hdPath = "m/44'/60'/1'/2"; // Custom path that doesn't match predefined patterns + + const unlockResult = 'unlocked'; + const unlockSpy = sinon.stub(keyring, 'unlock').resolves(unlockResult); + + keyring.hdk = fakeHdKey; + + const accounts = await keyring.addAccounts(1); + expect(accounts).toHaveLength(1); + + unlockSpy.restore(); + }); + + it('should handle same custom path in #isSameHdPath', function () { + // Cover line 694: return this.hdPath === newHdPath; when custom paths are the same + keyring.hdPath = "m/44'/60'/5'/6"; // Custom path + + const originalHdk = keyring.hdk; + keyring.setHdPath("m/44'/60'/0'/0"); // Change to allowed path + expect(keyring.hdPath).toBe("m/44'/60'/0'/0"); + expect(keyring.hdk).not.toBe(originalHdk); // HDKey should be reset + }); + + it('should handle Ledger Live HD path errors', async function () { + // Cover lines 641 and 648: throw new Error('Ledger Live is not supported'); + keyring.hdPath = "m/44'/60'/x'/0/0"; // Ledger Live path (not in ALLOWED_HD_PATHS but we set directly) + + const unlockSpy = sinon.stub(keyring, 'unlock').resolves('unlocked'); + keyring.hdk = fakeHdKey; + + // This should trigger the Ledger Live error paths during addAccounts + try { + await keyring.addAccounts(1); + // If we get here, the test setup was wrong + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toContain( + 'Ledger Live is not supported', + ); + } + + unlockSpy.restore(); + }); + }); + }); +}); diff --git a/packages/keyring-eth-onekey/src/onekey-keyring.ts b/packages/keyring-eth-onekey/src/onekey-keyring.ts new file mode 100644 index 00000000..842ee752 --- /dev/null +++ b/packages/keyring-eth-onekey/src/onekey-keyring.ts @@ -0,0 +1,620 @@ +import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; +import { TransactionFactory } from '@ethereumjs/tx'; +import * as ethUtil from '@ethereumjs/util'; +import type { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; +import { + SignTypedDataVersion, + TypedDataUtils, + recoverPersonalSignature, + recoverTypedSignature, +} from '@metamask/eth-sig-util'; +import type { Keyring } from '@metamask/keyring-utils'; +import type { Hex } from '@metamask/utils'; +import type { + ConnectSettings, + EthereumSignTypedDataMessage, + EthereumSignTypedDataTypes, + EVMSignedTx, + EVMSignTransactionParams, +} from '@onekeyfe/hd-core'; +// eslint-disable-next-line @typescript-eslint/no-shadow, n/prefer-global/buffer +import { Buffer } from 'buffer'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import HDKey from 'hdkey'; + +import type { OneKeyBridge } from './onekey-bridge'; + +const pathBase = 'm'; +const defaultHdPath = `${pathBase}/44'/60'/0'/0`; +const keyringType = 'OneKey Hardware'; + +const hdPathString = `m/44'/60'/0'/0/x`; +const ledgerLegacyHdPathString = `m/44'/60'/0'/x`; + +const ALLOWED_HD_PATHS: Record = { + [defaultHdPath]: true, + [hdPathString]: true, + [ledgerLegacyHdPathString]: true, +} as const; + +export type AccountDetails = { + index?: number; + hdPath: string; + passphraseState?: string | undefined; +}; + +export type AccountPageEntry = { + address: string; + balance: number | null; + index: number; +}; + +export type AccountPage = AccountPageEntry[]; + +export type OneKeyControllerOptions = { + hdPath?: string; + accounts?: Hex[]; + accountDetails?: Readonly>; + page?: number; + passphraseState?: string; + // onUIEvent?: (event: HardwareUIEvent) => void; +}; + +export type OneKeyControllerState = { + hdPath: string; + accounts: string[]; + accountDetails: Record; + page: number; + passphraseState?: string; +}; + +/** + * Check if the given value has a hex prefix. + * + * @param value - The value to check. + * @returns Returns `true` if the value has a hex prefix. + */ +function hasHexPrefix(value: string): boolean { + return value.startsWith('0x'); +} + +/** + * Add a hex prefix to the given value. + * + * @param value - The value to add a hex prefix to. + * @returns Returns the value with a hex prefix. + */ +function addHexPrefix(value: string): string { + if (hasHexPrefix(value)) { + return value; + } + return `0x${value}`; +} + +/** + * Check if the passphrase state is empty. + * + * @param passphraseState - The passphrase state to check. + * @returns Returns `true` if the passphrase state is empty. + */ +function isEmptyPassphrase(passphraseState: string | undefined): boolean { + return ( + passphraseState === null || + passphraseState === undefined || + passphraseState === '' + ); +} + +export class OneKeyKeyring implements Keyring { + readonly type: string = keyringType; + + static type: string = keyringType; + + page = 0; + + perPage = 5; + + unlockedAccount = 0; + + hdk = new HDKey(); + + accounts: readonly Hex[] = []; + + accountDetails: Record = {}; + + passphraseState: string | undefined; + + hdPath = defaultHdPath; + + readonly bridge: OneKeyBridge; + + constructor({ bridge }: { bridge: OneKeyBridge }) { + if (!bridge) { + throw new Error('Bridge is a required dependency for the keyring'); + } + + this.bridge = bridge; + } + + async init(): Promise { + return this.bridge.init(); + } + + async destroy(): Promise { + return this.bridge.dispose(); + } + + async serialize(): Promise { + return { + hdPath: this.hdPath, + accounts: [...this.accounts], + accountDetails: { ...this.accountDetails }, + page: this.page, + }; + } + + async deserialize(state: OneKeyControllerOptions): Promise { + this.hdPath = state.hdPath ?? defaultHdPath; + this.accounts = state.accounts ?? []; + this.accountDetails = state.accountDetails ?? {}; + this.page = state.page ?? 0; + } + + getModel(): string | undefined { + return this.bridge.model; + } + + setAccountToUnlock(index: number): void { + this.unlockedAccount = index; + } + + setHdPath(hdPath: string): void { + if (!ALLOWED_HD_PATHS[hdPath]) { + throw new Error('Unknown HD path'); + } + + // Reset HDKey if the path changes + if (!this.#isSameHdPath(hdPath)) { + this.hdk = new HDKey(); + this.accounts = []; + this.page = 0; + this.perPage = 5; + this.unlockedAccount = 0; + this.accountDetails = {}; + } + this.hdPath = hdPath; + } + + lock(): void { + this.hdk = new HDKey(); + } + + isUnlocked(): boolean { + return Boolean(this.hdk?.publicKey); + } + + async unlock(): Promise { + if (this.isUnlocked()) { + return 'already unlocked'; + } + + return new Promise((resolve, reject) => { + // eslint-disable-next-line no-void + void this.bridge + .getPassphraseState() + .then((passphraseResponse) => { + if (!passphraseResponse.success) { + throw new Error( + passphraseResponse.payload?.error || 'Unknown error', + ); + } + this.passphraseState = passphraseResponse.payload; + + // eslint-disable-next-line no-void + void this.bridge + .getPublicKey({ + showOnOneKey: false, + chainId: 1, + path: this.#getBasePath(), + passphraseState: this.passphraseState ?? '', + }) + .then(async (res) => { + if (res.success) { + this.hdk.publicKey = Buffer.from(res.payload.publicKey, 'hex'); + this.hdk.chainCode = Buffer.from(res.payload.chainCode, 'hex'); + resolve('just unlocked'); + } else { + reject(new Error('getPublicKey failed')); + } + }) + .catch((error) => { + reject(new Error(error?.toString() || 'Unknown error')); + }); + }) + .catch((error) => { + reject(new Error(error?.toString() || 'Unknown error')); + }); + }); + } + + async addAccounts(numberOfAccounts = 1): Promise { + await this.unlock(); + + const from = this.unlockedAccount; + const to = from + numberOfAccounts; + const newAccounts: Hex[] = []; + + for (let i = from; i < to; i++) { + const address = this.#addressFromIndex(i); + const hdPath = this.#getPathForIndex(i); + if (typeof address === 'undefined') { + throw new Error('Unknown error'); + } + if (!this.accounts.includes(address)) { + this.accounts = [...this.accounts, address]; + newAccounts.push(address); + } + if (!this.accountDetails[address]) { + this.accountDetails[address] = { + index: i, + hdPath, + passphraseState: this.passphraseState, + }; + } + this.page = 0; + } + + return newAccounts; + } + + getName(): string { + return keyringType; + } + + async getFirstPage(): Promise { + this.page = 0; + return this.#getPage(1); + } + + async getNextPage(): Promise { + return this.#getPage(1); + } + + async getPreviousPage(): Promise { + return this.#getPage(-1); + } + + async getAccounts(): Promise { + return Promise.resolve(this.accounts.slice()); + } + + removeAccount(address: string): void { + const filteredAccounts = this.accounts.filter( + (a) => a.toLowerCase() !== address.toLowerCase(), + ); + + if (filteredAccounts.length === this.accounts.length) { + throw new Error(`Address ${address} not found in this keyring`); + } + + this.accounts = filteredAccounts; + delete this.accountDetails[ethUtil.toChecksumAddress(address)]; + } + + async updateTransportMethod( + transportType: ConnectSettings['env'], + ): Promise { + return this.bridge.updateTransportMethod(transportType); + } + + #normalize(buffer: Buffer): string { + return ethUtil.bytesToHex(new Uint8Array(buffer)); + } + + /** + * Signs a transaction using OneKey. + * + * Accepts either an ethereumjs-tx or @ethereumjs/tx transaction, and returns + * the same type. + * + * @param address - Hex string address. + * @param tx - Instance of either new-style or old-style ethereumjs transaction. + * @returns The signed transaction, an instance of either new-style or old-style + * ethereumjs transaction. + */ + async signTransaction( + address: Hex, + tx: TypedTransaction, + ): Promise { + return this.#signTransaction( + address, + Number(tx.common.chainId()), + tx, + (payload) => { + // Because tx will be immutable, first get a plain javascript object that + // represents the transaction. Using txData here as it aligns with the + // nomenclature of ethereumjs/tx. + const txData: TypedTxData = tx.toJSON(); + // The fromTxData utility expects a type to support transactions with a type other than 0 + txData.type = tx.type; + // The fromTxData utility expects v,r and s to be hex prefixed + txData.v = ethUtil.addHexPrefix(payload.v); + txData.r = ethUtil.addHexPrefix(payload.r); + txData.s = ethUtil.addHexPrefix(payload.s); + // Adopt the 'common' option from the original transaction and set the + // returned object to be frozen if the original is frozen. + return TransactionFactory.fromTxData(txData, { + common: tx.common, + freeze: Object.isFrozen(tx), + }); + }, + ); + } + + async #signTransaction( + address: string, + chainId: number, + tx: TXData, + handleSigning: (tx: EVMSignedTx) => TXData, + ): Promise { + // new-style transaction from @ethereumjs/tx package + // we can just copy tx.toJSON() for everything except chainId, which must be a number + const transaction: EVMSignTransactionParams['transaction'] = { + ...tx.toJSON(), + chainId, + to: this.#normalize(Buffer.from(tx.to?.bytes ?? [])), + } as unknown as EVMSignTransactionParams['transaction']; + + try { + const details = this.#accountDetailsFromAddress(address); + const response = await this.bridge.ethereumSignTransaction({ + path: details.hdPath, + passphraseState: details.passphraseState ?? '', + useEmptyPassphrase: isEmptyPassphrase(details.passphraseState), + transaction, + }); + if (response.success) { + const newOrMutatedTx = handleSigning(response.payload); + + const addressSignedWith = ethUtil.toChecksumAddress( + ethUtil.addHexPrefix(newOrMutatedTx.getSenderAddress().toString()), + ); + const correctAddress = ethUtil.toChecksumAddress(address); + if (addressSignedWith !== correctAddress) { + throw new Error("signature doesn't match the right address"); + } + + return newOrMutatedTx; + } + throw new Error(response.payload?.error || 'Unknown error'); + } catch (error) { + throw new Error(error?.toString() ?? 'Unknown error'); + } + } + + async signMessage(withAccount: string, data: string): Promise { + return this.signPersonalMessage(withAccount, data); + } + + // For personal_sign, we need to prefix the message: + async signPersonalMessage( + withAccount: string, + message: string, + ): Promise { + return new Promise((resolve, reject) => { + const details = this.#accountDetailsFromAddress(withAccount); + this.bridge + .ethereumSignMessage({ + path: details.hdPath, + passphraseState: details.passphraseState ?? '', + useEmptyPassphrase: isEmptyPassphrase(details.passphraseState), + messageHex: ethUtil.stripHexPrefix(message), + }) + .then((response) => { + if (response.success) { + const signature = addHexPrefix(response.payload.signature); + const addressSignedWith = recoverPersonalSignature({ + data: message, + signature, + }); + if ( + ethUtil.toChecksumAddress(addressSignedWith) !== + ethUtil.toChecksumAddress(withAccount) + ) { + reject(new Error('signature doesnt match the right address')); + } + // eslint-disable-next-line promise/no-multiple-resolved + resolve(signature); + } else { + reject(new Error(response.payload?.error || 'Unknown error')); + } + }) + .catch((error) => { + reject(new Error(error?.toString() || 'Unknown error')); + }); + }); + } + + // EIP-712 Sign Typed Data + async signTypedData( + address: string, + data: TypedMessage, + { version }: { version?: SignTypedDataVersion }, + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + const useV4 = version === SignTypedDataVersion.V4; + const dataVersion = useV4 + ? SignTypedDataVersion.V4 + : SignTypedDataVersion.V3; + const typedData = TypedDataUtils.sanitizeData(data); + const domainHash = TypedDataUtils.hashStruct( + 'EIP712Domain', + typedData.domain, + typedData.types, + dataVersion, + ).toString('hex'); + const messageHash = TypedDataUtils.hashStruct( + typedData.primaryType as string, + typedData.message, + typedData.types, + dataVersion, + ).toString('hex'); + + const details = this.#accountDetailsFromAddress(address); + const response = await this.bridge.ethereumSignTypedData({ + path: details.hdPath, + passphraseState: details.passphraseState ?? '', + useEmptyPassphrase: isEmptyPassphrase(details.passphraseState), + data: data as EthereumSignTypedDataMessage, + domainHash, + messageHash, + metamaskV4Compat: Boolean(useV4), // eslint-disable-line camelcase + }); + + if (response.success) { + const signature = addHexPrefix(response.payload.signature); + const addressSignedWith = recoverTypedSignature({ + data: typedData, + signature, + version: dataVersion, + }); + if ( + ethUtil.toChecksumAddress(addressSignedWith) !== + ethUtil.toChecksumAddress(address) + ) { + throw new Error('signature doesnt match the right address'); + } + return signature; + } + + throw new Error(response.payload?.error || 'Unknown error'); + } + + forgetDevice(): void { + this.hdk = new HDKey(); + this.accounts = []; + this.page = 0; + this.unlockedAccount = 0; + this.accountDetails = {}; + this.passphraseState = undefined; + } + + async #getPage( + increment: number, + ): Promise<{ address: string; balance: number | null; index: number }[]> { + this.page += increment; + + if (this.page <= 0) { + this.page = 1; + } + + return new Promise((resolve, reject) => { + const from = (this.page - 1) * this.perPage; + const to = from + this.perPage; + + const accounts: { + address: string; + balance: number | null; + index: number; + }[] = []; + + this.unlock() + .then(async () => { + for (let i = from; i < to; i++) { + const address = this.#addressFromIndex(i); + if (typeof address === 'undefined') { + throw new Error('Unknown error'); + } + accounts.push({ + index: i, + address, + balance: null, + }); + } + resolve(accounts); + }) + .catch((error) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error); + }); + }); + } + + #accountDetailsFromAddress(address: string): AccountDetails { + const checksummedAddress = ethUtil.toChecksumAddress(address); + const accountDetails = this.accountDetails[checksummedAddress]; + if (typeof accountDetails === 'undefined') { + throw new Error('Unknown address'); + } + return accountDetails; + } + + #addressFromIndex(i: number): Hex { + const dkey = this.hdk.derive(this.#getDerivePath(i)); + const address = ethUtil.bytesToHex( + ethUtil.publicToAddress(new Uint8Array(dkey.publicKey), true), + ); + return ethUtil.toChecksumAddress(address); + } + + #getDerivePath(index: number): string { + if (this.#isLedgerLiveHdPath()) { + throw new Error('Ledger Live is not supported'); + } + if (this.#isStandardBip44HdPath()) { + return `${pathBase}/0/${index}`; + } + return `${pathBase}/${index}`; + } + + #getBasePath(): string { + if (this.#isLedgerLiveHdPath()) { + throw new Error('Ledger Live is not supported'); + } + return "m/44'/60'/0'"; + } + + #getPathForIndex(index: number): string { + // Check if the path is BIP 44 (Ledger Live) + if (this.#isLedgerLiveHdPath()) { + return `m/44'/60'/${index}'/0/0`; + } + + if (this.#isLedgerLegacyHdPath()) { + return `m/44'/60'/0'/${index}`; + } + + if (this.#isStandardBip44HdPath()) { + return `m/44'/60'/0'/0/${index}`; + } + + // default path: m/44'/60'/0'/0/x + return `${this.hdPath}/${index}`; + } + + #isLedgerLiveHdPath(): boolean { + return this.hdPath === `m/44'/60'/x'/0/0`; + } + + #isLedgerLegacyHdPath(): boolean { + return this.hdPath === `m/44'/60'/0'/x`; + } + + #isStandardBip44HdPath(): boolean { + return this.hdPath === `m/44'/60'/0'/0/x` || this.hdPath === defaultHdPath; + } + + #isSameHdPath(newHdPath: string): boolean { + if (this.#isLedgerLiveHdPath()) { + return newHdPath === `m/44'/60'/x'/0/0`; + } + if (this.#isLedgerLegacyHdPath()) { + return newHdPath === `m/44'/60'/0'/x`; + } + if (this.#isStandardBip44HdPath()) { + return newHdPath === `m/44'/60'/0'/0/x` || newHdPath === defaultHdPath; + } + + return this.hdPath === newHdPath; + } +} diff --git a/packages/keyring-eth-onekey/src/onekey-web-bridge.test.ts b/packages/keyring-eth-onekey/src/onekey-web-bridge.test.ts new file mode 100644 index 00000000..f739d177 --- /dev/null +++ b/packages/keyring-eth-onekey/src/onekey-web-bridge.test.ts @@ -0,0 +1,594 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core'; +import { HardwareErrorCode } from '@onekeyfe/hd-shared'; + +import { OneKeyWebBridge } from './onekey-web-bridge'; + +// Mock the static import at module level +jest.mock('@onekeyfe/hd-web-sdk', () => { + const mockHardwareWebSdk = { + init: jest.fn(), + on: jest.fn(), + uiResponse: jest.fn(), + dispose: jest.fn(), + switchTransport: jest.fn(), + evmGetPublicKey: jest.fn(), + getPassphraseState: jest.fn(), + evmSignTransaction: jest.fn(), + evmSignMessage: jest.fn(), + evmSignTypedData: jest.fn(), + }; + + const mockHardwareSDKLowLevel = {}; + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + PascalCase: true, + default: { + HardwareWebSdk: mockHardwareWebSdk, + HardwareSDKLowLevel: mockHardwareSDKLowLevel, + }, + }; +}); + +type MockHardwareWebSdk = { + init: jest.Mock; + on: jest.Mock; + uiResponse: jest.Mock; + dispose: jest.Mock; + switchTransport: jest.Mock; + evmGetPublicKey: jest.Mock; + getPassphraseState: jest.Mock; + evmSignTransaction: jest.Mock; + evmSignMessage: jest.Mock; + evmSignTypedData: jest.Mock; +}; + +const mockedModule = jest.requireMock('@onekeyfe/hd-web-sdk').default as { + // eslint-disable-next-line @typescript-eslint/naming-convention + HardwareWebSdk: MockHardwareWebSdk; + // eslint-disable-next-line @typescript-eslint/naming-convention + HardwareSDKLowLevel: Record; +}; + +const { + HardwareWebSdk: mockHardwareWebSdk, + HardwareSDKLowLevel: mockHardwareSDKLowLevel, +} = mockedModule; + +describe('OneKeyWebBridge', function () { + let bridge: OneKeyWebBridge; + + beforeEach(function () { + bridge = new OneKeyWebBridge(); + jest.clearAllMocks(); + }); + + describe('init', function () { + it('should initialize SDK and set up event handlers', async function () { + mockHardwareWebSdk.init.mockResolvedValue(undefined); + + await bridge.init(); + + expect(mockHardwareWebSdk.init).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.init).toHaveBeenCalledWith( + { + debug: false, + fetchConfig: false, + connectSrc: 'https://jssdk.onekey.so/1.1.0/', + env: 'webusb', + }, + mockHardwareSDKLowLevel, + ); + expect(bridge.isSDKInitialized).toBe(true); + expect(bridge.sdk).toBe(mockHardwareWebSdk); + expect(mockHardwareWebSdk.on).toHaveBeenCalledWith( + 'UI_EVENT', + expect.any(Function), + ); + }); + + it('should not initialize again if already initialized', async function () { + bridge.isSDKInitialized = true; + + await bridge.init(); + + expect(mockHardwareWebSdk.init).not.toHaveBeenCalled(); + }); + + it('should handle initialization failure', async function () { + mockHardwareWebSdk.init.mockRejectedValue(new Error('Init failed')); + + await bridge.init(); + + expect(bridge.isSDKInitialized).toBe(false); + expect(bridge.sdk).toBeUndefined(); + }); + + it('should handle PIN request in UI event', async function () { + let uiEventCallback: any; + mockHardwareWebSdk.on.mockImplementation( + (event: string, callback: any) => { + if (event === 'UI_EVENT') { + uiEventCallback = callback; + } + }, + ); + mockHardwareWebSdk.init.mockResolvedValue(undefined); + + await bridge.init(); + + // Simulate PIN request + uiEventCallback({ type: UI_REQUEST.REQUEST_PIN }); + + expect(mockHardwareWebSdk.uiResponse).toHaveBeenCalledWith({ + type: UI_RESPONSE.RECEIVE_PIN, + payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE', + }); + }); + + it('should handle passphrase request in UI event', async function () { + let uiEventCallback: any; + mockHardwareWebSdk.on.mockImplementation( + (event: string, callback: any) => { + if (event === 'UI_EVENT') { + uiEventCallback = callback; + } + }, + ); + mockHardwareWebSdk.init.mockResolvedValue(undefined); + + await bridge.init(); + + // Simulate passphrase request + uiEventCallback({ type: UI_REQUEST.REQUEST_PASSPHRASE }); + + expect(mockHardwareWebSdk.uiResponse).toHaveBeenCalledWith({ + type: UI_RESPONSE.RECEIVE_PASSPHRASE, + payload: { + value: '', + passphraseOnDevice: true, + save: false, + }, + }); + }); + }); + + describe('destroy', function () { + it('should destroy SDK', async function () { + bridge.sdk = mockHardwareWebSdk as any; + bridge.isSDKInitialized = true; + + await bridge.destroy(); + + expect(bridge.isSDKInitialized).toBe(false); + expect(bridge.sdk).toBeUndefined(); + }); + }); + + describe('dispose', function () { + it('should call dispose on SDK', async function () { + bridge.sdk = mockHardwareWebSdk as any; + + await bridge.dispose(); + + expect(mockHardwareWebSdk.dispose).toHaveBeenCalledTimes(1); + }); + + it('should handle dispose when SDK is not initialized', async function () { + bridge.sdk = undefined; + + // eslint-disable-next-line jest/no-restricted-matchers + await expect(bridge.dispose()).resolves.toBeUndefined(); + }); + }); + + describe('updateTransportMethod', function () { + it('should switch transport when SDK is initialized', async function () { + bridge.sdk = mockHardwareWebSdk as any; + + await bridge.updateTransportMethod('webusb'); + + expect(mockHardwareWebSdk.switchTransport).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.switchTransport).toHaveBeenCalledWith('webusb'); + }); + + it('should not switch transport when SDK is not initialized', async function () { + bridge.sdk = undefined; + + await bridge.updateTransportMethod('webusb'); + + expect(mockHardwareWebSdk.switchTransport).not.toHaveBeenCalled(); + }); + }); + + describe('getPublicKey', function () { + it('should call evmGetPublicKey', async function () { + const expectedResult = { + success: true, + payload: { + pub: '0x123', + // eslint-disable-next-line @typescript-eslint/naming-convention + node: { chain_code: 'abc123' }, + }, + }; + mockHardwareWebSdk.evmGetPublicKey.mockResolvedValue(expectedResult); + bridge.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + coin: 'eth', + }; + const result = await bridge.getPublicKey(params); + + expect(mockHardwareWebSdk.evmGetPublicKey).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.evmGetPublicKey).toHaveBeenCalledWith('', '', { + ...params, + skipPassphraseCheck: true, + }); + expect(result).toStrictEqual({ + success: true, + payload: { + publicKey: '0x123', + chainCode: 'abc123', + }, + }); + }); + + it('should handle error without code', async function () { + const errorResult = { + success: false, + payload: { + error: 'Some error', + }, + }; + mockHardwareWebSdk.evmGetPublicKey.mockResolvedValue(errorResult); + bridge.sdk = mockHardwareWebSdk as any; + + const result = await bridge.getPublicKey({ + path: "m/44'/60'/0'/0/0", + coin: 'eth', + }); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'Some error', + code: undefined, + }, + }); + }); + + it('should return error when SDK is not initialized', async function () { + bridge.sdk = undefined; + + const result = await bridge.getPublicKey({ + path: "m/44'/60'/0'/0/0", + coin: 'eth', + }); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }); + }); + }); + + describe('getPassphraseState', function () { + it('should call getPassphraseState', async function () { + const expectedResult = { + success: true, + payload: 'some-state', + }; + mockHardwareWebSdk.getPassphraseState.mockResolvedValue(expectedResult); + bridge.sdk = mockHardwareWebSdk as any; + + const result = await bridge.getPassphraseState(); + + expect(mockHardwareWebSdk.getPassphraseState).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.getPassphraseState).toHaveBeenCalledWith(''); + expect(result).toBe(expectedResult); + }); + + it('should return error when SDK is not initialized', async function () { + bridge.sdk = undefined; + + const result = await bridge.getPassphraseState(); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }); + }); + }); + + describe('ethereumSignTransaction', function () { + it('should call evmSignTransaction', async function () { + const expectedResult = { + success: true, + payload: { signature: '0xsignature' }, + }; + mockHardwareWebSdk.evmSignTransaction.mockResolvedValue(expectedResult); + bridge.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + transaction: { + to: '0x123', + value: '0x0', + gasLimit: '0x5208', + gasPrice: '0x1', + nonce: '0x0', + data: '0x', + chainId: 1, + }, + }; + const result = await bridge.ethereumSignTransaction(params); + + expect(mockHardwareWebSdk.evmSignTransaction).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.evmSignTransaction).toHaveBeenCalledWith( + '', + '', + { + ...params, + skipPassphraseCheck: true, + }, + ); + expect(result).toBe(expectedResult); + }); + + it('should return error when SDK is not initialized', async function () { + bridge.sdk = undefined; + + const params = { + path: "m/44'/60'/0'/0/0", + transaction: { + to: '0x123', + value: '0x0', + gasLimit: '0x5208', + gasPrice: '0x1', + nonce: '0x0', + data: '0x', + chainId: 1, + }, + }; + const result = await bridge.ethereumSignTransaction(params); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }); + }); + }); + + describe('ethereumSignMessage', function () { + it('should call evmSignMessage', async function () { + const expectedResult = { + success: true, + payload: { signature: '0xsignature' }, + }; + mockHardwareWebSdk.evmSignMessage.mockResolvedValue(expectedResult); + bridge.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + messageHex: '48656c6c6f20576f726c64', + }; + const result = await bridge.ethereumSignMessage(params); + + expect(mockHardwareWebSdk.evmSignMessage).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.evmSignMessage).toHaveBeenCalledWith('', '', { + ...params, + skipPassphraseCheck: true, + }); + expect(result).toBe(expectedResult); + }); + + it('should return error when SDK is not initialized', async function () { + bridge.sdk = undefined; + + const params = { + path: "m/44'/60'/0'/0/0", + messageHex: '48656c6c6f20576f726c64', + }; + const result = await bridge.ethereumSignMessage(params); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }); + }); + }); + + describe('ethereumSignTypedData', function () { + it('should call evmSignTypedData', async function () { + const expectedResult = { + success: true, + payload: { signature: '0xsignature' }, + }; + mockHardwareWebSdk.evmSignTypedData.mockResolvedValue(expectedResult); + bridge.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + data: { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: { name: 'Test' }, + message: {}, + }, + metamaskV4Compat: true, + }; + const result = await bridge.ethereumSignTypedData(params); + + expect(mockHardwareWebSdk.evmSignTypedData).toHaveBeenCalledTimes(1); + expect(mockHardwareWebSdk.evmSignTypedData).toHaveBeenCalledWith('', '', { + ...params, + skipPassphraseCheck: true, + }); + expect(result).toBe(expectedResult); + }); + + it('should return error when SDK is not initialized', async function () { + bridge.sdk = undefined; + + const params = { + path: "m/44'/60'/0'/0/0", + data: { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: { name: 'Test' }, + message: {}, + }, + metamaskV4Compat: true, + }; + const result = await bridge.ethereumSignTypedData(params); + + expect(result).toStrictEqual({ + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }); + }); + }); + + describe('model management', function () { + it('should return model', function () { + bridge.model = 'OneKey Pro'; + expect(bridge.getModel()).toBe('OneKey Pro'); + }); + + it('should return undefined when model is not set', function () { + expect(bridge.getModel()).toBeUndefined(); + }); + }); + + describe('error handling branch coverage', function () { + it('should handle getPassphraseState error and call handleBlockErrorEvent', async function () { + const errorResult = { + success: false, + payload: { + error: 'Passphrase error', + code: HardwareErrorCode.DeviceCheckPassphraseStateError, + }, + }; + mockHardwareWebSdk.getPassphraseState.mockResolvedValue(errorResult); + bridge.sdk = mockHardwareWebSdk as any; + + const callback = jest.fn(); + const bridgeWithCallback = new OneKeyWebBridge(); + bridgeWithCallback.setUiEventCallback(callback); + bridgeWithCallback.sdk = mockHardwareWebSdk as any; + + await bridgeWithCallback.getPassphraseState(); + expect(callback).toHaveBeenCalledWith(errorResult.payload); + }); + + it('should handle ethereumSignTransaction error and call handleBlockErrorEvent', async function () { + const errorResult = { + success: false, + payload: { + error: 'Sign error', + code: HardwareErrorCode.BridgeNotInstalled, + }, + }; + mockHardwareWebSdk.evmSignTransaction.mockResolvedValue(errorResult); + + const callback = jest.fn(); + const bridgeWithCallback = new OneKeyWebBridge(); + bridgeWithCallback.setUiEventCallback(callback); + bridgeWithCallback.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + transaction: { + to: '0x123', + value: '0x0', + gasLimit: '0x5208', + gasPrice: '0x1', + nonce: '0x0', + data: '0x', + chainId: 1, + }, + }; + + await bridgeWithCallback.ethereumSignTransaction(params); + expect(callback).toHaveBeenCalledWith(errorResult.payload); + }); + + it('should handle ethereumSignMessage error and call handleBlockErrorEvent', async function () { + const errorResult = { + success: false, + payload: { + error: 'Sign message error', + code: HardwareErrorCode.NewFirmwareForceUpdate, + }, + }; + mockHardwareWebSdk.evmSignMessage.mockResolvedValue(errorResult); + + const callback = jest.fn(); + const bridgeWithCallback = new OneKeyWebBridge(); + bridgeWithCallback.setUiEventCallback(callback); + bridgeWithCallback.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + messageHex: '48656c6c6f', + }; + + await bridgeWithCallback.ethereumSignMessage(params); + expect(callback).toHaveBeenCalledWith(errorResult.payload); + }); + + it('should handle ethereumSignTypedData error and call handleBlockErrorEvent', async function () { + const errorResult = { + success: false, + payload: { + error: 'Sign typed data error', + code: HardwareErrorCode.NotAllowInBootloaderMode, + }, + }; + mockHardwareWebSdk.evmSignTypedData.mockResolvedValue(errorResult); + + const callback = jest.fn(); + const bridgeWithCallback = new OneKeyWebBridge(); + bridgeWithCallback.setUiEventCallback(callback); + bridgeWithCallback.sdk = mockHardwareWebSdk as any; + + const params = { + path: "m/44'/60'/0'/0/0", + data: { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: { name: 'Test' }, + message: {}, + }, + metamaskV4Compat: true, + }; + + await bridgeWithCallback.ethereumSignTypedData(params); + expect(callback).toHaveBeenCalledWith(errorResult.payload); + }); + }); +}); diff --git a/packages/keyring-eth-onekey/src/onekey-web-bridge.ts b/packages/keyring-eth-onekey/src/onekey-web-bridge.ts new file mode 100644 index 00000000..101a41a9 --- /dev/null +++ b/packages/keyring-eth-onekey/src/onekey-web-bridge.ts @@ -0,0 +1,265 @@ +import { UI_REQUEST, UI_RESPONSE } from '@onekeyfe/hd-core'; +import type { + ConnectSettings, + CoreApi, + EVMSignedTx, + EVMSignMessageParams, + EVMSignTransactionParams, + EVMSignTypedDataParams, + Params, + UiEvent, + Unsuccessful, +} from '@onekeyfe/hd-core'; +import { HardwareErrorCode } from '@onekeyfe/hd-shared'; +import type { EthereumMessageSignature } from '@onekeyfe/hd-transport'; +import hardwareWebSdk from '@onekeyfe/hd-web-sdk'; + +import type { OneKeyBridge } from './onekey-bridge'; + +export type OneKeyIframeBridgeOptions = { + bridgeUrl: string; +}; + +export class OneKeyWebBridge implements OneKeyBridge { + isSDKInitialized = false; + + sdk: CoreApi | undefined = undefined; + + model?: string | undefined; + + #onUIEvent?: ((event: Unsuccessful['payload']) => void) | undefined; + + #handleBlockErrorEvent(payload: Unsuccessful): void { + const { code } = payload.payload; + const errorCodes: number[] = [ + HardwareErrorCode.WebDeviceNotFoundOrNeedsPermission, + HardwareErrorCode.BridgeNotInstalled, + HardwareErrorCode.NewFirmwareForceUpdate, + HardwareErrorCode.NotAllowInBootloaderMode, + HardwareErrorCode.CallMethodNeedUpgradeFirmware, + HardwareErrorCode.DeviceCheckPassphraseStateError, + HardwareErrorCode.DeviceCheckUnlockTypeError, + HardwareErrorCode.SelectDevice, + ]; + + if (code && typeof code === 'number' && errorCodes.includes(code)) { + this.#onUIEvent?.(payload.payload); + } + } + + async updateTransportMethod( + transportType: ConnectSettings['env'], + ): Promise { + if (!this.sdk) { + return; + } + await this.sdk.switchTransport(transportType); + } + + setUiEventCallback(callback: (event: Unsuccessful['payload']) => void): void { + this.#onUIEvent = callback; + } + + async init(): Promise { + if (this.isSDKInitialized) { + return; + } + + const settings: Partial = { + debug: false, + fetchConfig: false, + connectSrc: 'https://jssdk.onekey.so/1.1.0/', + env: 'webusb', + }; + try { + await hardwareWebSdk.HardwareWebSdk.init( + settings, + hardwareWebSdk.HardwareSDKLowLevel, + ); + this.isSDKInitialized = true; + this.sdk = hardwareWebSdk.HardwareWebSdk; + + this.sdk?.on('UI_EVENT', (originEvent: UiEvent) => { + if (originEvent.type === UI_REQUEST.REQUEST_PIN) { + this.sdk?.uiResponse({ + type: UI_RESPONSE.RECEIVE_PIN, + payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE', + }); + } + if (originEvent.type === UI_REQUEST.REQUEST_PASSPHRASE) { + this.sdk?.uiResponse({ + type: UI_RESPONSE.RECEIVE_PASSPHRASE, + payload: { + value: '', + passphraseOnDevice: true, + save: false, + }, + }); + } + }); + } catch { + this.isSDKInitialized = false; + } + } + + async destroy(): Promise { + this.isSDKInitialized = false; + this.sdk = undefined; + } + + async dispose(): Promise { + this.sdk?.dispose(); + return Promise.resolve(); + } + + getModel(): string | undefined { + return this.model; + } + + async getPublicKey(params: { + path: string; + coin: string; + }): Promise< + | { success: false; payload: { error: string; code?: string | number } } + | { success: true; payload: { publicKey: string; chainCode: string } } + > { + if (!this.sdk) { + return { + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }; + } + return await this.sdk + .evmGetPublicKey('', '', { ...params, skipPassphraseCheck: true }) + .then((result) => { + if (result?.success) { + return { + success: true, + payload: { + publicKey: result.payload.pub, + chainCode: result.payload.node.chain_code, + }, + }; + } + this.#handleBlockErrorEvent(result); + return { + success: false, + payload: { + error: result?.payload.error ?? '', + code: + typeof result?.payload?.code === 'number' + ? result?.payload?.code + : undefined, + }, + }; + }); + } + + async getPassphraseState(): Promise< + | { success: false; payload: { error: string; code?: string | number } } + | { success: true; payload: string | undefined } + > { + if (!this.sdk) { + return { + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }; + } + return await this.sdk.getPassphraseState('').then((result) => { + if (!result?.success) { + this.#handleBlockErrorEvent(result); + } + return result; + }); + } + + async ethereumSignTransaction( + params: Params, + ): Promise< + | { success: false; payload: { error: string; code?: string | number } } + | { success: true; payload: EVMSignedTx } + > { + if (!this.sdk) { + return { + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }; + } + return await this.sdk + .evmSignTransaction('', '', { + ...params, + skipPassphraseCheck: true, + }) + .then((result) => { + if (!result?.success) { + this.#handleBlockErrorEvent(result); + } + return result; + }); + } + + async ethereumSignMessage( + params: Params, + ): Promise< + | { success: false; payload: { error: string; code?: string | number } } + | { success: true; payload: EthereumMessageSignature } + > { + if (!this.sdk) { + return { + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }; + } + return await this.sdk + .evmSignMessage('', '', { + ...params, + skipPassphraseCheck: true, + }) + .then((result) => { + if (!result?.success) { + this.#handleBlockErrorEvent(result); + } + return result; + }); + } + + async ethereumSignTypedData( + params: Params, + ): Promise< + | { success: false; payload: { error: string; code?: string | number } } + | { success: true; payload: EthereumMessageSignature } + > { + if (!this.sdk) { + return { + success: false, + payload: { + error: 'SDK not initialized', + code: HardwareErrorCode.NotInitialized, + }, + }; + } + return await this.sdk + .evmSignTypedData('', '', { + ...params, + skipPassphraseCheck: true, + }) + .then((result) => { + if (!result?.success) { + this.#handleBlockErrorEvent(result); + } + return result; + }); + } +} diff --git a/packages/keyring-eth-onekey/tsconfig.build.json b/packages/keyring-eth-onekey/tsconfig.build.json new file mode 100644 index 00000000..9bcd3d13 --- /dev/null +++ b/packages/keyring-eth-onekey/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "rootDir": "src", + "exactOptionalPropertyTypes": false, + "skipLibCheck": true, + "lib": ["ES2020"], + "target": "es2017" + }, + "references": [{ "path": "../keyring-utils/tsconfig.build.json" }], + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/packages/keyring-eth-onekey/tsconfig.json b/packages/keyring-eth-onekey/tsconfig.json new file mode 100644 index 00000000..5ad645d0 --- /dev/null +++ b/packages/keyring-eth-onekey/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "exactOptionalPropertyTypes": false, + "skipLibCheck": true, + "lib": ["ES2020"], + "target": "es2017" + }, + "references": [{ "path": "../keyring-utils" }], + "include": ["./src"], + "exclude": ["./dist/**/*"] +} diff --git a/packages/keyring-eth-onekey/typedoc.json b/packages/keyring-eth-onekey/typedoc.json new file mode 100644 index 00000000..b527b625 --- /dev/null +++ b/packages/keyring-eth-onekey/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 60377db1..95d164ed 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,7 @@ { "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-onekey/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/tsconfig.json b/tsconfig.json index cd24f44b..1d02045d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./packages/keyring-eth-simple" }, { "path": "./packages/keyring-eth-trezor" }, { "path": "./packages/keyring-eth-hd" }, + { "path": "./packages/keyring-eth-onekey" }, { "path": "./packages/keyring-snap-bridge" }, { "path": "./packages/keyring-snap-client" }, { "path": "./packages/keyring-internal-snap-client" }, diff --git a/yarn.lock b/yarn.lock index cde1599d..4de26044 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1554,13 +1554,12 @@ __metadata: linkType: hard "@metamask/base-controller@npm:^8.0.0, @metamask/base-controller@npm:^8.0.1": - version: 8.3.0 - resolution: "@metamask/base-controller@npm:8.3.0" + version: 8.0.1 + resolution: "@metamask/base-controller@npm:8.0.1" dependencies: - "@metamask/messenger": "npm:^0.2.0" - "@metamask/utils": "npm:^11.4.2" + "@metamask/utils": "npm:^11.2.0" immer: "npm:^9.0.6" - checksum: 10/f4dec29cbf984e38c8dab331a7b98ad3ebb81d1e64d25f28e01025a0e7b4b4f6ead9e5b830852b7eabd8ad971753868a932dc2d0076f4bd3eec415d8604eb7a4 + checksum: 10/5ef02099ce2e2246c534a7742b45704417beebf2c21db70241d09c3ddbb549ff3375284ece00edf4051029facff181e5e05f135e97b943ec6c514eecce4fa37a languageName: node linkType: hard @@ -1732,6 +1731,47 @@ __metadata: languageName: unknown linkType: soft +"@metamask/eth-onekey-keyring@workspace:packages/keyring-eth-onekey": + version: 0.0.0-use.local + resolution: "@metamask/eth-onekey-keyring@workspace:packages/keyring-eth-onekey" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@lavamoat/allow-scripts": "npm:^3.2.1" + "@lavamoat/preinstall-always-fail": "npm:^2.1.0" + "@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" + "@noble/hashes": "npm:^1.7.0" + "@onekeyfe/hd-core": "npm:1.1.17-patch.1" + "@onekeyfe/hd-shared": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport": "npm:1.1.17-patch.1" + "@onekeyfe/hd-web-sdk": "npm:1.1.17-patch.1" + "@ts-bridge/cli": "npm:^0.6.3" + "@types/bytebuffer": "npm:^5.0.49" + "@types/ethereumjs-tx": "npm:^1.0.1" + "@types/hdkey": "npm:^2.0.1" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^20.12.12" + "@types/sinon": "npm:^17.0.3" + "@types/w3c-web-usb": "npm:^1.0.6" + deepmerge: "npm:^4.2.2" + depcheck: "npm:^1.4.7" + ethereumjs-tx: "npm:^1.3.7" + hdkey: "npm:^2.1.0" + jest: "npm:^29.5.0" + jest-environment-jsdom: "npm:^29.7.0" + jest-it-up: "npm:^3.1.0" + sinon: "npm:^19.0.2" + ts-jest: "npm:^29.0.5" + ts-node: "npm:^10.9.2" + typedoc: "npm:^0.25.13" + typescript: "npm:~5.6.3" + languageName: unknown + linkType: soft + "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr": version: 0.0.0-use.local resolution: "@metamask/eth-qr-keyring@workspace:packages/keyring-eth-qr" @@ -2134,13 +2174,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.2.0": - version: 0.2.0 - resolution: "@metamask/messenger@npm:0.2.0" - checksum: 10/48f682d9cde1208fbda0936022dea37acc3828cc221203b5f917df25c131d9a250dc5e86e9263f5dba8ee7c05adc6752a68dfb57da7d297f95f38b052f4fe5c1 - languageName: node - linkType: hard - "@metamask/messenger@npm:^0.3.0": version: 0.3.0 resolution: "@metamask/messenger@npm:0.3.0" @@ -2419,22 +2452,20 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2": - version: 11.7.0 - resolution: "@metamask/utils@npm:11.7.0" +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.4.0": + version: 11.4.0 + resolution: "@metamask/utils@npm:11.4.0" dependencies: "@ethereumjs/tx": "npm:^4.2.0" "@metamask/superstruct": "npm:^3.1.0" "@noble/hashes": "npm:^1.3.1" "@scure/base": "npm:^1.1.3" "@types/debug": "npm:^4.1.7" - "@types/lodash": "npm:^4.17.20" debug: "npm:^4.3.4" - lodash: "npm:^4.17.21" pony-cause: "npm:^2.1.10" semver: "npm:^7.5.4" uuid: "npm:^9.0.1" - checksum: 10/0f31783f357d1043d0e83af3a7af1e686f56c821e9acc650d71c02f5ea8ee7d5868aa09d240b179305893be05d5a406ccfdd9a8c534d265d6b821c383f51ace1 + checksum: 10/7c976268e944b542b5e936bae89f58a50eef58501bd3512944995c6d416cb1a7dd3f712aec8c7ca0969dcee889ab963b815fbc3e863dc80ccf16e9258eaec3ff languageName: node linkType: hard @@ -2518,7 +2549,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.0.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.6.1, @noble/hashes@npm:^1.7.1, @noble/hashes@npm:~1.8.0": +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.0.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.6.1, @noble/hashes@npm:^1.7.0, @noble/hashes@npm:^1.7.1, @noble/hashes@npm:~1.8.0": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e @@ -2663,6 +2694,114 @@ __metadata: languageName: node linkType: hard +"@onekeyfe/cross-inpage-provider-core@npm:^0.0.17": + version: 0.0.17 + resolution: "@onekeyfe/cross-inpage-provider-core@npm:0.0.17" + dependencies: + "@onekeyfe/cross-inpage-provider-errors": "npm:^0.0.17" + "@onekeyfe/cross-inpage-provider-events": "npm:^0.0.17" + "@onekeyfe/cross-inpage-provider-types": "npm:^0.0.17" + events: "npm:^3.3.0" + lodash: "npm:^4.17.21" + ms: "npm:^2.1.3" + checksum: 10/655305db565093b245d42c65f02f951cc4f064469aa201e9acea6c4b9e14e5acc2944815134b18773865ef05afff9bb78face94e6f730ef9e9dd5b0ccd0d72b4 + languageName: node + linkType: hard + +"@onekeyfe/cross-inpage-provider-errors@npm:^0.0.17": + version: 0.0.17 + resolution: "@onekeyfe/cross-inpage-provider-errors@npm:0.0.17" + dependencies: + fast-safe-stringify: "npm:^2.1.1" + checksum: 10/f9a37acaaff97581d5344651b3d72c91cdf537d88c452564db962b8cc214c32c97246de9d5adeeef225ef1ccc5d804545e84755d8c9a95e63af2fc94a90dd4fb + languageName: node + linkType: hard + +"@onekeyfe/cross-inpage-provider-events@npm:^0.0.17": + version: 0.0.17 + resolution: "@onekeyfe/cross-inpage-provider-events@npm:0.0.17" + checksum: 10/f98304e1d98c1b3fc9b2952056019dcd2123de8bf555d9039d1d93a953ceb2937a97a91c1061bd4b971d6efa3a017ed9c68915a3454a7c0b8f9bdcefa0d11d84 + languageName: node + linkType: hard + +"@onekeyfe/cross-inpage-provider-types@npm:^0.0.17": + version: 0.0.17 + resolution: "@onekeyfe/cross-inpage-provider-types@npm:0.0.17" + checksum: 10/4dbf5bc6b4467a8324f2e438757fccd934573e081d88532a75d1588b32120bde3c9f39229ac6678fcc25d4970622fbec4e128f01550b4f6925b798c93ad642a5 + languageName: node + linkType: hard + +"@onekeyfe/hd-core@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-core@npm:1.1.17-patch.1" + dependencies: + "@onekeyfe/hd-shared": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport": "npm:1.1.17-patch.1" + axios: "npm:1.12.2" + bignumber.js: "npm:^9.0.2" + bytebuffer: "npm:^5.0.1" + jszip: "npm:^3.10.1" + parse-uri: "npm:^1.0.7" + semver: "npm:^7.3.7" + peerDependencies: + "@noble/hashes": ^1.1.3 + checksum: 10/0dfab528b3c7ecf3881eb4a7abd317104f6f07e71c29c1ef9900c6beaeb68f67259d38ae0221bfbcf5ae713abc8cfde379f2fb29fa6760b4da29d86d034833c3 + languageName: node + linkType: hard + +"@onekeyfe/hd-shared@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-shared@npm:1.1.17-patch.1" + checksum: 10/906a3f38b44eaa00835addcc16e383355a59296d0abb2df69647316be90a350e6b22ba0a38ce727080c2fc965339bd79d39d842664436fe21003b9916ed12ff9 + languageName: node + linkType: hard + +"@onekeyfe/hd-transport-http@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-transport-http@npm:1.1.17-patch.1" + dependencies: + "@onekeyfe/hd-shared": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport": "npm:1.1.17-patch.1" + axios: "npm:1.12.2" + secure-json-parse: "npm:^4.0.0" + checksum: 10/55fc66153a8c407382182d4f0b7647ceaa2f03486be6799c7d3ddbf9d4cc17167c1bc8a1caf8be1b089c5c65db20e16c200104852447101edf5bce36a7d98eab + languageName: node + linkType: hard + +"@onekeyfe/hd-transport-web-device@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-transport-web-device@npm:1.1.17-patch.1" + dependencies: + "@onekeyfe/hd-shared": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport": "npm:1.1.17-patch.1" + checksum: 10/bf6adcdeb7a14173f971be4fb456ca7d10e5f59be63f3b15d25538e2693ab9940e8890556d203ce45fd9ba264840f5f4d78a4e9d2ff1d09796fc6d7d900c5828 + languageName: node + linkType: hard + +"@onekeyfe/hd-transport@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-transport@npm:1.1.17-patch.1" + dependencies: + bytebuffer: "npm:^5.0.1" + long: "npm:^4.0.0" + protobufjs: "npm:^6.11.2" + checksum: 10/570505354696375aa8b2734491c7e582ae35b828e7b269e3654c64754069d6409bb75a18a5ecdff35a3e4a82724dec4be55376a81992d87e661c4aa9f2e5bfa6 + languageName: node + linkType: hard + +"@onekeyfe/hd-web-sdk@npm:1.1.17-patch.1": + version: 1.1.17-patch.1 + resolution: "@onekeyfe/hd-web-sdk@npm:1.1.17-patch.1" + dependencies: + "@onekeyfe/cross-inpage-provider-core": "npm:^0.0.17" + "@onekeyfe/hd-core": "npm:1.1.17-patch.1" + "@onekeyfe/hd-shared": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport-http": "npm:1.1.17-patch.1" + "@onekeyfe/hd-transport-web-device": "npm:1.1.17-patch.1" + checksum: 10/a0ab9dc148c8728367f6e9ec02b254364c39b9b22c455f2ebf315c11926ff672f2b527e6127677b9992c23c0c26857cbe7a520d09e4d5f4a5294fbf2c651073c + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3915,6 +4054,16 @@ __metadata: languageName: node linkType: hard +"@types/bytebuffer@npm:^5.0.49": + version: 5.0.49 + resolution: "@types/bytebuffer@npm:5.0.49" + dependencies: + "@types/long": "npm:^3.0.0" + "@types/node": "npm:*" + checksum: 10/31eb2521d2710f256c3d17a3e8d87f04394f335b29f7276c31c054ddbf4795146f2663effa3b6e910442da69238e994d2db9f7d5918eead4313e3f9e29165932 + languageName: node + linkType: hard + "@types/color-name@npm:^1.1.1": version: 1.1.1 resolution: "@types/color-name@npm:1.1.1" @@ -4036,10 +4185,17 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.17.20": - version: 4.17.20 - resolution: "@types/lodash@npm:4.17.20" - checksum: 10/8cd8ad3bd78d2e06a93ae8d6c9907981d5673655fec7cb274a4d9a59549aab5bb5b3017361280773b8990ddfccf363e14d1b37c97af8a9fe363de677f9a61524 +"@types/long@npm:^3.0.0": + version: 3.0.32 + resolution: "@types/long@npm:3.0.32" + checksum: 10/cc5422875a085b49b74ffeb5c60a8681d30f700859a8931012b4a58c5c6005cdacb4d3ce3e5af7a7f579ee20d5c2e442a773a83b3a4f7a2d39795a7a8e9a962d + languageName: node + linkType: hard + +"@types/long@npm:^4.0.1": + version: 4.0.2 + resolution: "@types/long@npm:4.0.2" + checksum: 10/68afa05fb20949d88345876148a76f6ccff5433310e720db51ac5ca21cb8cc6714286dbe04713840ddbd25a8b56b7a23aa87d08472fabf06463a6f2ed4967707 languageName: node linkType: hard @@ -4871,25 +5027,25 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.7.7": - version: 1.7.7 - resolution: "axios@npm:1.7.7" +"axios@npm:1.12.2, axios@npm:^1.8.4": + version: 1.12.2 + resolution: "axios@npm:1.12.2" dependencies: follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.0" + form-data: "npm:^4.0.4" proxy-from-env: "npm:^1.1.0" - checksum: 10/7f875ea13b9298cd7b40fd09985209f7a38d38321f1118c701520939de2f113c4ba137832fe8e3f811f99a38e12c8225481011023209a77b0c0641270e20cde1 + checksum: 10/886a79770594eaad76493fecf90344b567bd956240609b5dcd09bd0afe8d3e6f1ad6d3257a93a483b6192b409d4b673d9515a34619e3e3ed1b2c0ec2a83b20ba languageName: node linkType: hard -"axios@npm:^1.8.4": - version: 1.10.0 - resolution: "axios@npm:1.10.0" +"axios@npm:1.7.7": + version: 1.7.7 + resolution: "axios@npm:1.7.7" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/d43c80316a45611fd395743e15d16ea69a95f2b7f7095f2bb12cb78f9ca0a905194a02e52a3bf4e0db9f85fd1186d6c690410644c10ecd8bb0a468e57c2040e4 + checksum: 10/7f875ea13b9298cd7b40fd09985209f7a38d38321f1118c701520939de2f113c4ba137832fe8e3f811f99a38e12c8225481011023209a77b0c0641270e20cde1 languageName: node linkType: hard @@ -5114,10 +5270,10 @@ __metadata: languageName: node linkType: hard -"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 +"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.0.1, bignumber.js@npm:^9.0.2, bignumber.js@npm:^9.1.2, bignumber.js@npm:^9.3.0": + version: 9.3.1 + resolution: "bignumber.js@npm:9.3.1" + checksum: 10/1be0372bf0d6d29d0a49b9e6a9cefbd54dad9918232ad21fcd4ec39030260773abf0c76af960c6b3b98d3115a3a71e61c6a111812d1395040a039cfa178e0245 languageName: node linkType: hard @@ -5377,6 +5533,15 @@ __metadata: languageName: node linkType: hard +"bytebuffer@npm:^5.0.1": + version: 5.0.1 + resolution: "bytebuffer@npm:5.0.1" + dependencies: + long: "npm:~3" + checksum: 10/f3e9739ed9ab30e19d985fc3dadfdbd631d030874bbb313feefddac756f21ac10957257737e630fd9959744318e6e8b7d8c35b797519693bf1897be16c560970 + languageName: node + linkType: hard + "cacache@npm:^16.1.0": version: 16.1.1 resolution: "cacache@npm:16.1.1" @@ -5423,6 +5588,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10/00482c1f6aa7cfb30fb1dbeb13873edf81cfac7c29ed67a5957d60635a56b2a4a480f1016ddbdb3395cc37900d46037fb965043a51c5c789ffeab4fc535d18b5 + 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" @@ -5753,6 +5928,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:~1.0.0": + version: 1.0.3 + resolution: "core-util-is@npm:1.0.3" + checksum: 10/9de8597363a8e9b9952491ebe18167e3b36e7707569eed0ebf14f8bba773611376466ae34575bca8cfe3c767890c859c74056084738f09d4e4a6f902b2ad7d99 + languageName: node + linkType: hard + "cosmiconfig@npm:9.0.0": version: 9.0.0 resolution: "cosmiconfig@npm:9.0.0" @@ -6173,6 +6355,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10/5add88a3d68d42d6e6130a0cac450b7c2edbe73364bbd2fc334564418569bea97c6943a8fcd70e27130bf32afc236f30982fc4905039b703f23e9e0433c29934 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -6306,12 +6499,10 @@ __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 +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10/f8dc9e660d90919f11084db0a893128f3592b781ce967e4fccfb8f3106cb83e400a4032c559184ec52ee1dbd4b01e7776c7cd0b3327b1961b1a4a7008920fe78 languageName: node linkType: hard @@ -6322,6 +6513,27 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10/54fe77de288451dae51c37bfbfe3ec86732dc3778f98f3eb3bdb4bf48063b2c0b8f9c93542656986149d08aa5be3204286e2276053d19582b76753f1a2728867 + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10/86814bf8afbcd8966653f731415888019d4bc4aca6b6c354132a7a75bb87566751e320369654a101d23a91c87a85c79b178bcf40332839bd347aff437c4fb65f + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.1.2": version: 3.1.2 resolution: "escalade@npm:3.1.2" @@ -7063,7 +7275,7 @@ __metadata: languageName: node linkType: hard -"fast-safe-stringify@npm:^2.0.6": +"fast-safe-stringify@npm:^2.0.6, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" checksum: 10/dc1f063c2c6ac9533aee14d406441f86783a8984b2ca09b19c2fe281f9ff59d315298bc7bc22fd1f83d26fe19ef2f20e2ddb68e96b15040292e555c5ced0c1e4 @@ -7199,12 +7411,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.15.6": - version: 1.15.9 - resolution: "follow-redirects@npm:1.15.9" + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" peerDependenciesMeta: debug: optional: true - checksum: 10/e3ab42d1097e90d28b913903841e6779eb969b62a64706a3eb983e894a5db000fbd89296f45f08885a0e54cd558ef62e81be1165da9be25a6c44920da10f424c + checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba languageName: node linkType: hard @@ -7227,14 +7439,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" +"form-data@npm:^4.0.0, form-data@npm:^4.0.4": + version: 4.0.4 + resolution: "form-data@npm:4.0.4" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" mime-types: "npm:^2.1.12" - checksum: 10/7264aa760a8cf09482816d8300f1b6e2423de1b02bba612a136857413fdc96d7178298ced106817655facc6b89036c6e12ae31c9eb5bdc16aabf502ae8a5d805 + checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb languageName: node linkType: hard @@ -7326,16 +7540,21 @@ __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" +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.6": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" 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 + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 languageName: node linkType: hard @@ -7353,6 +7572,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10/4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" @@ -7532,12 +7761,10 @@ __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 +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10/94e296d69f92dc1c0768fcfeecfb3855582ab59a7c75e969d5f96ce50c3d201fd86d5a2857c22565764d5bb8a816c7b1e58f133ec318cd56274da36c5e3fb1a1 languageName: node linkType: hard @@ -7578,17 +7805,10 @@ __metadata: 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 +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10/959385c98696ebbca51e7534e0dc723ada325efa3475350951363cce216d27373e0259b63edb599f72eb94d6cde8577b4b2375f080b303947e560f85692834fa languageName: node linkType: hard @@ -7629,7 +7849,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.2": +"hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -7814,6 +8034,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: 10/f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -7874,7 +8101,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -8142,6 +8369,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: 10/f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -8909,6 +9143,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:^3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" + dependencies: + lie: "npm:~3.3.0" + pako: "npm:~1.0.2" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:^1.0.5" + checksum: 10/bfbfbb9b0a27121330ac46ab9cdb3b4812433faa9ba4a54742c87ca441e31a6194ff70ae12acefa5fe25406c432290e68003900541d948a169b23d30c34dd984 + languageName: node + linkType: hard + "just-extend@npm:^6.2.0": version: 6.2.0 resolution: "just-extend@npm:6.2.0" @@ -8959,6 +9205,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: "npm:~3.0.5" + checksum: 10/f335ce67fe221af496185d7ce39c8321304adb701e122942c495f4f72dcee8803f9315ee572f5f8e8b08b9e8d7195da91b9fad776e8864746ba8b5e910adf76e + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.1.6 resolution: "lines-and-columns@npm:1.1.6" @@ -9039,6 +9294,20 @@ __metadata: languageName: node linkType: hard +"long@npm:^4.0.0": + version: 4.0.0 + resolution: "long@npm:4.0.0" + checksum: 10/8296e2ba7bab30f9cfabb81ebccff89c819af6a7a78b4bb5a70ea411aa764ee0532f7441381549dfa6a1a98d72abe9138bfcf99f4fa41238629849bc035b845b + languageName: node + linkType: hard + +"long@npm:~3": + version: 3.2.0 + resolution: "long@npm:3.2.0" + checksum: 10/ffc685ec458ddf71a830d6deb62ff7dc551a736d47473350d9e077c22db96ec88c8a3554c11ffce7d7f2291b0c30da36629e4d0a97c29b5360dc977533c96d28 + languageName: node + linkType: hard + "loose-envify@npm:^1.1.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -9206,6 +9475,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -9490,7 +9766,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -9961,6 +10237,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:~1.0.2": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10/1ad07210e894472685564c4d39a08717e84c2a68a70d3c1d9e657d32394ef1670e22972a433cbfe48976cb98b154ba06855dcd3fcfba77f60f1777634bec48c0 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -9989,6 +10272,13 @@ __metadata: languageName: node linkType: hard +"parse-uri@npm:^1.0.7": + version: 1.0.16 + resolution: "parse-uri@npm:1.0.16" + checksum: 10/5fd915fefd81bda753e7dbfdc887a5f8c88b6e4d1a23a4ac4f447f37cbff7fcdcabef047da56ad099c5418087ab7adb4df2f960f12d802467356ee136791bdae + languageName: node + linkType: hard + "parse5@npm:^7.0.0, parse5@npm:^7.1.1": version: 7.1.2 resolution: "parse5@npm:7.1.2" @@ -10208,6 +10498,13 @@ __metadata: languageName: node linkType: hard +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 10/1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf + languageName: node + linkType: hard + "process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" @@ -10262,6 +10559,30 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^6.11.2": + version: 6.11.4 + resolution: "protobufjs@npm:6.11.4" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.4" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.0" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.0" + "@types/long": "npm:^4.0.1" + "@types/node": "npm:>=13.7.0" + long: "npm:^4.0.0" + bin: + pbjs: bin/pbjs + pbts: bin/pbts + checksum: 10/6b7fd7540d74350d65c38f69f398c9995ae019da070e79d9cd464a458c6d19b40b07c9a026be4e10704c824a344b603307745863310c50026ebd661ce4da0663 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0" @@ -10435,6 +10756,21 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:~2.3.6": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.3" + isarray: "npm:~1.0.0" + process-nextick-args: "npm:~2.0.0" + safe-buffer: "npm:~5.1.1" + string_decoder: "npm:~1.1.1" + util-deprecate: "npm:~1.0.1" + checksum: 10/8500dd3a90e391d6c5d889256d50ec6026c059fadee98ae9aa9b86757d60ac46fff24fafb7a39fa41d54cb39d8be56cc77be202ebd4cd8ffcf4cb226cbaa40d4 + languageName: node + linkType: hard + "readable-web-to-node-stream@npm:^3.0.2": version: 3.0.2 resolution: "readable-web-to-node-stream@npm:3.0.2" @@ -10707,7 +11043,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.1.1": +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" checksum: 10/7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a @@ -10758,6 +11094,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^4.0.0": + version: 4.0.0 + resolution: "secure-json-parse@npm:4.0.0" + checksum: 10/c36c9dec9afaf4ef929a5469995d70d2f20d3d89b57219f22e0349b342715987283dbc1a80ab6f39e0bb28f8c3f3f073ce5363765c20c8d003ac243b4a89bd3d + languageName: node + linkType: hard + "semver-compare@npm:^1.0.0": version: 1.0.0 resolution: "semver-compare@npm:1.0.0" @@ -11206,6 +11549,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:~1.1.1": + version: 1.1.1 + resolution: "string_decoder@npm:1.1.1" + dependencies: + safe-buffer: "npm:~5.1.0" + checksum: 10/7c41c17ed4dea105231f6df208002ebddd732e8e9e2d619d133cecd8e0087ddfd9587d2feb3c8caf3213cbd841ada6d057f5142cae68a4e62d3540778d9819b4 + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -11911,7 +12263,7 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:^1.0.1": +"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" checksum: 10/474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2