* private keys are held in the secure chip of the device. As such, the user\r
* is responsible for using Ledger technology to back up these pieces of data.\r
*/\r
-export class Ledger extends Wallet {\r
+export class Ledger {\r
static #isInternal: boolean = false\r
+ static #status: DeviceStatus = 'DISCONNECTED'\r
\r
static #ADPU_CODES: { [key: string]: number } = Object.freeze({\r
class: 0xa1,\r
})\r
\r
static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID\r
+ static UsbVendorId = ledgerUSBVendorId\r
static SYMBOL: Symbol = Symbol('Ledger')\r
\r
static get #listenTimeout (): 30000 { return 30000 }\r
static get #openTimeout (): 3000 { return 3000 }\r
+ static get status (): DeviceStatus { return this.#status }\r
\r
/**\r
* Check which transport protocols are supported by the browser and return the\r
}\r
\r
/**\r
- * Creates a new Ledger hardware wallet communication layer by dynamically\r
- * importing the ledger.js service.\r
+ * Request an account at a specific BIP-44 index.\r
*\r
- * @returns {Ledger} A wallet containing accounts and a Ledger device communication object\r
+ * @returns Response object containing command status, public key, and address\r
*/\r
- static async create (): Promise<Ledger> {\r
- try {\r
- if (this.isUnsupported) throw new Error('Browser is unsupported')\r
- this.#isInternal = true\r
- const self = new this()\r
- await Database.add({ [self.id]: { id: self.id, type: 'Ledger' } }, Wallet.DB_NAME)\r
- return self\r
- } catch (err) {\r
- throw new Error('Failed to initialize Ledger wallet', { cause: err })\r
+ static async account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {\r
+ if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+ throw new TypeError('Invalid account index')\r
}\r
- }\r
+ const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
+ const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])\r
\r
- /**\r
- * Overrides `import()` from the base Wallet class since Ledger secrets cannot\r
- * be extracted from the device.\r
- */\r
- static import (): Promise<Ledger> {\r
- return Ledger.create()\r
- }\r
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
+ const response = await transport\r
+ .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+ await transport.close()\r
\r
- private constructor () {\r
- if (!Ledger.#isInternal) {\r
- throw new Error(`Ledger cannot be instantiated directly. Use 'await Ledger.create()' instead.`)\r
+ const statusCode = bytes.toDec(response.slice(-2)) as number\r
+ const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+ if (status !== 'OK') {\r
+ return { status, publicKey: null, address: null }\r
}\r
- Ledger.#isInternal = false\r
- super('Ledger')\r
- this.#accounts = new AccountList()\r
- }\r
-\r
- get status (): DeviceStatus { return this.#status }\r
\r
- /**\r
- * Gets the index and public key for an account from the Ledger device.\r
- *\r
- * @param {number} index - Wallet index of the account\r
- * @returns Promise for the Account at the index specified\r
- */\r
- async account (index: number): Promise<Account> {\r
- const { status, publicKey } = await Ledger.#account(index)\r
- if (status !== 'OK' || publicKey == null) {\r
- throw new Error(`Error getting Ledger account: ${status}`)\r
- }\r
- return Account.load({ index, publicKey })\r
- }\r
+ try {\r
+ const publicKey = bytes.toHex(response.slice(0, 32))\r
+ const addressLength = response[32]\r
+ const address = response.slice(33, 33 + addressLength).toString()\r
\r
- /**\r
- * Retrieves accounts from a Ledger wallet using its internal secure software.\r
- * Defaults to the first account at index 0.\r
- *\r
- * The returned object will have keys corresponding with the requested range\r
- * of account indexes. The value of each key will be the Account derived for\r
- * that index in the wallet.\r
- *\r
- * ```\r
- * const accounts = await wallet.accounts(0, 1))\r
- * // outputs the first and second account of the wallet\r
- * console.log(accounts)\r
- * // {\r
- * // 0: {\r
- * // address: <...>,\r
- * // publicKey: <...>,\r
- * // index: 0,\r
- * // <etc...>\r
- * // },\r
- * // 1: {\r
- * // address: <...>,\r
- * // publicKey: <...>,\r
- * // index: 1,\r
- * // <etc...>\r
- * // }\r
- * // }\r
- * // individual accounts can be referenced using array index notation\r
- * console.log(accounts[1])\r
- * // { address: <...>, publicKey: <...>, index: 1, <etc...> }\r
- * ```\r
- *\r
- * If `from` is greater than `to`, their values will be swapped.\r
- * @param {number} from - Start index of accounts. Default: 0\r
- * @param {number} to - End index of accounts. Default: `from`\r
- * @returns {AccountList} Promise for a list of Accounts at the specified indexes\r
- */\r
- async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
- if (from > to) [from, to] = [to, from]\r
- const output = new AccountList()\r
- const indexes: number[] = []\r
- for (let i = from; i <= to; i++) {\r
- if (this.#accounts[i] == null) {\r
- indexes.push(i)\r
- } else {\r
- output[i] = this.#accounts[i]\r
- }\r
- }\r
- if (indexes.length > 0) {\r
- const publicAccounts = []\r
- for (const index of indexes) {\r
- publicAccounts.push(await this.account(index))\r
- }\r
- for (const a of publicAccounts) {\r
- if (a.index == null) {\r
- throw new RangeError('Index missing for Account')\r
- }\r
- output[a.index] = this.#accounts[a.index] = a\r
- }\r
+ return { status, publicKey, address }\r
+ } catch (err) {\r
+ return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }\r
}\r
- return output\r
}\r
\r
/**\r
* - LOCKED: Nano app is open but the device locked after a timeout\r
* - CONNECTED: Nano app is open and listening\r
*/\r
- async connect (): Promise<DeviceStatus> {\r
- const version = await this.#version()\r
+ static async connect (): Promise<DeviceStatus> {\r
+ const version = await this.version()\r
if (version.status !== 'OK') {\r
this.#status = 'DISCONNECTED'\r
} else if (version.name === 'Nano') {\r
- const { status } = await Ledger.#account()\r
+ const { status } = await Ledger.account()\r
if (status === 'OK') {\r
this.#status = 'CONNECTED'\r
} else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {\r
return this.#status\r
}\r
\r
- /**\r
- * Removes encrypted secrets in storage and releases variable references to\r
- * allow garbage collection.\r
- */\r
- async destroy (): Promise<void> {\r
- await super.destroy()\r
- this.lock()\r
- }\r
-\r
- /**\r
- * Revokes permission granted by the user to access the Ledger device.\r
- *\r
- * The 'quit app' ADPU command has not passed testing, so this is the only way\r
- * to ensure the connection is severed at this time. `setTimeout` is used to\r
- * expire any lingering transient user activation.\r
- *\r
- * Overrides the default wallet `lock()` method since as a hardware wallet it\r
- * does not need to be encrypted by software.\r
- */\r
- lock (): void {\r
- setTimeout(async () => {\r
- const devices = await globalThis.navigator.usb.getDevices()\r
- for (const device of devices) {\r
- if (device.vendorId === ledgerUSBVendorId) {\r
- device.forget()\r
- }\r
- }\r
- })\r
- }\r
-\r
/**\r
* Sign a block with the Ledger device.\r
*\r
* @param {Block} block - Block data to sign\r
* @param {Block} [frontier] - Previous block data to cache in the device\r
*/\r
- async sign (index: number, block: Block, frontier?: Block): Promise<void>\r
- async sign (index: number, block: Block, frontier?: Block): Promise<void> {\r
+ static async sign (index: number, block: Block, frontier?: Block): Promise<void>\r
+ static async sign (index: number, block: Block, frontier?: Block): Promise<void> {\r
try {\r
if (typeof index !== 'number') {\r
throw new TypeError('Index must be a number', { cause: index })\r
throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index })\r
}\r
if (frontier != null) {\r
- const { status } = await this.#cacheBlock(index, frontier)\r
+ const { status } = await Ledger.#cacheBlock(index, frontier)\r
if (status !== 'OK') {\r
throw new Error('Failed to cache frontier block in ledger', { cause: status })\r
}\r
}\r
console.log('Waiting for signature confirmation on Ledger device...')\r
- const { status, signature, hash } = await this.#signBlock(index, block)\r
+ const { status, signature, hash } = await Ledger.#signBlock(index, block)\r
if (status !== 'OK') {\r
throw new Error('Signing with ledger failed', { cause: status })\r
}\r
}\r
}\r
\r
- /**\r
- * Attempts to connect to the Ledger device.\r
- *\r
- * Overrides the default wallet `unlock()` method since as a hardware wallet it\r
- * does not need to be encrypted by software.\r
- *\r
- * @returns True if successfully unlocked\r
- */\r
- async unlock (): Promise<void> {\r
- const status = await this.connect()\r
- if (await status !== 'CONNECTED') {\r
- throw new Error('Failed to unlock wallet', { cause: status })\r
- }\r
- }\r
-\r
/**\r
* Update cache from raw block data. Suitable for offline use.\r
*\r
* @param {number} index - Account number\r
* @param {object} block - JSON-formatted block data\r
*/\r
- async updateCache (index: number, block: Block): Promise<LedgerResponse>\r
+ static async updateCache (index: number, block: Block): Promise<LedgerResponse>\r
/**\r
* Update cache from a block hash by calling out to a node. Suitable for online\r
* use only.\r
* @param {string} hash - Hexadecimal block hash\r
* @param {Rpc} rpc - Rpc class object with a node URL\r
*/\r
- async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
- async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
+ static async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
+ static async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
if (typeof input === 'string' && node instanceof Rpc) {\r
const data = {\r
'json_block': 'true',\r
return { status }\r
}\r
\r
+ /**\r
+ * Get the version of the current process. If a specific app is running, get\r
+ * the app version. Otherwise, get the Ledger BOLOS version instead.\r
+ *\r
+ * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
+ *\r
+ * @returns Status, process name, and version\r
+ */\r
+ static async version (): Promise<LedgerVersionResponse> {\r
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
+ const response = await transport\r
+ .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+ await transport.close()\r
+\r
+ const statusCode = bytes.toDec(response.slice(-2)) as number\r
+ const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+ if (status !== 'OK') {\r
+ return { status, name: null, version: null }\r
+ }\r
+\r
+ const nameLength = response[1]\r
+ const name = response.slice(2, 2 + nameLength).toString()\r
+ const versionLength = response[2 + nameLength]\r
+ const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()\r
+\r
+ return { status, name, version }\r
+ }\r
+\r
/**\r
* Checks whether a given seed matches the wallet seed. The wallet must be\r
* unlocked prior to verification.\r
* @param {string} seed - Hexadecimal seed to be matched against the wallet data\r
* @returns True if input matches wallet seed\r
*/\r
- async verify (seed: string): Promise<boolean>\r
+ static async verify (seed: string): Promise<boolean>\r
/**\r
* Checks whether a given mnemonic phrase matches the wallet mnemonic. If a\r
* personal salt was used when generating the mnemonic, it cannot be verified.\r
* @param {string} mnemonic - Phrase to be matched against the wallet data\r
* @returns True if input matches wallet mnemonic\r
*/\r
- async verify (mnemonic: string): Promise<boolean>\r
- async verify (secret: string): Promise<boolean> {\r
+ static async verify (mnemonic: string): Promise<boolean>\r
+ static async verify (secret: string): Promise<boolean> {\r
const testWallet = await Wallet.load('BIP-44', '', secret)\r
await testWallet.unlock('')\r
const testAccount = await testWallet.account(0)\r
.send(testAccount.address, 0)\r
await testWallet.sign(0, testOpenBlock)\r
try {\r
- await this.sign(0, testSendBlock, testOpenBlock)\r
+ await Ledger.sign(0, testSendBlock, testOpenBlock)\r
return testSendBlock.signature === testOpenBlock.signature\r
} catch (err) {\r
throw new Error('Failed to verify wallet', { cause: err })\r
}\r
}\r
\r
- /**\r
- * Get the version of the current process. If a specific app is running, get\r
- * the app version. Otherwise, get the Ledger BOLOS version instead.\r
- *\r
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
- *\r
- * @returns Status, process name, and version\r
- */\r
- async version (): Promise<LedgerVersionResponse> {\r
- return await this.#version()\r
- }\r
-\r
- #accounts: AccountList\r
- #status: DeviceStatus = 'DISCONNECTED'\r
-\r
/**\r
* Close the currently running app and return to the device dashboard.\r
*\r
return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))\r
}\r
\r
- /**\r
- * Request an account at a specific BIP-44 index.\r
- *\r
- * @returns Response object containing command status, public key, and address\r
- */\r
- static async #account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\r
- }\r
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
- const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])\r
-\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
- if (status !== 'OK') {\r
- return { status, publicKey: null, address: null }\r
- }\r
-\r
- try {\r
- const publicKey = bytes.toHex(response.slice(0, 32))\r
- const addressLength = response[32]\r
- const address = response.slice(33, 33 + addressLength).toString()\r
-\r
- return { status, publicKey, address }\r
- } catch (err) {\r
- return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }\r
- }\r
- }\r
-\r
/**\r
* Cache frontier block in device memory.\r
*\r
* @param {any} block - Block data to cache\r
* @returns Status of command\r
*/\r
- async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {\r
+ static async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {\r
if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
throw new TypeError('Invalid account index')\r
}\r
return { status: Ledger.#STATUS_CODES[response] }\r
}\r
\r
- #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+ static #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
console.log(e)\r
if (e.device?.vendorId === ledgerUSBVendorId) {\r
console.log('Ledger connected')\r
}\r
}\r
\r
- #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+ static #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
console.log(e)\r
if (e.device?.vendorId === ledgerUSBVendorId) {\r
console.log('Ledger disconnected')\r
* @param {object} block - Block data to sign\r
* @returns {Promise} Status, signature, and block hash\r
*/\r
- async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {\r
+ static async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {\r
if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
throw new TypeError('Invalid account index')\r
}\r
\r
throw new Error('Unexpected byte length from device signature', { cause: response })\r
}\r
-\r
- /**\r
- * Get the version of the current process. If a specific app is running, get\r
- * the app version. Otherwise, get the Ledger BOLOS version instead.\r
- *\r
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
- *\r
- * @returns Status, process name, and version\r
- */\r
- async #version (): Promise<LedgerVersionResponse> {\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
- if (status !== 'OK') {\r
- return { status, name: null, version: null }\r
- }\r
-\r
- const nameLength = response[1]\r
- const name = response.slice(2, 2 + nameLength).toString()\r
- const versionLength = response[2 + nameLength]\r
- const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()\r
-\r
- return { status, name, version }\r
- }\r
}\r