diff --git a/packages/stack/src/wallet/wallet-xo.ts b/packages/stack/src/wallet/wallet-xo.ts new file mode 100644 index 0000000..c107521 --- /dev/null +++ b/packages/stack/src/wallet/wallet-xo.ts @@ -0,0 +1,699 @@ +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; +}