From 6462099ad4ff1fb4c83ea753fe37d6c5663a12e2 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Fri, 22 Aug 2025 16:38:52 -0700 Subject: [PATCH] v0.3.0 --- CHANGELOG.md | 36 ++++++++++ README.md | 137 +++++++++++++++++++++----------------- package.json | 2 +- src/lib/wallet/index.ts | 2 +- src/types.d.ts | 143 ++++++++++------------------------------ 5 files changed, 150 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbc292..4ef965c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,42 @@ SPDX-FileCopyrightText: 2025 Chris Duncan SPDX-License-Identifier: GPL-3.0-or-later --> +## v0.3.0 + +### Notable Changes + +#### Reorganization + +It is easy to get lost when scrolling through code in monolithic long files, and +it is also equally easy to get lost in too many layers of project file +hierarchy. This update aims to find a balance between the two and has separated +key behavior into smaller modules while resisting a deeply-nested directory +structure. Basically, this update moved files around. + +#### `import` methods are now `load` methods + +To avoid confusion with the `import` JavaScript keyword, `import()` methods like +`Wallet.import()` and `Account.import()` have been renamed to `load()`. + +#### Expanded account info + +The `wallet.refresh()` method now uses batch endpoints to fetch basic account +data like balance, frontier, and representative. The `account.refresh()` method +now fetches all available data from the relevant endpoint, including confirmed +block information. In pratical use, the wallet method should be called when +viewing multiple accounts and basic data should be presented to the end user, +and the account method should be called when viewing an individual account or +creating and processing transactions. + +#### Ledger aligned with other wallet types + +The various functions of the Ledger wallet have been abstracted away under the +hood of the overarching `Wallet` class. Account derivation, secret verification, +and block signing can all be done with the same methods for a 'Ledger'-type +wallet is `libnemo` as its 'BIP-44' and 'BLAKE2b' counterparts. + + + ## v0.2.1 ### Notable Changes diff --git a/README.md b/README.md index 9b8572a..8b90593 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ SPDX-License-Identifier: GPL-3.0-or-later # libnemo -`libnemo` is a fork of the nanocurrency-web toolkit. It is used for client-side -implementations of Nano cryptocurrency wallets and enables building web-based -applications that can work even while offline. `libnemo` supports managing -wallets, deriving accounts, signing blocks, and more. +`libnemo` started as a fork of the `nanocurrency-web` toolkit and has evolved +into its own distinct package. It is used for client-side implementations of +Nano cryptocurrency wallets and enables building web-based applications that can +work even while offline. `libnemo` supports managing wallets, deriving accounts, + signing blocks, and more. It utilizes the Web Crypto API which is native to all modern browsers. Private keys are encrypted in storage with a user password as soon as they are derived, @@ -29,7 +30,7 @@ described by nano spec. * Get account info and process blocks on the network while online. * Manage known addresses with a rolodex. * Sign and verify arbitrary strings with relevant keys. -* Validate entropy, seeds, mnemonic phrases, and Nano addresses. +* Validate seeds, mnemonic phrases, and Nano addresses. * Convert Nano unit denominations. ## Installation @@ -58,12 +59,18 @@ For clarity, the following terms are used throughout the library: allow a single wallet to store multiple currencies `libnemo` is able to generate and import HD and BLAKE2b wallets, and it can -derive accounts for both. An HD wallet seed is 128 characters while a BLAKE2b -wallet seed is 64 characters. For enhanced security, `libnemo` requires a -password to create or import wallets, and wallets are initialized in a locked -state. Implementations can provide their own Uint8Array bytes instead of a -password. Refer to the documentation on each class factory method for specific -usage. +derive accounts for both. An HD wallet seed is 64 bytes (128 hexadecimal +characters), and a BLAKE2b wallet seed is 32 bytes (64 hexadecimal characters). + +For enhanced security, `libnemo` requires a password to create or load wallets, +and wallets are initialized in a locked state. When importing an existing +wallet, the seed and, if included, the mnemonic phrase (collectively referenced +herein as "wallet secrets") are inaccessible; since the user provided them, the +user should already have a copy of them. For convenience, a verification method +can be used to compare a user-provided value to the wallet value and return true +if they are equal. When creating a new wallet, the wallet secrets are each +accessible **once** and self-destruct after the first access of each of their +values. ```javascript import { Wallet } from 'libnemo' @@ -79,17 +86,15 @@ const wallet = await Wallet.load('BLAKE2b', password, mnemonic) ```javascript try { - const unlockResult = await wallet.unlock(password) -} catch(err) { - console.log(err) + await wallet.unlock(password) +} catch (err) { + console.error(err) } -console.log(unlockResult) // true if successfully unlocked -const { mnemonic, seed } = wallet const firstAccount = await wallet.account() const secondAccount = await wallet.account(1) const multipleAccounts = await wallet.accounts(2, 3) -const thirdAccount = multipleAccounts[3] +const thirdAccount = multipleAccounts[2] const { address, publicKey, index } = firstAccount const nodeUrl = 'https://nano-node.example.com/' @@ -108,87 +113,97 @@ receive and calculates the balance change itself. All blocks are 'state' types, but they are interpreted as one of three different subtypes based on the data they contain: send, receive, or change -representative. `libnemo` implements them as the following classes: +representative. `libnemo` implements three methods to handle them appropriately: -* SendBlock: the Nano balance of the account decreases -* ReceivBlock: the Nano balance of the account increases and requires a matching -SendBlock -* ChangeBlock: the representative for the account changes while the Nano balance -does not +* `send(recipient, amount)`: the Nano balance of the account decreases +* `receive(hash, amount)`: the Nano balance of the account increases and +requires a matching send block hash +* `change(representative)`: the representative for the account changes while the +Nano balance does not _Nano protocol allows changing the representative at the same time as a balance -change. `libnemo` does not implement this for purposes of clarity; all -ChangeBlock objects will maintain the same Nano balance._ +change. `libnemo` does not implement this for purposes of clarity; all change +block objects will maintain the same Nano balance._ Always fetch the most up to date information for the account from the network using the [account_info RPC command](https://docs.nano.org/commands/rpc-protocol/#account_info) -which can then be used to populate the block parameters. +which can then be used to populate the block parameters. This can be done on a +per-account basis with the `account.refresh()` method. Blocks require a small proof-of-work that must be calculated for the block to be accepted by the network. This can be provided when creating the block, generated -with the `block.pow()` method, or a requested from a public node that allows the +locally with the `block.pow()` method, or requested from a public node that +allows the [work_generate RPC command](https://docs.nano.org/commands/rpc-protocol/#work_generate). Finally, the block must be signed with the private key of the account. `libnemo` -accounts can sign blocks offline if desired. After being signed, the block can -be published to the network with the +wallets, accounts, and blocks can all create signatures, event offline if +desired. After being signed, the block can be published to the network with the +`block.process()` method or by separately calling out to the [process RPC command](https://docs.nano.org/commands/rpc-protocol/#process). #### Creating blocks ```javascript -import { SendBlock, ReceiveBlock, ChangeBlock } from 'libnemo' +import { Block } from 'libnemo' -const send = new SendBlock( +const sendBlock = new Block( 'nano_1pu7p5n3ghq1i1p4rhmek41f5add1uh34xpb94nkbxe8g4a6x1p69emk8y1d', // sender '5618869000000000000000000000000', // current balance - 'nano_3phqgrqbso99xojkb1bijmfryo7dy1k38ep1o3k3yrhb7rqu1h1k47yu78gz', // recipient - '2000000000000000000000000000000', // amount to send - 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou', // representative '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', // hash of previous block - 'fbffed7c73b61367' // PoW nonce (optional at first but required to process) + 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou' // representative +) +sendBlock.send( + 'nano_3phqgrqbso99xojkb1bijmfryo7dy1k38ep1o3k3yrhb7rqu1h1k47yu78gz', // recipient + '2000000000000000000000000000000' // amount to send +) +await sendBlock.pow( + 'fbffed7c73b61367' // PoW nonce (argument is optional) +) +await sendBlock.sign(wallet, accountIndex) // signature added to block +await sendBlock.process( + 'https://nano-node.example.com' // must be online ) -const receive = new ReceiveBlock( +const receiveBlock = await new Block( 'nano_1pu7p5n3ghq1i1p4rhmek41f5add1uh34xpb94nkbxe8g4a6x1p69emk8y1d', // recipient '18618869000000000000000000000000', // current balance + '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', // hash of previous block + 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou' // representative +) +.receive( // methods can be chained 'CBC911F57B6827649423C92C88C0C56637A4274FF019E77E24D61D12B5338783', // origin (hash of matching send block) '7000000000000000000000000000000', // amount that was sent - 'nano_1stofnrxuz3cai7ze75o174bpm7scwj9jn3nxsn8ntzg784jf1gzn1jjdkou', // representative - '92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D', // hash of previous block - 'c5cf86de24b24419' // PoW nonce (optional at first but required to process) ) +.pow( + 'c5cf86de24b24419' // PoW nonce (synchronous if value provided) +) +.sign(wallet, accountIndex, frontier) // frontier may be necessary when using Ledger devices -const change = new ChangeBlock( +const changeBlock = await new Block( 'nano_1pu7p5n3ghq1i1p4rhmek41f5add1uh34xpb94nkbxe8g4a6x1p69emk8y1d', // account redelegating vote weight '3000000000000000000000000000000', // current balance - 'nano_1anrzcuwe64rwxzcco8dkhpyxpi8kd7zsjc1oeimpc3ppca4mrjtwnqposrs', // new representative - '128106287002E595F479ACD615C818117FCB3860EC112670557A2467386249D4', // hash of previous block - '0000000000000000' // PoW nonce (optional at first but required to process) + '128106287002E595F479ACD615C818117FCB3860EC112670557A2467386249D4' // hash of previous block ) +.change( + 'nano_1anrzcuwe64rwxzcco8dkhpyxpi8kd7zsjc1oeimpc3ppca4mrjtwnqposrs' // new representative +) +sign( + '1495F2D49159CC2EAAAA97EBB42346418E1268AFF16D7FCA90E6BAD6D0965520' // sign blocks with literal private keys too +) +.pow() // async if calculating locally ``` #### Signing a block with a wallet ```javascript -const wallet = await Bip44Wallet.create('password123') +const wallet = await Wallet.create('BIP-44', 'password123') await wallet.unlock('password123') try { await wallet.sign(0, block) } catch (err) { - console.log(err) -} -``` - -#### Signing a block with a detached account - -```javascript -const account = await Account.import({privateKey: K, index: 0}, 'password123') -try { - await account.sign(block, 'password123') -} catch (err) { - console.log(err) + console.error(err) } ``` @@ -199,7 +214,7 @@ const privateKey = '3BE4FC2EF3F3B7374E6FC4FB6E7BB153F8A2998B3B3DAB50853EABE12802 try { await block.sign(privateKey) } catch (err) { - console.log(err) + console.error(err) } ``` @@ -209,7 +224,7 @@ try { try { await block.pow() } catch (err) { - console.log(err) + console.error(err) } ``` @@ -220,7 +235,7 @@ const node = new Rpc('https://nano-node.example.com/') try { await block.pow('https://nano-node.example.com/') } catch (err) { - console.log(err) + console.error(err) } ``` @@ -231,7 +246,7 @@ const node = new Rpc('https://nano-node.example.com', 'nodes-api-key') try { const hash = await block.process('https://nano-node.example.com/') } catch (err) { - console.log(err) + console.error(err) } ``` @@ -240,7 +255,7 @@ try { #### Converting Nano denominations Raw values are the native unit of exchange throughout libnemo and are -represented by the primitive bigint data type. Other supported denominations +represented by the primitive `bigint` data type. Other supported denominations are as follows: | Unit | Raw | diff --git a/package.json b/package.json index 26a25a5..73042e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "libnemo", - "version": "0.2.1", + "version": "0.3.0", "description": "Asynchronous, non-blocking Nano cryptocurrency integration toolkit.", "keywords": [ "nemo", diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 75c8987..96bc703 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -54,7 +54,7 @@ export class Wallet { * @param {string} [salt=''] - Used when generating the final seed * @returns {Wallet} A newly instantiated Wallet */ - static async create (type: 'BIP-44' | 'BLAKE2b', password?: string, mnemonicSalt?: string): Promise + static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise static async create (type: WalletType, password?: string, mnemonicSalt?: string): Promise { Wallet.#isInternal = true const self = new this(type) diff --git a/src/types.d.ts b/src/types.d.ts index 6062b46..4d8eb03 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -13,17 +13,19 @@ import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' */ export declare class Account { #private + [key: string]: any + static get DB_NAME (): 'Account' get address (): string get index (): number | undefined get publicKey (): string get confirmed_balance (): bigint | undefined - get confirmed_block_height (): number | undefined + get confirmed_height (): number | undefined get confirmed_frontier (): string | undefined get confirmed_frontier_block (): Block | undefined get confirmed_receivable (): bigint | undefined get confirmed_representative (): Account | undefined get balance (): bigint | undefined - get block_height (): number | undefined + get block_count (): number | undefined get frontier (): string | undefined get frontier_block (): Block | undefined get open_block (): string | undefined @@ -32,13 +34,13 @@ export declare class Account { get representative_block (): string | undefined get weight (): bigint | undefined set confirmed_balance (v: bigint | number | string) - set confirmed_block_height (v: number | undefined) + set confirmed_height (v: number | undefined) set confirmed_frontier (v: string | undefined) set confirmed_frontier_block (v: Block | undefined) set confirmed_receivable (v: bigint | number | string) set confirmed_representative (v: unknown) set balance (v: bigint | number | string) - set block_height (v: number | undefined) + set block_count (v: number | undefined) set frontier (v: string | undefined) set frontier_block (v: Block | undefined) set open_block (v: string | undefined) @@ -48,8 +50,7 @@ export declare class Account { set weight (v: bigint | number | string) private constructor () /** - * Removes encrypted secrets in storage and releases variable references to - * allow garbage collection. + * Releases variable references to allow garbage collection. */ destroy (): Promise /** @@ -132,11 +133,11 @@ export declare class Account { * @param {string} address - Nano address to validate * @throws Error if address is undefined, not a string, or an invalid format */ - static validate (address: unknown): asserts address is string + static validate (address: string): asserts address is string } export declare class AccountList extends Object { [index: number]: Account - get length (): number; + get length (): number [Symbol.iterator] (): Iterator } @@ -146,16 +147,20 @@ export declare class AccountList extends Object { export declare class Bip39 { #private /** + * https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt + */ + static wordlist: readonly string[] + /** * Derives a mnemonic phrase from source of entropy or seed. * * The entropy must be between 16-32 bytes (32-64 characters) to stay within * the limit of 128-256 bits defined in BIP-39. Typically, wallets use the * maximum entropy allowed. * - * @param {(string|ArrayBuffer|Uint8Array)} entropy - Cryptographically secure random value + * @param {(ArrayBuffer|Uint8Array)} entropy - Cryptographically secure random value * @returns {string} Mnemonic phrase created using the BIP-39 wordlist */ - static fromEntropy (entropy: string | ArrayBuffer | Uint8Array): Promise + static fromEntropy (entropy: ArrayBuffer | Uint8Array): Promise /** * Imports and validates an existing mnemonic phrase. * @@ -234,7 +239,7 @@ export declare class Blake2b { * @param {Uint8Array} [personal] - (_optional_) Arbitrary user-specified value */ constructor (length: number, key?: Uint8Array, salt?: Uint8Array, personal?: Uint8Array) - update (input: Uint8Array): Blake2b + update (input: ArrayBuffer | Uint8Array): Blake2b digest (): Uint8Array digest (out: 'hex'): string digest (out: 'binary' | Uint8Array): Uint8Array @@ -400,7 +405,7 @@ export type KeyPair = { * saved under one nickname. */ export declare class Rolodex { - #private + static get DB_NAME (): 'Rolodex' /** * Adds an address to the rolodex under a specific nickname. * @@ -486,6 +491,7 @@ type SweepResult = { address: string message: string } + /** * Converts a decimal amount of nano from one unit divider to another. * @@ -542,7 +548,7 @@ export type WalletType = 'BIP-44' | 'BLAKE2b' | 'Ledger' */ export declare class Wallet { #private - static DB_NAME: string + static get DB_NAME (): 'Wallet' /** * Retrieves all encrypted wallets from the database. * @@ -564,7 +570,7 @@ export declare class Wallet { * @param {string} [salt=''] - Used when generating the final seed * @returns {Wallet} A newly instantiated Wallet */ - static create (type: 'BIP-44' | 'BLAKE2b', password?: string, mnemonicSalt?: string): Promise + static create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise /** * Imports an existing HD wallet by using an entropy value generated using a * cryptographically strong pseudorandom number generator.NamedD @@ -783,10 +789,13 @@ interface LedgerSignResponse extends LedgerResponse { * calls. This wallet does not feature any seed nor mnemonic phrase as all * private keys are held in the secure chip of the device. As such, the user * is responsible for using Ledger technology to back up these pieces of data. +* +* https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md */ -export declare class Ledger extends Wallet { +export declare class Ledger { #private static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID + static UsbVendorId: number static SYMBOL: Symbol /** * Check which transport protocols are supported by the browser and return the @@ -794,63 +803,17 @@ export declare class Ledger extends Wallet { */ static get isUnsupported (): boolean /** - * Creates a new Ledger hardware wallet communication layer by dynamically - * importing the ledger.js service. - * - * @returns {Ledger} A wallet containing accounts and a Ledger device communication object - */ - static create (): Promise - /** - * Overrides `import()` from the base Wallet class since Ledger secrets cannot - * be extracted from the device. - */ - static import (): Promise - private constructor () - get status (): DeviceStatus - /** - * Gets the index and public key for an account from the Ledger device. + * Status of the Ledger device connection. * - * @param {number} index - Wallet index of the account - * @returns Promise for the Account at the index specified + * DISCONNECTED | BUSY | LOCKED | CONNECTED */ - account (index: number): Promise + static get status (): DeviceStatus /** - * Retrieves accounts from a Ledger wallet using its internal secure software. - * Defaults to the first account at index 0. - * - * The returned object will have keys corresponding with the requested range - * of account indexes. The value of each key will be the Account derived for - * that index in the wallet. - * - * ``` - * const accounts = await wallet.accounts(0, 1)) - * // outputs the first and second account of the wallet - * console.log(accounts) - * // { - * // 0: { - * // address: <...>, - * // publicKey: <...>, - * // index: 0, - * // - * // }, - * // 1: { - * // address: <...>, - * // publicKey: <...>, - * // index: 1, - * // - * // } - * // } - * // individual accounts can be referenced using array index notation - * console.log(accounts[1]) - * // { address: <...>, publicKey: <...>, index: 1, } - * ``` + * Request an account at a specific BIP-44 index. * - * If `from` is greater than `to`, their values will be swapped. - * @param {number} from - Start index of accounts. Default: 0 - * @param {number} to - End index of accounts. Default: `from` - * @returns {AccountList} Promise for a list of Accounts at the specified indexes + * @returns Response object containing command status, public key, and address */ - accounts (from?: number, to?: number): Promise + static account (index?: number, show?: boolean): Promise /** * Check if the Nano app is currently open and set device status accordingly. * @@ -860,23 +823,7 @@ export declare class Ledger extends Wallet { * - LOCKED: Nano app is open but the device locked after a timeout * - CONNECTED: Nano app is open and listening */ - connect (): Promise - /** - * Removes encrypted secrets in storage and releases variable references to - * allow garbage collection. - */ - destroy (): Promise - /** - * Revokes permission granted by the user to access the Ledger device. - * - * The 'quit app' ADPU command has not passed testing, so this is the only way - * to ensure the connection is severed at this time. `setTimeout` is used to - * expire any lingering transient user activation. - * - * Overrides the default wallet `lock()` method since as a hardware wallet it - * does not need to be encrypted by software. - */ - lock (): void + static connect (): Promise /** * Sign a block with the Ledger device. * @@ -884,23 +831,14 @@ export declare class Ledger extends Wallet { * @param {Block} block - Block data to sign * @param {Block} [frontier] - Previous block data to cache in the device */ - sign (index: number, block: Block, frontier?: Block): Promise - /** - * Attempts to connect to the Ledger device. - * - * Overrides the default wallet `unlock()` method since as a hardware wallet it - * does not need to be encrypted by software. - * - * @returns True if successfully unlocked - */ - unlock (): Promise + static sign (index: number, block: Block, frontier?: Block): Promise /** * Update cache from raw block data. Suitable for offline use. * * @param {number} index - Account number * @param {object} block - JSON-formatted block data */ - updateCache (index: number, block: Block): Promise + static updateCache (index: number, block: Block): Promise /** * Update cache from a block hash by calling out to a node. Suitable for online * use only. @@ -909,7 +847,7 @@ export declare class Ledger extends Wallet { * @param {string} hash - Hexadecimal block hash * @param {Rpc} rpc - Rpc class object with a node URL */ - updateCache (index: number, hash: string, rpc: Rpc): Promise + static updateCache (index: number, hash: string, rpc: Rpc): Promise /** * Checks whether a given seed matches the wallet seed. The wallet must be * unlocked prior to verification. @@ -917,7 +855,7 @@ export declare class Ledger extends Wallet { * @param {string} seed - Hexadecimal seed to be matched against the wallet data * @returns True if input matches wallet seed */ - verify (seed: string): Promise + static verify (seed: string): Promise /** * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a * personal salt was used when generating the mnemonic, it cannot be verified. @@ -926,14 +864,5 @@ export declare class Ledger extends Wallet { * @param {string} mnemonic - Phrase to be matched against the wallet data * @returns True if input matches wallet mnemonic */ - verify (mnemonic: string): Promise - /** - * Get the version of the current process. If a specific app is running, get - * the app version. Otherwise, get the Ledger BOLOS version instead. - * - * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information - * - * @returns Status, process name, and version - */ - version (): Promise + static verify (mnemonic: string): Promise } -- 2.47.3