From 40382c5af2bda454dc874e1c0d2ed34585e93f8a Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Sun, 5 Jul 2026 12:39:50 -0700 Subject: [PATCH] Declassify Tools class into module functions. --- src/index.ts | 2 +- src/lib/block/index.ts | 4 +- src/lib/block/receive.ts | 4 +- src/lib/block/send.ts | 4 +- src/lib/rolodex.ts | 2 +- src/lib/tools.ts | 380 ++++++++++++++++++++------------------- 6 files changed, 204 insertions(+), 192 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9b95e52..bbc9941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { Blake2b } from './lib/crypto' import { Ledger } from './lib/ledger' import { Rolodex } from './lib/rolodex' import { Rpc } from './lib/rpc' -import { Tools } from './lib/tools' +import * as Tools from './lib/tools' import { Wallet } from './lib/wallet' declare global { diff --git a/src/lib/block/index.ts b/src/lib/block/index.ts index c76dc86..71b53f1 100644 --- a/src/lib/block/index.ts +++ b/src/lib/block/index.ts @@ -7,7 +7,7 @@ import { DIFFICULTY_RECEIVE, DIFFICULTY_SEND, PREAMBLE } from '../constants' import { bytes, dec, hex } from '../convert' import { Blake2b } from '../crypto' import { Rpc } from '../rpc' -import { Tools } from '../tools' +import { convert } from '../tools' import { Wallet } from '../wallet' import { _change } from './change' import { _receive } from './receive' @@ -75,7 +75,7 @@ export class Block { throw new TypeError('Account frontier is unknown') } this.account = account - this.balance = Tools.convert(balance, 'raw', 'raw', 'bigint') + this.balance = convert(balance, 'raw', 'raw', 'bigint') this.previous = hex.toBytes(previous, 32) if (representative instanceof Account) { this.representative = representative diff --git a/src/lib/block/receive.ts b/src/lib/block/receive.ts index 7d82373..bfb5229 100644 --- a/src/lib/block/receive.ts +++ b/src/lib/block/receive.ts @@ -4,7 +4,7 @@ import type { Block } from '.' import { UNITS } from '../constants' import { hex } from '../convert' -import { Tools } from '../tools' +import { convert } from '../tools' /** * Set the amount of nano that this block will receive from a corresponding @@ -31,7 +31,7 @@ export function _receive (block: Block, sendBlock: unknown, amount: unknown, uni if (typeof amount !== 'bigint' && typeof amount !== 'number' && typeof amount !== 'string') { throw new TypeError('Invalid amount') } - block.balance += Tools.convert(amount, unit, 'raw', 'bigint') + block.balance += convert(amount, unit, 'raw', 'bigint') if (typeof sendBlock !== 'string' && !(sendBlock instanceof (block.constructor as typeof Block))) { throw new TypeError('Invalid send block') diff --git a/src/lib/block/send.ts b/src/lib/block/send.ts index e49df01..00552af 100644 --- a/src/lib/block/send.ts +++ b/src/lib/block/send.ts @@ -5,7 +5,7 @@ import type { Block } from '.' import { Account } from '../account' import { UNITS } from '../constants' import { hex } from '../convert' -import { Tools } from '../tools' +import { convert } from '../tools' /** * Set the amount of nano that this block will send to a recipient account. * @@ -30,7 +30,7 @@ export function _send (block: Block, account: unknown, amount: unknown, unit: un if (typeof amount !== 'bigint' && typeof amount !== 'number' && typeof amount !== 'string') { throw new TypeError(`Invalid amount ${amount}`, { cause: typeof amount }) } - block.balance -= Tools.convert(amount, unit, 'raw', 'bigint') + block.balance -= convert(amount, unit, 'raw', 'bigint') if (block.balance < 0) { throw new RangeError('Insufficient funds', { cause: block.balance }) diff --git a/src/lib/rolodex.ts b/src/lib/rolodex.ts index 147f630..02dd422 100644 --- a/src/lib/rolodex.ts +++ b/src/lib/rolodex.ts @@ -3,7 +3,7 @@ import { Account } from './account' import { Database } from './database' -import { Tools } from './tools' +import * as Tools from './tools' /** * Represents a basic address book of Nano accounts. Multiple addresses can be diff --git a/src/lib/tools.ts b/src/lib/tools.ts index b48d722..b1df95f 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -15,208 +15,220 @@ type SweepResult = { message: string } -const decoder = Object.freeze(new TextDecoder()) -const encoder = Object.freeze(new TextEncoder()) - +/** + * Decodes bytes to a UTF-8 string. + * + * @param {AllowSharedBufferSource} [input] + * @param {TextDecodeOptions} [options] + * @returns {string} Decoded string + */ export function decode (input?: AllowSharedBufferSource, options?: TextDecodeOptions): string { return decoder.decode(input, options) } +/** + * Encodes a UTF-8 string to bytes. + * + * @param {string} [input] + * @returns {Bytes} Encoded bytes + */ export function encode (input?: string): Bytes { return encoder.encode(input) } -export class Tools { - static #normalize (input: string | ArrayBuffer | Bytes): Bytes { - return (typeof input === 'string') - ? hex.toBytes(input) - : input instanceof ArrayBuffer - ? new Uint8Array(input.slice()) - : input - } - /** - * Converts a decimal amount of nano from one unit divider to another. - * - * @param {(bigint|number|string)} amount - Decimal amount to convert - * @param {string} inputUnit - Current denomination - * @param {string} outputUnit - Desired denomination - * @param {string} [format] - Data type of output - */ - static convert (amount: bigint | number | string, inputUnit: string, outputUnit: string): string - static convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'bigint'): bigint - static convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'number'): number - static convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'string'): string - static convert (amount: unknown, inputUnit: unknown, outputUnit: unknown, format?: unknown): bigint | number | string { - if (typeof amount !== 'bigint' && typeof amount !== 'number' && typeof amount !== 'string') { - throw new Error('Invalid amount', { cause: typeof amount }) - } - if (typeof amount === 'string' && !/^[0-9]+\.?[0-9]*$/.test(amount)) { - throw new Error('Invalid amount', { cause: amount }) - } - if (typeof inputUnit !== 'string') { - throw new TypeError('Invalid input unit', { cause: typeof inputUnit }) - } - (inputUnit as string) = inputUnit.toUpperCase() - if (typeof outputUnit !== 'string') { - throw new TypeError('Invalid output unit', { cause: typeof outputUnit }) - } - (outputUnit as string) = outputUnit.toUpperCase() - if (UNITS[inputUnit] == null) { - throw new Error(`Unknown denomination ${inputUnit}, expected one of the following: ${Object.keys(UNITS)}`) - } - if (UNITS[outputUnit] == null) { - throw new Error(`Unknown denomination ${outputUnit}, expected one of the following: ${Object.keys(UNITS)}`) - } - if (format !== undefined && format !== 'bigint' && format !== 'number' && format !== 'string') { - throw new Error('Invalid output format', { cause: format }) - } +/** + * Converts a decimal amount of nano from one unit divider to another. + * + * @param {(bigint|number|string)} amount - Decimal amount to convert + * @param {string} inputUnit - Current denomination + * @param {string} outputUnit - Desired denomination + * @param {string} [format] - Data type of output + */ +export function convert (amount: bigint | number | string, inputUnit: string, outputUnit: string): string +export function convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'bigint'): bigint +export function convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'number'): number +export function convert (amount: bigint | number | string, inputUnit: string, outputUnit: string, format: 'string'): string +export function convert (amount: unknown, inputUnit: unknown, outputUnit: unknown, format?: unknown): bigint | number | string { + if (typeof amount !== 'bigint' && typeof amount !== 'number' && typeof amount !== 'string') { + throw new Error('Invalid amount', { cause: typeof amount }) + } + if (typeof amount === 'string' && !/^[0-9]+\.?[0-9]*$/.test(amount)) { + throw new Error('Invalid amount', { cause: amount }) + } + if (typeof inputUnit !== 'string') { + throw new TypeError('Invalid input unit', { cause: typeof inputUnit }) + } + (inputUnit as string) = inputUnit.toUpperCase() + if (typeof outputUnit !== 'string') { + throw new TypeError('Invalid output unit', { cause: typeof outputUnit }) + } + (outputUnit as string) = outputUnit.toUpperCase() + if (UNITS[inputUnit] == null) { + throw new Error(`Unknown denomination ${inputUnit}, expected one of the following: ${Object.keys(UNITS)}`) + } + if (UNITS[outputUnit] == null) { + throw new Error(`Unknown denomination ${outputUnit}, expected one of the following: ${Object.keys(UNITS)}`) + } + if (format !== undefined && format !== 'bigint' && format !== 'number' && format !== 'string') { + throw new Error('Invalid output format', { cause: format }) + } - const inUnit = UNITS[inputUnit] - let [i, f] = typeof amount === 'string' - ? amount.split('.') - : dec.toString(amount).split('.') - i = i.replace(/^0*/g, '') - f = f?.replace(/0*$/g, '') - if (f?.length > inUnit) { - throw new RangeError('Amount contains fractional raw') - } + const inUnit = UNITS[inputUnit] + let [i, f] = typeof amount === 'string' + ? amount.split('.') + : dec.toString(amount).split('.') + i = i.replace(/^0*/g, '') + f = f?.replace(/0*$/g, '') + if (f?.length > inUnit) { + throw new RangeError('Amount contains fractional raw') + } - // convert to raw - let shift = 0 - while (shift++ < inUnit) i += '0' - let int = BigInt(i || '0') - if (f != null) { - shift = f.length - while (shift++ < inUnit) f += '0' - int += BigInt(f || '0') - } - if (int > MAX_SUPPLY) { - throw new Error('Amount exceeds available supply') + // convert to raw + let shift = 0 + while (shift++ < inUnit) i += '0' + let int = BigInt(i || '0') + if (f != null) { + shift = f.length + while (shift++ < inUnit) f += '0' + int += BigInt(f || '0') + } + if (int > MAX_SUPPLY) { + throw new Error('Amount exceeds available supply') + } + if (int < 0n) { + throw new Error('Amount must be non-negative') + } + + // convert to desired denomination + const outUnit = UNITS[outputUnit] + i = dec.toString(int, 40) + f = i.slice(40 - outUnit).replace(/0*$/g, '') + i = i.slice(0, 40 - outUnit).replace(/^0*/g, '') + const output = `${i === '' ? '0' : i}${f === '' ? '' : '.'}${f}` + + switch (format) { + case 'bigint': { + if (!f) return BigInt(output) + throw new RangeError('Output fractional amount truncated') } - if (int < 0n) { - throw new Error('Amount must be non-negative') + case 'number': { + if (Number(i) <= Number.MAX_SAFE_INTEGER) return Number(output) + throw new RangeError('Output larger than Number.MAX_SAFE_INTEGER') } + case 'string': + default: return output + } +} - // convert to desired denomination - const outUnit = UNITS[outputUnit] - i = dec.toString(int, 40) - f = i.slice(40 - outUnit).replace(/0*$/g, '') - i = i.slice(0, 40 - outUnit).replace(/^0*/g, '') - const output = `${i === '' ? '0' : i}${f === '' ? '' : '.'}${f}` - - switch (format) { - case 'bigint': { - if (!f) return BigInt(output) - throw new RangeError('Output fractional amount truncated') - } - case 'number': { - if (Number(i) <= Number.MAX_SAFE_INTEGER) return Number(output) - throw new RangeError('Output larger than Number.MAX_SAFE_INTEGER') - } - case 'string': - default: return output - } +/** + * Signs an arbitrary string with a secret key using nano25519. The input data + * is encoded as UTF-8 and can be up to 32 KiB in total. + * + * @param {(string|ArrayBuffer|Bytes)} secretKey - 64-byte secret key + * @param {string} input - Data to be signed + * @returns {string} 64-byte hexadecimal signature + */ +export function sign (secretKey: string | ArrayBuffer | Bytes, input: string): string { + if (navigator.userActivation?.isActive === false) { + throw new DOMException( + 'Signing request was blocked due to lack of user activation', + 'NotAllowedError' + ) + } + const k = normalize(secretKey) + try { + const signature = nano25519_sign(utf8.toBytes(input), k) + return bytes.toHex(signature) + } catch (err) { + throw new Error(`Failed to sign message`, { cause: err }) + } finally { + k.fill(0) } +} - /** - * Signs an arbitrary string with a secret key using nano25519. The input data - * is encoded as UTF-8 and can be up to 32 KiB in total. - * - * @param {(string|ArrayBuffer|Bytes)} secretKey - 64-byte secret key - * @param {string} input - Data to be signed - * @returns {string} 64-byte hexadecimal signature - */ - static sign (secretKey: string | ArrayBuffer | Bytes, input: string): string { - if (navigator.userActivation?.isActive === false) { - throw new DOMException( - 'Signing request was blocked due to lack of user activation', - 'NotAllowedError' - ) - } - const k = this.#normalize(secretKey) - try { - const signature = nano25519_sign(utf8.toBytes(input), k) - return bytes.toHex(signature) - } catch (err) { - throw new Error(`Failed to sign message`, { cause: err }) - } finally { - k.fill(0) - } +/** + * Collects the funds from a specified range of accounts in a wallet and sends + * them all to a single recipient address. Hardware wallets are unsupported. + * + * @param {(Rpc|string|URL)} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks + * @param {Wallet} wallet - Wallet from which to sweep funds + * @param {string} recipient - Destination address for all swept funds + * @param {number} [from=0] - Starting account index to sweep + * @param {number} [to=from] - Ending account index to sweep + * @returns An array of results including both successes and failures + */ +export async function sweep ( + rpc: Rpc | string | URL, + wallet: Wallet, + recipient: string, + from: number = 0, + to: number = from +): Promise { + if (rpc == null || wallet == null || recipient == null) { + throw new ReferenceError('Missing required sweep arguments') + } + if (typeof rpc === 'string' || rpc instanceof URL) { + rpc = new Rpc(rpc) + } + if (!(rpc instanceof Rpc)) { + throw new TypeError('RPC must be a valid node') } - /** - * Collects the funds from a specified range of accounts in a wallet and sends - * them all to a single recipient address. Hardware wallets are unsupported. - * - * @param {(Rpc|string|URL)} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks - * @param {Wallet} wallet - Wallet from which to sweep funds - * @param {string} recipient - Destination address for all swept funds - * @param {number} [from=0] - Starting account index to sweep - * @param {number} [to=from] - Ending account index to sweep - * @returns An array of results including both successes and failures - */ - static async sweep ( - rpc: Rpc | string | URL, - wallet: Wallet, - recipient: string, - from: number = 0, - to: number = from - ): Promise { - if (rpc == null || wallet == null || recipient == null) { - throw new ReferenceError('Missing required sweep arguments') - } - if (typeof rpc === 'string' || rpc instanceof URL) { - rpc = new Rpc(rpc) - } - if (!(rpc instanceof Rpc)) { - throw new TypeError('RPC must be a valid node') - } + const blockQueue: Promise[] = [] + const results: SweepResult[] = [] + const recipientAccount = new Account(recipient) + const accounts = await wallet.refresh(rpc, from, to) - const blockQueue: Promise[] = [] - const results: SweepResult[] = [] - const recipientAccount = new Account(recipient) - const accounts = await wallet.refresh(rpc, from, to) - - for (const [index, account] of accounts) { - const blockRequest: Promise = new Promise(async (resolve) => { - let block - try { - if (index == null) { - throw new TypeError('Account index is required', { cause: account }) - } - block = await new Block(account) - .send(recipientAccount, account.balance ?? 0n) - .sign(wallet, index) - await block.pow() - const hash = await block.process(rpc) - results.push({ status: 'success', address: block.account.address, message: hash }) - } catch (err: any) { - results.push({ status: 'error', address: account.address, message: err.message }) - } finally { - resolve() + for (const [index, account] of accounts) { + const blockRequest: Promise = new Promise(async (resolve) => { + let block + try { + if (index == null) { + throw new TypeError('Account index is required', { cause: account }) } - }) - blockQueue.push(blockRequest) - } - await Promise.allSettled(blockQueue) - return results - } - - /** - * Verifies the signature of an arbitrary string using a public key. - * - * @param {(string|ArrayBuffer|Bytes)} publicKey - 32-byte hexadecimal public key - * @param {(string|ArrayBuffer|Bytes)} signature - 128-character hexadcimal signature - * @param {string} input - Data to be verified - * @returns {boolean} True if the data was signed by the public key's matching private key - */ - static verify (publicKey: string | ArrayBuffer | Bytes, signature: string | ArrayBuffer | Bytes, input: string): boolean { - const k = this.#normalize(publicKey) - const s = this.#normalize(signature) - try { - return nano25519_verify(s, utf8.toBytes(input), k) - } catch (err) { - throw new Error('Failed to verify signature', { cause: err }) - } + block = await new Block(account) + .send(recipientAccount, account.balance ?? 0n) + .sign(wallet, index) + await block.pow() + const hash = await block.process(rpc) + results.push({ status: 'success', address: block.account.address, message: hash }) + } catch (err: any) { + results.push({ status: 'error', address: account.address, message: err.message }) + } finally { + resolve() + } + }) + blockQueue.push(blockRequest) + } + await Promise.allSettled(blockQueue) + return results +} + +/** + * Verifies the signature of an arbitrary string using a public key. + * + * @param {(string|ArrayBuffer|Bytes)} publicKey - 32-byte hexadecimal public key + * @param {(string|ArrayBuffer|Bytes)} signature - 128-character hexadcimal signature + * @param {string} input - Data to be verified + * @returns {boolean} True if the data was signed by the public key's matching private key + */ +export function verify (publicKey: string | ArrayBuffer | Bytes, signature: string | ArrayBuffer | Bytes, input: string): boolean { + const k = normalize(publicKey) + const s = normalize(signature) + try { + return nano25519_verify(s, utf8.toBytes(input), k) + } catch (err) { + throw new Error('Failed to verify signature', { cause: err }) } } + +function normalize (input: string | ArrayBuffer | Bytes): Bytes { + return (typeof input === 'string') + ? hex.toBytes(input) + : input instanceof ArrayBuffer + ? new Uint8Array(input.slice()) + : input +} + +const decoder = Object.freeze(new TextDecoder()) +const encoder = Object.freeze(new TextEncoder()) -- 2.52.0