import { BaseWallet } from './base-wallet.js'; import { type TransactionTemplate } from './types.js'; import { TransactionBuilder } from './transaction-builder.js'; import { type WalletBlockchain, type WalletDependencies } from './types.js'; import { WalletP2PKH } from './wallet-p2pkh.js'; import { type BaseStore, Activities, StoreInMemory } from '../storage/index.js'; import { type TokenBalances, HDPrivateNode, ExtMap, Mnemonic, calculateBalanceSats, calculateBalanceTokens, } from '@xocash/primitives'; export const DERIVATION_PATH = "m/44'/145'/0'"; export const ADDRESS_GAP = 20; // These constants correspond to the "change" path in BIP44. // See: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki export const CHAIN_EXTERNAL = 0; export const CHAIN_INTERNAL = 1; export type WalletHDGenesisData = { type: 'WalletHD'; derivationPath: string; addressGap?: number; } & ( | { seed: string } | { mnemonic: string; passphrase?: string } | { xpriv: string } ); /** * A Hierarhical Determinstic (BIP44) Wallet. * * @example * * ```ts * // Define services to use. * const services = { * blockchain, * cache, * storage, * } * * // Instantiate an instance of WalletHD. * const walletHD = await WalletHD.from({ * derivationPath: "m/44'/145'/0'", * mnemonic: 'page pencil stock planet limb cluster assault speak off joke private pioneer' * // or "seed" or "xpub" * }, services); * * // Scan for transactions and start monitoring. * await wallet = await walletHD.start(); * ``` */ export class WalletHD extends BaseWallet { static async from( this: T, deps: WalletDependencies, genesisData: WalletHDGenesisData, ) { // Assign defaults if not otherwise provideed. const activities = deps.activities ? deps.activities : await Activities.from(); const cache = deps.cache ? deps.cache : StoreInMemory.from(); // Assign our final dependencies. const depsFinal = { activities, cache, blockchain: deps.blockchain, }; // Return a new instance of WalletHD. 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: WalletHDGenesisData; // Optimizations/Simplifications. public readonly hdPrivateNode: HDPrivateNode; // Mutable State. public shouldMonitor = false; // Store child wallets, grouped by chain ("change") path in BIP44 spec. public wallets: { [chainPath: number]: Array } = {}; constructor( dependencies: Required, genesisData: WalletHDGenesisData, ) { super(); // Assign our dependencies. this.activities = dependencies.activities; this.blockchain = dependencies.blockchain; this.cache = dependencies.cache; // Assign our state. this.genesisData = genesisData; // Instantiate the HDPrivateNode from the type specified in our genesis data. if ('seed' in genesisData) { this.hdPrivateNode = HDPrivateNode.fromSeed(genesisData.seed); } else if ('mnemonic' in genesisData) { // TODO: We really want to support multiple word-lists somehow. // But this is not trivial because other word-lists are technically not standard. // For now, we are allowing any phrase (BIP39 derives seed from literal phrase, not word indexes). const mnemonic = Mnemonic.fromRaw({ words: genesisData.mnemonic.split(' '), passphrase: genesisData.passphrase, }); // Create the HD Node from our Mnemonic instance. this.hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic); } else { this.hdPrivateNode = HDPrivateNode.fromXPriv(genesisData.xpriv); } // Initialize empty arrays for our wallets. this.wallets[CHAIN_EXTERNAL] = []; this.wallets[CHAIN_INTERNAL] = []; } async start() { // Get the address gap. // TODO: This should come from activities. const addressGap = this.genesisData.addressGap || ADDRESS_GAP; // Scan the HDWallet for child wallets. await this.scan(0, addressGap); // Declare storage for our start promises. const startPromises: Promise[] = []; // Iterate over each chain path and start each wallet. for (const chainWallets of Object.values(this.wallets)) { for (const wallet of chainWallets) { startPromises.push(wallet.start()); } } // Wait for all of our start promises to complete. await Promise.all(startPromises); } async stop() { // Declare storage for our stop promises. const stopPromises: Promise[] = []; // Iterate over each chain path and stop each wallet. for (const chainWallets of Object.values(this.wallets)) { for (const wallet of chainWallets) { stopPromises.push(wallet.stop()); } } // Wait for all of our stop promises to complete. await Promise.all(stopPromises); } async destroy() { this.removeAllListeners(); } async getTransactions() { // Declare storage for our fetch promises. const fetchPromises = []; // Iterate over each chain path and fetch transactions for each wallet. for (const chainWallets of Object.values(this.wallets)) { for (const wallet of chainWallets) { fetchPromises.push(wallet.getTransactions()); } } // Wait for all of our fetch promises to complete. const txs = await Promise.all(fetchPromises); // Flatten (and deduplicate) the transactions. const txsFlattened = ExtMap.flatten(txs); // Emit an event to notify that transactions have been updated. this.emit('transactionsUpdated', txsFlattened); // Return the deduplicated transactions. return txsFlattened; } async getUnspents() { // Declare storage for our fetch promises. const fetchPromises = []; // Iterate over each chain path and fetch unspents for each wallet. for (const chainWallets of Object.values(this.wallets)) { for (const wallet of chainWallets) { fetchPromises.push(wallet.getUnspents()); } } // Wait for all of our fetch promises to complete. const unspents = await Promise.all(fetchPromises); // Flatten them. const unspentsFlattened = ExtMap.flatten(unspents); // Return the unspents. return unspentsFlattened; } async getUnspentDirectives() { // Declare storage for our fetch promises. const fetchPromises = []; // Iterate over each chain path and fetch unspent directives for each wallet. for (const chainWallets of Object.values(this.wallets)) { for (const wallet of chainWallets) { fetchPromises.push(wallet.getUnspentDirectives()); } } // Wait for all of our fetch promises to complete. const unspentDirectives = await Promise.all(fetchPromises); // Flatten them. const unspentsFlattened = ExtMap.flatten(unspentDirectives); // Return the unspents. return unspentsFlattened; } async getBalanceSats() { // Fetch our unspents. const unspents = await this.getUnspents(); // Get the individual UTXOs. const utxos = unspents .map((blockchainUTXO) => blockchainUTXO.utxo) .toArray(); // Calculate the balance. return calculateBalanceSats(utxos); } async getBalanceTokens(): Promise { // Fetch our unspents. const unspents = await this.getUnspents(); // Get the individual UTXOs. const utxos = unspents .map((blockchainUTXO) => blockchainUTXO.utxo) .toArray(); // Calculate the token balances. return calculateBalanceTokens(utxos); } async getAddresses() { const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL]; const addressPromises = []; for (const chainPath of chainPaths) { addressPromises.push( ...this.wallets[chainPath].map((wallet) => wallet.getReceivingAddress(), ), ); } const addresses = await Promise.all(addressPromises); return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); } async getReceivingAddress(chainPath = CHAIN_EXTERNAL) { // TODO: Derive a new wallet, not the first one you lazy fuckhead. // You'll probably need to refactor your scan() approach. let receivingWallet = this.wallets[chainPath][0]; // If there is no receiving wallet, create one. // TODO: This is fucked. You need to rethink deriveWallets. if (!receivingWallet) { this.deriveWallets(1, chainPath); receivingWallet = this.wallets[chainPath][0]; } // Return the receiving address of the derived wallet. return receivingWallet.privateKey.derivePublicKey().deriveAddress(); } async signTransaction( txTemplate: Partial, ): Promise { // Get the receiving address of this wallet. // NOTE: As per BIP44 spec, we set the change address to use the internal chain. const changeAddress = await this.getReceivingAddress(CHAIN_EXTERNAL); // Get the change locking bytecode (change address) for this wallet. const changeLockingBytecode = changeAddress.toLockscriptBytes(); // Fetch unspents that are usable by this wallet. const unspents = await this.getUnspentDirectives(); // Build the transaction. return TransactionBuilder.build( txTemplate, unspents.toArray(), changeLockingBytecode, ); } async sendTransaction(txTemplate: Partial) { // Generate the transaction. const generatedTx = await this.signTransaction(txTemplate); // Convert the tx to bytes. const generatedTxBytes = generatedTx.toBytes(); // Broadcast the transaction to the blockchain. await this.blockchain.broadcastTransaction(generatedTxBytes); // Return the transaction. return generatedTxBytes; } async deriveWallets( count: number, chainPath = CHAIN_EXTERNAL, startIndex = 0, ) { // Create an array to store our wallets. const newWallets: Array = []; // Create the given count of wallets. for (let i = 0; i < count; i++) { // Define the derivation path. const derivationPath = `${this.genesisData.derivationPath}/${chainPath}/${startIndex + i}`; // Derive the private key at the given path. const privateKeyHex = await this.cache.getOrSet(derivationPath, () => { const childNode = this.hdPrivateNode.derivePath(derivationPath); return childNode.toPrivateKey().toHex(); }); // Create a child P2PKH wallet for this Private Key. const wallet = await WalletP2PKH.from( { activities: this.activities, blockchain: this.blockchain, cache: this.cache, }, { type: 'WalletP2PKH', privateKey: privateKeyHex, }, ); // Subscribe to wallet-state updates. wallet.on('stateUpdated', async () => { this.emit('stateUpdated', null); }); // Start monitoring the wallet. wallet.start(); // Add the wallet to our list of wallets. newWallets.push(wallet); } // Set our wallets. this.wallets[chainPath] = [...this.wallets[chainPath], ...newWallets]; // Return the wallets. return newWallets; } async scan( startIndex = 0, addressGap = 20, chainPaths: Array = [CHAIN_EXTERNAL, CHAIN_INTERNAL], ) { // Iterate over each of the provided chain (change) paths. for (const chainPath of chainPaths) { // Array to store active wallet nodes const wallets: Array = []; let currentIndex = startIndex; let emptyAddressCount = 0; // Continue scanning until we find 'addressGap' consecutive empty addresses while (emptyAddressCount < addressGap) { // Derive a number of wallets equivalent to our addressGap. const batch: Array = await this.deriveWallets( addressGap, chainPath, currentIndex, ); // Get the transaction history of each wallet in the batch const results = await Promise.all( batch.map((wallet) => wallet.getTransactions()), ); // Process the results for (let i = 0; i < results.length; i++) { if (results[i].size > 0) { // This address has transactions, add it to nodes and reset empty address count wallets.push(batch[i]); emptyAddressCount = 0; } else { // This address is empty, increment the empty address count emptyAddressCount++; } currentIndex++; // If we've found 'addressGap' consecutive empty addresses, stop scanning if (emptyAddressCount >= addressGap) { break; } } } } this.emit('stateUpdated', null); } } //----------------------------------------------------------------------------- // Factories Types/Functions //----------------------------------------------------------------------------- export type WalletHDFactory = ( services: WalletDependencies, genesisData: WalletHDGenesisData, ) => Promise; export async function useWalletHD( services: WalletDependencies, genesisData: WalletHDGenesisData, ): Promise { return (await WalletHD.from(services, genesisData)) as T; }