diff --git a/setupDashClient-core.mjs b/setupDashClient-core.mjs new file mode 100644 index 0000000..031f1b5 --- /dev/null +++ b/setupDashClient-core.mjs @@ -0,0 +1,597 @@ +/* eslint-disable max-classes-per-file */ +// +// Browser-safe core of setupDashClient. +// +// This file contains the parts of setupDashClient that work in any JS runtime: +// - createClient(network) +// - dip13KeyPath(network, identityIndex, keyIndex) +// - IdentityKeyManager +// - AddressKeyManager +// - KEY_SPECS +// +// The Node-only convenience layer (dotenv loading, process.env-driven +// clientConfig, and the setupDashClient() wrapper) lives in setupDashClient.mjs +// alongside this file. Both Node tutorials and browser apps import the same +// IdentityKeyManager from here so there is no copy-paste drift. +// +import { + EvoSDK, + IdentityPublicKeyInCreation, + IdentitySigner, + KeyType, + PlatformAddressSigner, + PrivateKey, + Purpose, + SecurityLevel, + wallet, +} from '@dashevo/evo-sdk'; + +/** @typedef {import('@dashevo/evo-sdk').Identity} Identity */ +/** @typedef {import('@dashevo/evo-sdk').IdentityPublicKey} IdentityPublicKey */ +/** @typedef {import('@dashevo/evo-sdk').PlatformAddress} PlatformAddress */ +/** @typedef {import('@dashevo/evo-sdk').PlatformAddressInfo} PlatformAddressInfo */ +/** @typedef {import('@dashevo/evo-sdk').NetworkLike} NetworkLike */ + +/** + * @typedef {Object} DerivedKeyEntry + * @property {number} keyId + * @property {string} privateKeyWif + * @property {string} [publicKey] - Only present via createForNewIdentity() + */ + +/** + * @typedef {Object} AddressEntry + * @property {PlatformAddress} address + * @property {string} bech32m + * @property {string} privateKeyWif + * @property {string} path + */ + +// ⚠️ Tutorial helper — holds WIFs in memory for convenience. +// Do not use this pattern as-is for production key management. + +// --------------------------------------------------------------------------- +// Browser-safe hex → bytes (replaces Buffer.from(hex, 'hex')) +// --------------------------------------------------------------------------- + +/** + * Decode a hex string to a Uint8Array. Browser-safe replacement for + * Buffer.from(hex, 'hex'). Throws on odd-length or non-hex input. + * + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + if (typeof hex !== 'string' || hex.length % 2 !== 0) { + throw new Error('hexToBytes: expected even-length hex string'); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i += 1) { + const offset = i * 2; + const chunk = hex.slice(offset, offset + 2); + if (!/^[0-9A-Fa-f]{2}$/.test(chunk)) { + throw new Error(`hexToBytes: invalid hex at offset ${offset}`); + } + out[i] = parseInt(chunk, 16); + } + return out; +} + +// --------------------------------------------------------------------------- +// DIP-13 key path +// --------------------------------------------------------------------------- + +/** + * Build a DIP-13 identity key derivation path. + * Returns the full 7-level hardened path: + * m/9'/{coin}'/5'/0'/0'/{identityIndex}'/{keyIndex}' + * @param {string} network + * @param {number} identityIndex + * @param {number} keyIndex + * @returns {Promise} + */ +export async function dip13KeyPath(network, identityIndex, keyIndex) { + const base = + network === 'testnet' + ? await wallet.derivationPathDip13Testnet(5) + : await wallet.derivationPathDip13Mainnet(5); + return `${base.path}/0'/0'/${identityIndex}'/${keyIndex}'`; +} + +// --------------------------------------------------------------------------- +// SDK client helpers +// --------------------------------------------------------------------------- + +/** + * Create and connect an EvoSDK client for the selected network. + * + * @param {string} [network='testnet'] + * @returns {Promise} + */ +export async function createClient(network = 'testnet') { + const factories = /** @type {Record EvoSDK>} */ ({ + testnet: () => EvoSDK.testnetTrusted(), + mainnet: () => EvoSDK.mainnetTrusted(), + local: () => EvoSDK.localTrusted(), + }); + + const factory = factories[network]; + if (!factory) { + throw new Error( + `Unknown network "${network}". Use: ${Object.keys(factories).join(', ')}`, + ); + } + + const sdk = /** @type {EvoSDK} */ (factory()); + await sdk.connect(); + return sdk; +} + +// --------------------------------------------------------------------------- +// IdentityKeyManager +// --------------------------------------------------------------------------- + +/** Key specs for the 5 standard identity keys (DIP-9). */ +export const KEY_SPECS = [ + { + keyId: 0, + purpose: Purpose.AUTHENTICATION, + securityLevel: SecurityLevel.MASTER, + }, + { + keyId: 1, + purpose: Purpose.AUTHENTICATION, + securityLevel: SecurityLevel.HIGH, + }, + { + keyId: 2, + purpose: Purpose.AUTHENTICATION, + securityLevel: SecurityLevel.CRITICAL, + }, + { + keyId: 3, + purpose: Purpose.TRANSFER, + securityLevel: SecurityLevel.CRITICAL, + }, + { + keyId: 4, + purpose: Purpose.ENCRYPTION, + securityLevel: SecurityLevel.MEDIUM, + }, +]; + +/** + * Manages identity keys and signing for write operations. + * + * Mirrors the old js-dash-sdk pattern where `setupDashClient()` hid all + * wallet/signing config. Construct once, then call getAuth(), getTransfer(), + * or getMaster() to get a ready-to-use { identity, identityKey, signer }. + * + * Keys are derived from a BIP39 mnemonic using standard DIP-9 paths + * (compatible with dash-evo-tool / Dash wallets): + * Key 0 = MASTER (identity updates) + * Key 1 = HIGH auth (documents, names) + * Key 2 = CRITICAL auth (contracts, documents, names) + * Key 3 = TRANSFER (credit transfers/withdrawals) + * Key 4 = ENCRYPTION MEDIUM (encrypted messaging/data) + */ +class IdentityKeyManager { + /** + * @param {EvoSDK} sdk + * @param {string|null|undefined} identityId + * @param {Record} keys + * @param {number} identityIndex + */ + constructor(sdk, identityId, keys, identityIndex) { + this.sdk = sdk; + this.id = identityId; + this.keys = keys; // { master, auth, authHigh, transfer, encryption } + this.identityIndex = identityIndex ?? 0; + } + + get identityId() { + return this.id; + } + + /** + * Create an IdentityKeyManager from a BIP39 mnemonic. + * Derives all standard identity keys using DIP-9 paths. + * + * @param {object} opts + * @param {EvoSDK} opts.sdk - Connected EvoSDK instance + * @param {string} [opts.identityId] - Identity ID. If omitted, auto-resolved + * from the mnemonic by looking up the master key's public key hash on-chain. + * @param {string} opts.mnemonic - BIP39 mnemonic + * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' + * @param {number} [opts.identityIndex=0] - Which identity derived from this mnemonic + * @returns {Promise} + */ + static async create({ + sdk, + identityId, + mnemonic, + network = 'testnet', + identityIndex = 0, + }) { + const derive = async (/** @type {number} */ keyIndex) => + wallet.deriveKeyFromSeedWithPath({ + mnemonic, + path: await dip13KeyPath(network, identityIndex, keyIndex), + network, + }); + + const [masterKey, authHighKey, authKey, transferKey, encryptionKey] = + await Promise.all([ + derive(0), // MASTER + derive(1), // HIGH auth + derive(2), // CRITICAL auth + derive(3), // TRANSFER + derive(4), // ENCRYPTION MEDIUM + ]); + + let resolvedId = identityId; + if (!resolvedId) { + const privateKey = PrivateKey.fromWIF(masterKey.toObject().privateKeyWif); + const pubKeyHash = privateKey.getPublicKeyHash(); + const identity = await sdk.identities.byPublicKeyHash(pubKeyHash); + if (!identity) { + throw new Error( + 'No identity found for the given mnemonic (key 0 public key hash)', + ); + } + resolvedId = identity.id.toString(); + } + + return new IdentityKeyManager( + sdk, + resolvedId, + { + master: { keyId: 0, privateKeyWif: masterKey.toObject().privateKeyWif }, + authHigh: { + keyId: 1, + privateKeyWif: authHighKey.toObject().privateKeyWif, + }, + auth: { keyId: 2, privateKeyWif: authKey.toObject().privateKeyWif }, + transfer: { + keyId: 3, + privateKeyWif: transferKey.toObject().privateKeyWif, + }, + encryption: { + keyId: 4, + privateKeyWif: encryptionKey.toObject().privateKeyWif, + }, + }, + identityIndex, + ); + } + + /** + * Find the first unused DIP-9 identity index for a mnemonic. + * Scans indices starting at 0 until no on-chain identity is found. + * + * @param {EvoSDK} sdk - Connected EvoSDK instance + * @param {string} mnemonic - BIP39 mnemonic + * @param {string} [network='testnet'] - 'testnet' or 'mainnet' + * @returns {Promise} The first unused identity index + */ + static async findNextIndex(sdk, mnemonic, network = 'testnet') { + /* eslint-disable no-await-in-loop */ + for (let i = 0; ; i += 1) { + const path = await dip13KeyPath(network, i, 0); + const key = await wallet.deriveKeyFromSeedWithPath({ + mnemonic, + path, + network, + }); + const privateKey = PrivateKey.fromWIF(key.toObject().privateKeyWif); + const existing = await sdk.identities.byPublicKeyHash( + privateKey.getPublicKeyHash(), + ); + if (!existing) return i; + } + /* eslint-enable no-await-in-loop */ + } + + /** + * Create an IdentityKeyManager for a new (not yet registered) identity. + * Derives keys and stores public key data needed for identity creation. + * If identityIndex is omitted, auto-selects the next unused index. + * + * @param {object} opts + * @param {EvoSDK} opts.sdk - Connected EvoSDK instance + * @param {string} opts.mnemonic - BIP39 mnemonic + * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' + * @param {number} [opts.identityIndex] - Identity index (auto-scanned if omitted) + * @returns {Promise} + */ + static async createForNewIdentity({ + sdk, + mnemonic, + network = 'testnet', + identityIndex, + }) { + const idx = + identityIndex ?? + (await IdentityKeyManager.findNextIndex(sdk, mnemonic, network)); + const derive = async (/** @type {number} */ keyIndex) => + wallet.deriveKeyFromSeedWithPath({ + mnemonic, + path: await dip13KeyPath(network, idx, keyIndex), + network, + }); + + const derivedKeys = await Promise.all( + KEY_SPECS.map((spec) => derive(spec.keyId)), + ); + + const keys = { + master: { + keyId: 0, + privateKeyWif: derivedKeys[0].toObject().privateKeyWif, + publicKey: derivedKeys[0].toObject().publicKey, + }, + authHigh: { + keyId: 1, + privateKeyWif: derivedKeys[1].toObject().privateKeyWif, + publicKey: derivedKeys[1].toObject().publicKey, + }, + auth: { + keyId: 2, + privateKeyWif: derivedKeys[2].toObject().privateKeyWif, + publicKey: derivedKeys[2].toObject().publicKey, + }, + transfer: { + keyId: 3, + privateKeyWif: derivedKeys[3].toObject().privateKeyWif, + publicKey: derivedKeys[3].toObject().publicKey, + }, + encryption: { + keyId: 4, + privateKeyWif: derivedKeys[4].toObject().privateKeyWif, + publicKey: derivedKeys[4].toObject().publicKey, + }, + }; + + return new IdentityKeyManager(sdk, null, keys, idx); + } + + /** + * Build IdentityPublicKeyInCreation objects for all 5 standard keys. + * Only works when public key data is available (via createForNewIdentity). + * + * @returns {IdentityPublicKeyInCreation[]} + */ + getKeysInCreation() { + return KEY_SPECS.map((spec) => { + const key = Object.values(this.keys).find((k) => k.keyId === spec.keyId); + if (!key?.publicKey) { + throw new Error( + `Public key data not available for key ${spec.keyId}. Use createForNewIdentity().`, + ); + } + const pubKeyData = hexToBytes(key.publicKey); + return new IdentityPublicKeyInCreation({ + keyId: spec.keyId, + purpose: spec.purpose, + securityLevel: spec.securityLevel, + keyType: KeyType.ECDSA_SECP256K1, + data: pubKeyData, + }); + }); + } + + /** + * Build an IdentitySigner loaded with all 5 key WIFs. + * Useful for identity creation where all keys must sign. + * + * @returns {IdentitySigner} + */ + getFullSigner() { + const signer = new IdentitySigner(); + Object.values(this.keys).forEach((key) => { + signer.addKeyFromWif(key.privateKeyWif); + }); + return signer; + } + + /** + * Fetch identity and build { identity, identityKey, signer } for a given key. + * @param {string} keyName - One of: master, auth, authHigh, transfer, encryption + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getSigner(keyName) { + if (!this.id) { + throw new Error( + 'Identity ID is not set. Use IdentityKeyManager.create() for an existing identity, ' + + 'or create/register the identity first and then set the ID.', + ); + } + const key = /** @type {Record} */ (this.keys)[ + keyName + ]; + if (!key) { + throw new Error( + `Unknown key "${keyName}". Use: ${Object.keys(this.keys).join(', ')}`, + ); + } + const identity = await this.sdk.identities.fetch(this.id); + if (!identity) { + throw new Error(`Identity "${this.id}" not found on-chain.`); + } + const identityKey = identity.getPublicKeyById(key.keyId); + const signer = new IdentitySigner(); + signer.addKeyFromWif(key.privateKeyWif); + return { identity, identityKey, signer }; + } + + /** + * CRITICAL auth (key 2) — contracts, documents, names. + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getAuth() { + return this.getSigner('auth'); + } + + /** + * HIGH auth (key 1) — documents, names. + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getAuthHigh() { + return this.getSigner('authHigh'); + } + + /** + * TRANSFER — credit transfers, withdrawals. + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getTransfer() { + return this.getSigner('transfer'); + } + + /** + * ENCRYPTION MEDIUM — encrypted messaging/data. + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getEncryption() { + return this.getSigner('encryption'); + } + + /** + * MASTER — identity updates (add/disable keys). + * @param {string[]} [additionalKeyWifs] - WIFs for new keys being added + * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} + */ + async getMaster(additionalKeyWifs) { + const result = await this.getSigner('master'); + if (additionalKeyWifs) { + additionalKeyWifs.forEach((wif) => result.signer.addKeyFromWif(wif)); + } + return result; + } +} + +// --------------------------------------------------------------------------- +// AddressKeyManager +// --------------------------------------------------------------------------- + +/** + * Manages platform address keys and signing for address operations. + * + * Parallel to IdentityKeyManager but for platform address operations. + * Derives BIP44 keys from a mnemonic and provides ready-to-use + * PlatformAddressSigner instances. + * + * Platform addresses are bech32m-encoded L2 addresses (tdash1... on testnet) + * that hold credits directly, independent of identities. + */ +class AddressKeyManager { + /** + * @param {EvoSDK} sdk + * @param {AddressEntry[]} addresses + * @param {string} network + */ + constructor(sdk, addresses, network) { + this.sdk = sdk; + this.addresses = addresses; // [{ address, bech32m, privateKeyWif, path }] + this.network = network; + } + + /** The first derived address (index 0). */ + get primaryAddress() { + return this.addresses[0]; + } + + /** + * Create an AddressKeyManager from a BIP39 mnemonic. + * Derives platform address keys using BIP44 paths. + * + * @param {object} opts + * @param {EvoSDK} opts.sdk - Connected EvoSDK instance + * @param {string} opts.mnemonic - BIP39 mnemonic + * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' + * @param {number} [opts.count=1] - Number of addresses to derive + * @returns {Promise} + */ + static async create({ sdk, mnemonic, network = 'testnet', count = 1 }) { + const addresses = []; + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < count; i += 1) { + const pathInfo = + network === 'testnet' + ? await wallet.derivationPathBip44Testnet(0, 0, i) + : await wallet.derivationPathBip44Mainnet(0, 0, i); + const { path } = pathInfo; + const keyInfo = await wallet.deriveKeyFromSeedWithPath({ + mnemonic, + path, + network, + }); + const obj = keyInfo.toObject(); + const privateKey = PrivateKey.fromWIF(obj.privateKeyWif); + const signer = new PlatformAddressSigner(); + const platformAddress = signer.addKey(privateKey); + + addresses.push({ + address: platformAddress, + bech32m: platformAddress.toBech32m( + /** @type {NetworkLike} */ (network), + ), + privateKeyWif: obj.privateKeyWif, + path, + }); + } + /* eslint-enable no-await-in-loop */ + + return new AddressKeyManager(sdk, addresses, network); + } + + /** + * Create a PlatformAddressSigner with the primary key loaded. + * @returns {PlatformAddressSigner} + */ + getSigner() { + const signer = new PlatformAddressSigner(); + const privateKey = PrivateKey.fromWIF(this.primaryAddress.privateKeyWif); + signer.addKey(privateKey); + return signer; + } + + /** + * Create a PlatformAddressSigner with all derived keys loaded. + * @returns {PlatformAddressSigner} + */ + getFullSigner() { + const signer = new PlatformAddressSigner(); + this.addresses.forEach((addr) => { + const privateKey = PrivateKey.fromWIF(addr.privateKeyWif); + signer.addKey(privateKey); + }); + return signer; + } + + /** + * Fetch current balance and nonce for the primary address. + * @returns {Promise} + */ + async getInfo() { + return this.sdk.addresses.get(this.primaryAddress.bech32m); + } + + /** + * Fetch current balance and nonce for an address by index. + * @param {number} index - Address index + * @returns {Promise} + */ + async getInfoAt(index) { + const entry = this.addresses[index]; + if (!entry) { + throw new Error( + `No derived address at index ${index} (count=${this.addresses.length})`, + ); + } + return this.sdk.addresses.get(entry.bech32m); + } +} + +export { IdentityKeyManager, AddressKeyManager }; diff --git a/setupDashClient.mjs b/setupDashClient.mjs index 3c91a8f..a134f5e 100644 --- a/setupDashClient.mjs +++ b/setupDashClient.mjs @@ -1,15 +1,26 @@ -/* eslint-disable max-classes-per-file */ +// +// Node-only convenience wrapper around setupDashClient-core.mjs. +// +// This file adds the bits that only make sense in Node tutorials: +// - dotenv loading (PLATFORM_MNEMONIC / NETWORK from .env) +// - clientConfig (network + mnemonic, sourced from process.env) +// - setupDashClient() — the one-call wrapper used by tutorial scripts +// +// Everything browser-safe (createClient, IdentityKeyManager, AddressKeyManager, +// dip13KeyPath, KEY_SPECS) is re-exported from setupDashClient-core.mjs so a +// Node tutorial that imports from this file sees an identical API surface. +// +// Browser apps should import from setupDashClient-core.mjs directly. +// +import { wallet } from '@dashevo/evo-sdk'; + import { - EvoSDK, - IdentityPublicKeyInCreation, - IdentitySigner, - KeyType, - PlatformAddressSigner, - PrivateKey, - Purpose, - SecurityLevel, - wallet, -} from '@dashevo/evo-sdk'; + createClient, + dip13KeyPath, + KEY_SPECS, + IdentityKeyManager, + AddressKeyManager, +} from './setupDashClient-core.mjs'; // Load .env if dotenv is installed (optional — not needed for tutorials). // Top-level await requires ESM — .mjs extension ensures this. @@ -21,26 +32,7 @@ try { /* dotenv not installed */ } -/** @typedef {import('@dashevo/evo-sdk').Identity} Identity */ -/** @typedef {import('@dashevo/evo-sdk').IdentityPublicKey} IdentityPublicKey */ -/** @typedef {import('@dashevo/evo-sdk').PlatformAddress} PlatformAddress */ -/** @typedef {import('@dashevo/evo-sdk').PlatformAddressInfo} PlatformAddressInfo */ -/** @typedef {import('@dashevo/evo-sdk').NetworkLike} NetworkLike */ - -/** - * @typedef {Object} DerivedKeyEntry - * @property {number} keyId - * @property {string} privateKeyWif - * @property {string} [publicKey] - Only present via createForNewIdentity() - */ - -/** - * @typedef {Object} AddressEntry - * @property {PlatformAddress} address - * @property {string} bech32m - * @property {string} privateKeyWif - * @property {string} path - */ +/** @typedef {import('@dashevo/evo-sdk').EvoSDK} EvoSDK */ // ⚠️ Tutorial helper — holds WIFs in memory for convenience. // Do not use this pattern as-is for production key management. @@ -61,519 +53,6 @@ const clientConfig = { // mnemonic: 'your twelve word mnemonic phrase goes here ...', }; -/** - * Build a DIP-13 identity key derivation path. - * Returns the full 7-level hardened path: - * m/9'/{coin}'/5'/0'/0'/{identityIndex}'/{keyIndex}' - * @param {string} network - * @param {number} identityIndex - * @param {number} keyIndex - * @returns {Promise} - */ -export async function dip13KeyPath(network, identityIndex, keyIndex) { - const base = - network === 'testnet' - ? await wallet.derivationPathDip13Testnet(5) - : await wallet.derivationPathDip13Mainnet(5); - return `${base.path}/0'/0'/${identityIndex}'/${keyIndex}'`; -} - -// --------------------------------------------------------------------------- -// SDK client helpers -// --------------------------------------------------------------------------- - -/** - * Create and connect an EvoSDK client for the selected network. - * - * @param {string} [network='testnet'] - * @returns {Promise} - */ -export async function createClient(network = 'testnet') { - const factories = /** @type {Record EvoSDK>} */ ({ - testnet: () => EvoSDK.testnetTrusted(), - mainnet: () => EvoSDK.mainnetTrusted(), - local: () => EvoSDK.localTrusted(), - }); - - const factory = factories[network]; - if (!factory) { - throw new Error( - `Unknown network "${network}". Use: ${Object.keys(factories).join(', ')}`, - ); - } - - const sdk = /** @type {EvoSDK} */ (factory()); - await sdk.connect(); - return sdk; -} - -// --------------------------------------------------------------------------- -// IdentityKeyManager -// --------------------------------------------------------------------------- - -/** Key specs for the 5 standard identity keys (DIP-9). */ -const KEY_SPECS = [ - { - keyId: 0, - purpose: Purpose.AUTHENTICATION, - securityLevel: SecurityLevel.MASTER, - }, - { - keyId: 1, - purpose: Purpose.AUTHENTICATION, - securityLevel: SecurityLevel.HIGH, - }, - { - keyId: 2, - purpose: Purpose.AUTHENTICATION, - securityLevel: SecurityLevel.CRITICAL, - }, - { - keyId: 3, - purpose: Purpose.TRANSFER, - securityLevel: SecurityLevel.CRITICAL, - }, - { - keyId: 4, - purpose: Purpose.ENCRYPTION, - securityLevel: SecurityLevel.MEDIUM, - }, -]; - -/** - * Manages identity keys and signing for write operations. - * - * Mirrors the old js-dash-sdk pattern where `setupDashClient()` hid all - * wallet/signing config. Construct once, then call getAuth(), getTransfer(), - * or getMaster() to get a ready-to-use { identity, identityKey, signer }. - * - * Keys are derived from a BIP39 mnemonic using standard DIP-9 paths - * (compatible with dash-evo-tool / Dash wallets): - * Key 0 = MASTER (identity updates) - * Key 1 = HIGH auth (documents, names) - * Key 2 = CRITICAL auth (contracts, documents, names) - * Key 3 = TRANSFER (credit transfers/withdrawals) - * Key 4 = ENCRYPTION MEDIUM (encrypted messaging/data) - */ -class IdentityKeyManager { - /** - * @param {EvoSDK} sdk - * @param {string|null|undefined} identityId - * @param {Record} keys - * @param {number} identityIndex - */ - constructor(sdk, identityId, keys, identityIndex) { - this.sdk = sdk; - this.id = identityId; - this.keys = keys; // { master, auth, authHigh, transfer, encryption } - this.identityIndex = identityIndex ?? 0; - } - - get identityId() { - return this.id; - } - - /** - * Create an IdentityKeyManager from a BIP39 mnemonic. - * Derives all standard identity keys using DIP-9 paths. - * - * @param {object} opts - * @param {EvoSDK} opts.sdk - Connected EvoSDK instance - * @param {string} [opts.identityId] - Identity ID. If omitted, auto-resolved - * from the mnemonic by looking up the master key's public key hash on-chain. - * @param {string} opts.mnemonic - BIP39 mnemonic - * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' - * @param {number} [opts.identityIndex=0] - Which identity derived from this mnemonic - * @returns {Promise} - */ - static async create({ - sdk, - identityId, - mnemonic, - network = 'testnet', - identityIndex = 0, - }) { - const derive = async (/** @type {number} */ keyIndex) => - wallet.deriveKeyFromSeedWithPath({ - mnemonic, - path: await dip13KeyPath(network, identityIndex, keyIndex), - network, - }); - - const [masterKey, authHighKey, authKey, transferKey, encryptionKey] = - await Promise.all([ - derive(0), // MASTER - derive(1), // HIGH auth - derive(2), // CRITICAL auth - derive(3), // TRANSFER - derive(4), // ENCRYPTION MEDIUM - ]); - - let resolvedId = identityId; - if (!resolvedId) { - const privateKey = PrivateKey.fromWIF(masterKey.toObject().privateKeyWif); - const pubKeyHash = privateKey.getPublicKeyHash(); - const identity = await sdk.identities.byPublicKeyHash(pubKeyHash); - if (!identity) { - throw new Error( - 'No identity found for the given mnemonic (key 0 public key hash)', - ); - } - resolvedId = identity.id.toString(); - } - - return new IdentityKeyManager( - sdk, - resolvedId, - { - master: { keyId: 0, privateKeyWif: masterKey.toObject().privateKeyWif }, - authHigh: { - keyId: 1, - privateKeyWif: authHighKey.toObject().privateKeyWif, - }, - auth: { keyId: 2, privateKeyWif: authKey.toObject().privateKeyWif }, - transfer: { - keyId: 3, - privateKeyWif: transferKey.toObject().privateKeyWif, - }, - encryption: { - keyId: 4, - privateKeyWif: encryptionKey.toObject().privateKeyWif, - }, - }, - identityIndex, - ); - } - - /** - * Find the first unused DIP-9 identity index for a mnemonic. - * Scans indices starting at 0 until no on-chain identity is found. - * - * @param {EvoSDK} sdk - Connected EvoSDK instance - * @param {string} mnemonic - BIP39 mnemonic - * @param {string} [network='testnet'] - 'testnet' or 'mainnet' - * @returns {Promise} The first unused identity index - */ - static async findNextIndex(sdk, mnemonic, network = 'testnet') { - /* eslint-disable no-await-in-loop */ - for (let i = 0; ; i += 1) { - const path = await dip13KeyPath(network, i, 0); - const key = await wallet.deriveKeyFromSeedWithPath({ - mnemonic, - path, - network, - }); - const privateKey = PrivateKey.fromWIF(key.toObject().privateKeyWif); - const existing = await sdk.identities.byPublicKeyHash( - privateKey.getPublicKeyHash(), - ); - if (!existing) return i; - } - /* eslint-enable no-await-in-loop */ - } - - /** - * Create an IdentityKeyManager for a new (not yet registered) identity. - * Derives keys and stores public key data needed for identity creation. - * If identityIndex is omitted, auto-selects the next unused index. - * - * @param {object} opts - * @param {EvoSDK} opts.sdk - Connected EvoSDK instance - * @param {string} opts.mnemonic - BIP39 mnemonic - * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' - * @param {number} [opts.identityIndex] - Identity index (auto-scanned if omitted) - * @returns {Promise} - */ - static async createForNewIdentity({ - sdk, - mnemonic, - network = 'testnet', - identityIndex, - }) { - const idx = - identityIndex ?? - (await IdentityKeyManager.findNextIndex(sdk, mnemonic, network)); - const derive = async (/** @type {number} */ keyIndex) => - wallet.deriveKeyFromSeedWithPath({ - mnemonic, - path: await dip13KeyPath(network, idx, keyIndex), - network, - }); - - const derivedKeys = await Promise.all( - KEY_SPECS.map((spec) => derive(spec.keyId)), - ); - - const keys = { - master: { - keyId: 0, - privateKeyWif: derivedKeys[0].toObject().privateKeyWif, - publicKey: derivedKeys[0].toObject().publicKey, - }, - authHigh: { - keyId: 1, - privateKeyWif: derivedKeys[1].toObject().privateKeyWif, - publicKey: derivedKeys[1].toObject().publicKey, - }, - auth: { - keyId: 2, - privateKeyWif: derivedKeys[2].toObject().privateKeyWif, - publicKey: derivedKeys[2].toObject().publicKey, - }, - transfer: { - keyId: 3, - privateKeyWif: derivedKeys[3].toObject().privateKeyWif, - publicKey: derivedKeys[3].toObject().publicKey, - }, - encryption: { - keyId: 4, - privateKeyWif: derivedKeys[4].toObject().privateKeyWif, - publicKey: derivedKeys[4].toObject().publicKey, - }, - }; - - return new IdentityKeyManager(sdk, null, keys, idx); - } - - /** - * Build IdentityPublicKeyInCreation objects for all 5 standard keys. - * Only works when public key data is available (via createForNewIdentity). - * - * @returns {IdentityPublicKeyInCreation[]} - */ - getKeysInCreation() { - return KEY_SPECS.map((spec) => { - const key = Object.values(this.keys).find((k) => k.keyId === spec.keyId); - if (!key?.publicKey) { - throw new Error( - `Public key data not available for key ${spec.keyId}. Use createForNewIdentity().`, - ); - } - const pubKeyData = Uint8Array.from(Buffer.from(key.publicKey, 'hex')); - return new IdentityPublicKeyInCreation({ - keyId: spec.keyId, - purpose: spec.purpose, - securityLevel: spec.securityLevel, - keyType: KeyType.ECDSA_SECP256K1, - data: pubKeyData, - }); - }); - } - - /** - * Build an IdentitySigner loaded with all 5 key WIFs. - * Useful for identity creation where all keys must sign. - * - * @returns {IdentitySigner} - */ - getFullSigner() { - const signer = new IdentitySigner(); - Object.values(this.keys).forEach((key) => { - signer.addKeyFromWif(key.privateKeyWif); - }); - return signer; - } - - /** - * Fetch identity and build { identity, identityKey, signer } for a given key. - * @param {string} keyName - One of: master, auth, authHigh, transfer, encryption - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getSigner(keyName) { - if (!this.id) { - throw new Error( - 'Identity ID is not set. Use IdentityKeyManager.create() for an existing identity, ' + - 'or create/register the identity first and then set the ID.', - ); - } - const key = /** @type {Record} */ (this.keys)[ - keyName - ]; - if (!key) { - throw new Error( - `Unknown key "${keyName}". Use: ${Object.keys(this.keys).join(', ')}`, - ); - } - const identity = await this.sdk.identities.fetch(this.id); - if (!identity) { - throw new Error(`Identity "${this.id}" not found on-chain.`); - } - const identityKey = identity.getPublicKeyById(key.keyId); - const signer = new IdentitySigner(); - signer.addKeyFromWif(key.privateKeyWif); - return { identity, identityKey, signer }; - } - - /** - * CRITICAL auth (key 2) — contracts, documents, names. - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getAuth() { - return this.getSigner('auth'); - } - - /** - * HIGH auth (key 1) — documents, names. - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getAuthHigh() { - return this.getSigner('authHigh'); - } - - /** - * TRANSFER — credit transfers, withdrawals. - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getTransfer() { - return this.getSigner('transfer'); - } - - /** - * ENCRYPTION MEDIUM — encrypted messaging/data. - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getEncryption() { - return this.getSigner('encryption'); - } - - /** - * MASTER — identity updates (add/disable keys). - * @param {string[]} [additionalKeyWifs] - WIFs for new keys being added - * @returns {Promise<{ identity: Identity, identityKey: IdentityPublicKey | undefined, signer: IdentitySigner }>} - */ - async getMaster(additionalKeyWifs) { - const result = await this.getSigner('master'); - if (additionalKeyWifs) { - additionalKeyWifs.forEach((wif) => result.signer.addKeyFromWif(wif)); - } - return result; - } -} - -// --------------------------------------------------------------------------- -// AddressKeyManager -// --------------------------------------------------------------------------- - -/** - * Manages platform address keys and signing for address operations. - * - * Parallel to IdentityKeyManager but for platform address operations. - * Derives BIP44 keys from a mnemonic and provides ready-to-use - * PlatformAddressSigner instances. - * - * Platform addresses are bech32m-encoded L2 addresses (tdash1... on testnet) - * that hold credits directly, independent of identities. - */ -class AddressKeyManager { - /** - * @param {EvoSDK} sdk - * @param {AddressEntry[]} addresses - * @param {string} network - */ - constructor(sdk, addresses, network) { - this.sdk = sdk; - this.addresses = addresses; // [{ address, bech32m, privateKeyWif, path }] - this.network = network; - } - - /** The first derived address (index 0). */ - get primaryAddress() { - return this.addresses[0]; - } - - /** - * Create an AddressKeyManager from a BIP39 mnemonic. - * Derives platform address keys using BIP44 paths. - * - * @param {object} opts - * @param {EvoSDK} opts.sdk - Connected EvoSDK instance - * @param {string} opts.mnemonic - BIP39 mnemonic - * @param {string} [opts.network='testnet'] - 'testnet' or 'mainnet' - * @param {number} [opts.count=1] - Number of addresses to derive - * @returns {Promise} - */ - static async create({ sdk, mnemonic, network = 'testnet', count = 1 }) { - const addresses = []; - - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < count; i += 1) { - const pathInfo = - network === 'testnet' - ? await wallet.derivationPathBip44Testnet(0, 0, i) - : await wallet.derivationPathBip44Mainnet(0, 0, i); - const { path } = pathInfo; - const keyInfo = await wallet.deriveKeyFromSeedWithPath({ - mnemonic, - path, - network, - }); - const obj = keyInfo.toObject(); - const privateKey = PrivateKey.fromWIF(obj.privateKeyWif); - const signer = new PlatformAddressSigner(); - const platformAddress = signer.addKey(privateKey); - - addresses.push({ - address: platformAddress, - bech32m: platformAddress.toBech32m( - /** @type {NetworkLike} */ (network), - ), - privateKeyWif: obj.privateKeyWif, - path, - }); - } - /* eslint-enable no-await-in-loop */ - - return new AddressKeyManager(sdk, addresses, network); - } - - /** - * Create a PlatformAddressSigner with the primary key loaded. - * @returns {PlatformAddressSigner} - */ - getSigner() { - const signer = new PlatformAddressSigner(); - const privateKey = PrivateKey.fromWIF(this.primaryAddress.privateKeyWif); - signer.addKey(privateKey); - return signer; - } - - /** - * Create a PlatformAddressSigner with all derived keys loaded. - * @returns {PlatformAddressSigner} - */ - getFullSigner() { - const signer = new PlatformAddressSigner(); - this.addresses.forEach((addr) => { - const privateKey = PrivateKey.fromWIF(addr.privateKeyWif); - signer.addKey(privateKey); - }); - return signer; - } - - /** - * Fetch current balance and nonce for the primary address. - * @returns {Promise} - */ - async getInfo() { - return this.sdk.addresses.get(this.primaryAddress.bech32m); - } - - /** - * Fetch current balance and nonce for an address by index. - * @param {number} index - Address index - * @returns {Promise} - */ - async getInfoAt(index) { - const entry = this.addresses[index]; - if (!entry) { - throw new Error( - `No derived address at index ${index} (count=${this.addresses.length})`, - ); - } - return this.sdk.addresses.get(entry.bech32m); - } -} - // --------------------------------------------------------------------------- // setupDashClient — convenience wrapper // --------------------------------------------------------------------------- @@ -627,4 +106,13 @@ export async function setupDashClient({ return { sdk, keyManager, addressKeyManager }; } -export { IdentityKeyManager, AddressKeyManager, clientConfig }; +// Re-export everything from the core so existing imports +// (e.g. `import { IdentityKeyManager } from './setupDashClient.mjs'`) keep working. +export { + createClient, + dip13KeyPath, + KEY_SPECS, + IdentityKeyManager, + AddressKeyManager, + clientConfig, +}; diff --git a/test/setupDashClient.test.mjs b/test/setupDashClient.test.mjs index 595e075..640d7a6 100644 --- a/test/setupDashClient.test.mjs +++ b/test/setupDashClient.test.mjs @@ -411,6 +411,67 @@ describe('IdentityKeyManager', function suite() { expect(k).to.be.an.instanceOf(IdentityPublicKeyInCreation); }); }); + + it('should reject non-hex characters in publicKey (hexToBytes validation)', async function () { + // Regression: parseInt(chunk, 16) silently accepts chunks like '1z' as 1, + // so the previous Number.isNaN guard let invalid hex through. hexToBytes + // must strictly validate each 2-char chunk against /^[0-9A-Fa-f]{2}$/. + const base = await IdentityKeyManager.create({ + sdk, + identityId: IDENTITY_ID, + mnemonic: TEST_MNEMONIC, + }); + const pk = PrivateKey.fromWIF(base.keys.master.privateKeyWif); + const validHex = Buffer.from(pk.getPublicKey().toBytes()).toString('hex'); + // Replace bytes 2-3 with '1z' — even length, wrong alphabet. + const poisonedHex = `${validHex.slice(0, 2)}1z${validHex.slice(4)}`; + + const km = new IdentityKeyManager( + sdk, + null, + { + master: { ...base.keys.master, publicKey: poisonedHex }, + authHigh: { + ...base.keys.authHigh, + publicKey: Buffer.from( + PrivateKey.fromWIF(base.keys.authHigh.privateKeyWif) + .getPublicKey() + .toBytes(), + ).toString('hex'), + }, + auth: { + ...base.keys.auth, + publicKey: Buffer.from( + PrivateKey.fromWIF(base.keys.auth.privateKeyWif) + .getPublicKey() + .toBytes(), + ).toString('hex'), + }, + transfer: { + ...base.keys.transfer, + publicKey: Buffer.from( + PrivateKey.fromWIF(base.keys.transfer.privateKeyWif) + .getPublicKey() + .toBytes(), + ).toString('hex'), + }, + encryption: { + ...base.keys.encryption, + publicKey: Buffer.from( + PrivateKey.fromWIF(base.keys.encryption.privateKeyWif) + .getPublicKey() + .toBytes(), + ).toString('hex'), + }, + }, + 0, + ); + + expect(() => km.getKeysInCreation()) + .to.throw(Error) + .with.property('message') + .that.matches(/hexToBytes.*offset 2/); + }); }); describe('getSigner() guard', function () {