import { BaseWallet } from './base-wallet.js'; import { type TransactionTemplate, type WalletBlockchain, type WalletDependencies, } from './types.js'; import { TransactionBuilder } from './transaction-builder.js'; import { WalletP2PKH } from './wallet-p2pkh.js'; import { type BaseStore, Activities, StoreInMemory } from '../storage/index.js'; import { type DistributiveOmit } from '../utils/index.js'; import { type TokenBalances, HDPrivateNode, ExtMap, Mnemonic, calculateBalanceSats, calculateBalanceTokens, } from '@xocash/primitives'; import { binToNumberUint32LE, hexToBin } from '@bitauth/libauth'; //----------------------------------------------------------------------------- // Constants //----------------------------------------------------------------------------- /** * XO derivation purpose path. All XO key derivation starts from this BIP43 purpose node. * This matches engine/crypto's `XO_BASE_PATH = m/2415'`. */ export const XO_PURPOSE_PATH = "m/2415'"; /** * Default number of consecutive empty addresses before stopping the address gap scan. * If this many sequential child indexes produce addresses with no on-chain activity, * we assume there are no more used keys beyond this point. */ export const XO_ADDRESS_GAP = 100; /** * Template key use case index for signing and spending (e.g. Schnorr signatures, P2PKH). * Matches engine/crypto's `TemplateKeyUseCase.SigningAndSpending = 0`. */ export const XO_USE_CASE_SIGNING_AND_SPENDING = 0; /** * Template key use case index for encryption. * Not yet implemented in engine/crypto, but we scan it for forward-compatibility. * Matches engine/crypto's `AccountKeyUseCase.Encryption = 1`. */ export const XO_USE_CASE_ENCRYPTION = 1; /** * All use case indexes to scan for each template. * We check both so that keys derived under either use case are discovered. */ export const XO_USE_CASES = [ XO_USE_CASE_SIGNING_AND_SPENDING, XO_USE_CASE_ENCRYPTION, ]; /** * Maximum valid non-hardened BIP32 child index (2^31 - 1). * Matches engine/crypto's `NON_HARDENED_INDEX_MAX`. */ const NON_HARDENED_INDEX_MAX = 0x7fffffff; /** * Expected length of a template identifier hex string (32 bytes = 64 hex chars). * Template identifiers are SHA-256 hashes represented as hex. */ const TEMPLATE_IDENTIFIER_HEX_LENGTH = 64; //----------------------------------------------------------------------------- // Types //----------------------------------------------------------------------------- /** * Entropy sources for WalletXO. Accepts the same formats as WalletHD: * a hex seed string, an extended private key, or a Mnemonic instance. */ export type WalletXOEntropy = | { seed: string } | { xpriv: string } | { mnemonic: Mnemonic }; /** * Genesis data for constructing a WalletXO instance. * Contains the entropy source, the list of template identifiers to scan, * and an optional address gap override. */ export type WalletXOGenesisData = { type: 'WalletXO'; templateIdentifiers: string[]; addressGap?: number; } & WalletXOEntropy; //----------------------------------------------------------------------------- // Utilities //----------------------------------------------------------------------------- /** * Creates a 31-bit BIP32 non-hardened child index from a template identifier hex string. * This mirrors the engine/crypto `createPathIndexFromHash` function but operates on * the raw template identifier bytes directly (template identifiers are already 32-byte hashes). * * The algorithm reads the first 4 bytes as a little-endian uint32, then masks to 31 bits * to guarantee a valid non-hardened index (0 to 2,147,483,647). * * @see engine/crypto/source/utils.ts - createPathIndexFromHash * @param templateIdentifier - A 64-character hex string representing a 32-byte template identifier * @returns Path index (31-bit unsigned integer, 0 to 2,147,483,647) */ const createPathIndexFromTemplateIdentifier = ( templateIdentifier: string, ): number => { // Convert the hex template identifier to raw bytes. const hashBytes = hexToBin(templateIdentifier); // Read the first 4 bytes as a little-endian uint32. const uint32FirstFourBytes = binToNumberUint32LE(hashBytes.slice(0, 4)); // Mask to 31 bits to stay within the valid non-hardened index range. // eslint-disable-next-line no-bitwise return uint32FirstFourBytes & NON_HARDENED_INDEX_MAX; }; //----------------------------------------------------------------------------- // WalletXO //----------------------------------------------------------------------------- /** * A wallet that implements the same HD key derivation as the XO Crypto package. * This allows a user to import the mnemonic used with an XO Templates wallet * and then derive keys based on a template identifier. * * Crypto Package Reference: engine/crypto/source/derivation.ts * * Derivation Path: m/2415'/{templateIndex}'/{useCaseIndex}'/{childIndex} * * - templateIndex: derived from the first 4 bytes of the template identifier * (little-endian uint32, masked to 31 bits). This is computed via the same * algorithm as engine/crypto's `createPathIndexFromHash`. * - useCaseIndex: 0 (SigningAndSpending) or 1 (Encryption). Both are scanned. * - childIndex: 0..N, scanned using address gap logic to discover used keys. * * @example * * ```ts * // Create a WalletXO from a mnemonic and template identifiers. * const walletXO = await WalletXO.from( * { blockchain }, * { * mnemonic: Mnemonic.from('abandon abandon abandon ... about'), * templateIdentifiers: ['aabb...64hexchars...'], * }, * ); * * // Scan for transactions and start monitoring. * await walletXO.start(); * * // Dynamically add a new template later. * await walletXO.addTemplate('ccdd...64hexchars...'); * ``` */ export class WalletXO extends BaseWallet { /** * Factory method to create a new WalletXO instance with default dependencies. * Fills in optional dependencies (activities, cache) with defaults if not provided. * * @param deps - Wallet dependencies (blockchain required, activities/cache optional) * @param genesisData - Entropy source + template identifiers + optional address gap * @returns A new WalletXO instance */ static async from( this: T, deps: WalletDependencies, genesisData: DistributiveOmit, ) { // Assign defaults if not otherwise provided. const activities = deps.activities ? deps.activities : await Activities.from(); const cache = deps.cache ? deps.cache : StoreInMemory.from(); // Assemble the final dependencies with all required fields. const depsFinal = { activities, cache, blockchain: deps.blockchain, }; // Return a new instance of WalletXO. return new this(depsFinal, genesisData) as InstanceType; } // Dependencies. public readonly activities: Activities; public readonly blockchain: WalletBlockchain; public readonly cache: BaseStore; // Genesis Data. public readonly genesisData: WalletXOGenesisData; /** The root HD node derived from the provided entropy source. */ public readonly hdPrivateNode: HDPrivateNode; /** * The XO purpose HD node at m/2415'. * All template key derivations are relative to this node, matching * the engine/crypto package's `derivePurposeHDNodeFromSeed` output. */ public readonly purposeNode: HDPrivateNode; /** * Child wallets grouped by template identifier and use case index. * Structure: Map */ public wallets: Map = new Map(); constructor( dependencies: Required, genesisData: DistributiveOmit, ) { super(); // Assign our dependencies. this.activities = dependencies.activities; this.blockchain = dependencies.blockchain; this.cache = dependencies.cache; // Assign our genesis data with the type discriminator. this.genesisData = { type: 'WalletXO', ...genesisData, }; // Instantiate the HDPrivateNode from the entropy source provided in genesis data. if ('seed' in genesisData) { this.hdPrivateNode = HDPrivateNode.fromSeed(genesisData.seed); } else if ('mnemonic' in genesisData) { this.hdPrivateNode = HDPrivateNode.fromMnemonic(genesisData.mnemonic); } else { this.hdPrivateNode = HDPrivateNode.fromXPriv(genesisData.xpriv); } // Derive the XO purpose node at m/2415'. // This is the root for all template key derivations and matches the // engine/crypto purposeHDNode exactly. this.purposeNode = this.hdPrivateNode.derivePath(XO_PURPOSE_PATH); } /** * Start the wallet by scanning all template identifiers from genesis data * for used keys and then starting all derived child wallets for monitoring. */ async start() { // Get the address gap from genesis data or use the default. const addressGap = this.genesisData.addressGap || XO_ADDRESS_GAP; // Scan all templates provided in genesis data for used keys. for (const templateIdentifier of this.genesisData.templateIdentifiers) { await this.scanTemplate(templateIdentifier, 0, addressGap); } // Start all derived child wallets. // Child wallets may already be started from deriveWalletsForUseCase, // but WalletP2PKH.start() is idempotent (checks isStarted flag). const startPromises: Promise[] = []; for (const wallet of this.getAllChildWallets()) { startPromises.push(wallet.start()); } await Promise.all(startPromises); } /** * Stop monitoring all child wallets for transaction activity. */ async stop() { const stopPromises: Promise[] = []; for (const wallet of this.getAllChildWallets()) { stopPromises.push(wallet.stop()); } await Promise.all(stopPromises); } /** * Destroy all child wallets and remove all event listeners from this wallet. */ async destroy() { const destroyPromises: Promise[] = []; for (const wallet of this.getAllChildWallets()) { destroyPromises.push(wallet.destroy()); } await Promise.all(destroyPromises); this.removeAllListeners(); } /** * Dynamically add a new template identifier and scan for derived keys. * If the template has already been added, this is a no-op. * * @param templateIdentifier - A 64-character hex string template identifier * @throws {Error} If the template identifier is not 64 hex characters */ async addTemplate(templateIdentifier: string) { // Validate the template identifier length. if (templateIdentifier.length !== TEMPLATE_IDENTIFIER_HEX_LENGTH) { console.error('Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}'); throw new Error( `Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}`, ); } // Skip if this template has already been added. if (this.wallets.has(templateIdentifier)) { console.error('Template ${templateIdentifier} has already been added'); return; } // Determine the address gap to use for scanning. const addressGap = this.genesisData.addressGap || XO_ADDRESS_GAP; console.log('addressGap', addressGap); // Scan the template for derived keys across all use cases. console.log('scanning template'); await this.scanTemplate(templateIdentifier, 0, addressGap); console.log('scanTemplate complete'); // Start all newly derived child wallets for this template. const templateWallets = this.wallets.get(templateIdentifier); if (templateWallets) { const startPromises: Promise[] = []; for (const useCaseWallets of Object.values(templateWallets)) { for (const wallet of useCaseWallets) { startPromises.push(wallet.start()); } } await Promise.all(startPromises); } this.emit('stateUpdated', null); } /** * Fetch all transactions from the blockchain involving any child wallet. * Results are flattened and deduplicated across all templates and use cases. */ async getTransactions() { const fetchPromises = []; for (const wallet of this.getAllChildWallets()) { fetchPromises.push(wallet.getTransactions()); } const txs = await Promise.all(fetchPromises); // Flatten and deduplicate transactions across all child wallets. const txsFlattened = ExtMap.flatten(txs); // Emit an event to notify that transactions have been updated. this.emit('transactionsUpdated', txsFlattened); return txsFlattened; } /** * Fetch all unspent outputs from the blockchain across all child wallets. */ async getUnspents() { const fetchPromises = []; for (const wallet of this.getAllChildWallets()) { fetchPromises.push(wallet.getUnspents()); } const unspents = await Promise.all(fetchPromises); return ExtMap.flatten(unspents); } /** * Fetch all unspent output directives (with unlocking bytecode) across all child wallets. */ async getUnspentDirectives() { const fetchPromises = []; for (const wallet of this.getAllChildWallets()) { fetchPromises.push(wallet.getUnspentDirectives()); } const unspentDirectives = await Promise.all(fetchPromises); return ExtMap.flatten(unspentDirectives); } /** * Get the total satoshi balance across all child wallets. */ async getBalanceSats() { const unspents = await this.getUnspents(); const utxos = unspents .map((blockchainUTXO) => blockchainUTXO.utxo) .toArray(); return calculateBalanceSats(utxos); } /** * Get the total token balances across all child wallets. */ async getBalanceTokens(): Promise { const unspents = await this.getUnspents(); const utxos = unspents .map((blockchainUTXO) => blockchainUTXO.utxo) .toArray(); return calculateBalanceTokens(utxos); } /** * Get all addresses belonging to this wallet across all templates and use cases. */ async getAddresses() { const addressPromises = []; for (const wallet of this.getAllChildWallets()) { addressPromises.push(wallet.getReceivingAddress()); } const addresses = await Promise.all(addressPromises); return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); } /** * Get the default receiving address for this wallet. * Returns the address of the first derived child wallet. * * @throws {Error} If no child wallets have been derived yet */ async getReceivingAddress() { const allWallets = this.getAllChildWallets(); if (allWallets.length === 0) { throw new Error( 'No child wallets available. Add a template and start the wallet first.', ); } // Return the first child wallet's receiving address. return allWallets[0].privateKey.derivePublicKey().deriveAddress(); } /** * Build a signed transaction using UTXOs from all child wallets. * The change output is sent to the first child wallet's address. * * @param txTemplate - Partial transaction template with desired outputs * @returns A TransactionBuilder with the compiled transaction */ async signTransaction( txTemplate: Partial, ): Promise { // Use the first child wallet's address as the change address. const changeAddress = await this.getReceivingAddress(); const changeLockingBytecode = changeAddress.toLockscriptBytes(); // Gather unspent directives from all child wallets. const unspents = await this.getUnspentDirectives(); // Build the transaction. return TransactionBuilder.build( txTemplate, unspents.toArray(), changeLockingBytecode, ); } /** * Build, sign, and broadcast a transaction using UTXOs from all child wallets. * * @param txTemplate - Partial transaction template with desired outputs * @returns The raw transaction bytes that were broadcast */ async sendTransaction(txTemplate: Partial) { // Generate the transaction. const generatedTx = await this.signTransaction(txTemplate); // Convert the transaction to bytes. const generatedTxBytes = generatedTx.toBytes(); // Broadcast the transaction to the blockchain. await this.blockchain.broadcastTransaction(generatedTxBytes); return generatedTxBytes; } /** * Derive a batch of WalletP2PKH child wallets for a specific template + use case combination. * Each child wallet monitors its own address on the blockchain. * * The derivation path relative to the purpose node is: * {templateIndex}'/{useCaseIndex}'/{childIndex} * * This produces identical keys to engine/crypto's `deriveUseCaseChildNode` when given * the same purpose node, template index, use case index, and child index. * * @param templateIdentifier - The 64-char hex template identifier (used as storage key) * @param templateIndex - The BIP32-compatible template index derived from the identifier * @param useCaseIndex - The use case index (0 = SigningAndSpending, 1 = Encryption) * @param count - Number of child wallets to derive * @param startIndex - Starting child index (default 0) * @returns Array of newly derived WalletP2PKH instances */ async deriveWalletsForUseCase( templateIdentifier: string, templateIndex: number, useCaseIndex: number, count: number, startIndex = 0, ): Promise { const newWallets: WalletP2PKH[] = []; // Ensure the wallet storage structure exists for this template and all use cases. if (!this.wallets.has(templateIdentifier)) { const useCases: { [useCaseIndex: number]: WalletP2PKH[] } = {}; for (const uc of XO_USE_CASES) { useCases[uc] = []; } this.wallets.set(templateIdentifier, useCases); } for (let i = 0; i < count; i++) { const childIndex = startIndex + i; // Build the relative derivation path from the purpose node. // This matches engine/crypto's deriveUseCaseChildNode relative path construction: // `${accountOrTemplateIndex}'/${accountOrTemplateUseCaseIndex}'/${useCaseChildIndex}` const relativePath = `${templateIndex}'/${useCaseIndex}'/${childIndex}`; // Use the full absolute path as the cache key to avoid collisions. const fullPath = `${XO_PURPOSE_PATH}/${relativePath}`; // Derive the private key at this path. The cache avoids re-deriving the same key. const privateKeyHex = await this.cache.getOrSet(fullPath, () => { const childNode = this.purposeNode.deriveRelativePath(relativePath); return childNode.toPrivateKey().toHex(); }); // Create a child P2PKH wallet for this derived private key. const wallet = await WalletP2PKH.from( { activities: this.activities, blockchain: this.blockchain, cache: this.cache, }, { privateKey: privateKeyHex }, ); // Bubble up state-update events from child wallets to this parent wallet. wallet.on('stateUpdated', async () => { this.emit('stateUpdated', null); }); // Start monitoring the child wallet's address on the blockchain. await wallet.start(); newWallets.push(wallet); } // Append the newly derived wallets to the storage for this template + use case. const templateWallets = this.wallets.get(templateIdentifier)!; templateWallets[useCaseIndex] = [ ...templateWallets[useCaseIndex], ...newWallets, ]; return newWallets; } /** * Scan a single template across all use cases using address gap logic. * For each use case, derives batches of child wallets and checks their transaction * history. Scanning stops for a use case when `addressGap` consecutive child indexes * produce addresses with no on-chain activity. * * @param templateIdentifier - The 64-char hex template identifier * @param startIndex - Starting child index for scanning (default 0) * @param addressGap - Number of consecutive empty addresses before stopping (default XO_ADDRESS_GAP) */ async scanTemplate( templateIdentifier: string, startIndex = 0, addressGap = XO_ADDRESS_GAP, ) { // Validate the template identifier length (must be a 32-byte hex string). if (templateIdentifier.length !== TEMPLATE_IDENTIFIER_HEX_LENGTH) { throw new Error( `Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}`, ); } // Compute the BIP32-compatible template index from the identifier. // This uses the same algorithm as engine/crypto's createPathIndexFromHash. console.log('creating path index from template identifier'); const templateIndex = createPathIndexFromTemplateIdentifier(templateIdentifier); console.log('path index created'); // Scan each use case for this template (SigningAndSpending + Encryption). for (const useCaseIndex of XO_USE_CASES) { console.log('scanning use case', useCaseIndex); let currentIndex = startIndex; let emptyAddressCount = 0; // Continue scanning until we find 'addressGap' consecutive empty addresses. while (emptyAddressCount < addressGap) { console.log('deriving wallets for use case', useCaseIndex); // Derive a batch of wallets for this use case. const batch = await this.deriveWalletsForUseCase( templateIdentifier, templateIndex, useCaseIndex, addressGap, currentIndex, ); console.log('batch derived', typeof batch, batch.length); // Check the transaction history of each wallet in the batch. const results = await Promise.all( batch.map((wallet) => wallet.getTransactions()), ); console.log('results', typeof results, results.length); // Process results: reset empty count on activity, increment on empty. for (let i = 0; i < results.length; i++) { if (results[i].size > 0) { // This address has transactions; reset the empty address counter. emptyAddressCount = 0; } else { // This address is empty; increment the consecutive empty counter. emptyAddressCount++; } currentIndex++; // If we've hit the address gap threshold, stop scanning this use case. if (emptyAddressCount >= addressGap) { break; } } } } this.emit('stateUpdated', null); } /** * Collects all child WalletP2PKH instances across all templates and use cases * into a single flat array. Used by aggregate methods to operate over every * derived wallet uniformly. * * @returns Flat array of all child WalletP2PKH instances */ private getAllChildWallets(): WalletP2PKH[] { const allWallets: WalletP2PKH[] = []; for (const templateWallets of this.wallets.values()) { for (const useCaseWallets of Object.values(templateWallets)) { allWallets.push(...useCaseWallets); } } return allWallets; } } //----------------------------------------------------------------------------- // Factory Types/Functions //----------------------------------------------------------------------------- export type WalletXOFactory = ( services: WalletDependencies, genesisData: WalletXOGenesisData, ) => Promise; export async function useWalletXO( services: WalletDependencies, genesisData: WalletXOGenesisData, ): Promise { return (await WalletXO.from(services, genesisData)) as T; }