diff --git a/src/pages/AddressesPage.vue b/src/pages/AddressesPage.vue index fa3ca07..d38a85c 100644 --- a/src/pages/AddressesPage.vue +++ b/src/pages/AddressesPage.vue @@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router'; import { BlockchainElectrum, - WalletP2PKHWatch, - WalletHDWatch, - HDPublicNode, PublicKey, type Address, } from '@xocash/stack'; +import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js'; +import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js'; +import { HDPublicNode } from '../xo-extensions/hd-public-node.js'; + import { ReactiveWallet } from '../services/wallet.js'; //----------------------------------------------------------------------------- diff --git a/src/pages/WalletPage.vue b/src/pages/WalletPage.vue index e7743c7..80ba683 100644 --- a/src/pages/WalletPage.vue +++ b/src/pages/WalletPage.vue @@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router'; import { BlockchainElectrum, - WalletP2PKHWatch, - WalletHDWatch, - HDPublicNode, PublicKey, BlockHeader, } from '@xocash/stack'; +import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js'; +import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js'; +import { HDPublicNode } from '../xo-extensions/hd-public-node.js'; + import { ReactiveWallet } from '../services/wallet.js'; //----------------------------------------------------------------------------- diff --git a/src/xo-extensions/hd-private-node.ts b/src/xo-extensions/hd-private-node.ts new file mode 100644 index 0000000..bc8f6bb --- /dev/null +++ b/src/xo-extensions/hd-private-node.ts @@ -0,0 +1,296 @@ +import { Bytes, Mnemonic, PrivateKey } from '@xocash/stack'; + +import { HDPublicNode } from './hd-public-node.js'; + +import { + encodeHdPrivateKey, + decodeHdPrivateKey, + deriveHdPath, + deriveHdPathRelative, + deriveHdPublicNode, + deriveHdPrivateNodeFromSeed, + generateRandomBytes, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPrivateNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Private Node Entity. + * + * @example + * + * ```ts + * // Import our primitives. + * import { Mnemonic, HDPrivateNode } from '@xocash/primitives'; + * + * // Generate a mnemonic. + * const mnemonic = Mnemonic.generateRandom(); + * + * // Initialize a HD Private Node from Mnemonic. + * const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic); + * + * // Derive the first Private Key. + * const privateKey = hdPrivateNode.derivePath("m/44'/145'/0'/0/0").toPrivateKey(); + * + * // Output the BCH Address. + * console.log(privateKey.derivePublicKey().deriveAddress().toCashAddr()); + * ``` + */ +export class HDPrivateNode { + // The HD Private Node. + protected readonly node: HdPrivateNodeValid; + + /** + * Construct a new HD Private Node. + * + * @param node The HD Private Node. + */ + protected constructor(node: HdPrivateNodeValid) { + this.node = node; + } + + /** + * Creates a HD Private Node from a raw seed. + * + * @param seed The raw seed bytes. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromSeed(seed: Uint8Array | string): HDPrivateNode { + // Cast from hex if a string was provided. + if (typeof seed === 'string') { + seed = Bytes.fromHex(seed); + } + + // If the seed is not 64 bytes, throw an error. + if (seed.length !== 64) { + throw new Error( + `Failed to derive Private Node from seed: Seed must be 64 bytes (${seed.length} given)`, + ); + } + + // Attempt to derive HD Private Node from the given seed. + const deriveResult = deriveHdPrivateNodeFromSeed(seed); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return a new HD Private Node from the given seed. + return new this(deriveResult); + } + + /** + * Creates a HD Private Node from the given mnemonic. + * + * @param mnemonic The mnemonic to use. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromMnemonic(mnemonic: Mnemonic): HDPrivateNode { + // Return a new HDPrivateNode from the given seed. + return this.fromSeed(mnemonic.toSeed()); + } + + /** + * Creates a HD Private Node from the given XPriv Key. + * + * @param xpriv The XPriv Key. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static fromXPriv(xpriv: string): HDPrivateNode { + // Attempt to decode the XPriv Key. + const decodeResult = decodeHdPrivateKey(xpriv); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Private Node from the given XPriv. + return new this(decodeResult.node); + } + + /** + * Creates a HD Private Node from raw node data. + * + * @param node The raw node data. + * + * @returns The created HD Private Node. + */ + public static fromRaw(node: HdPrivateNodeValid) { + return new this(node); + } + + /** + * Generates a random HD Private Node. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns The created HD Private Node. + */ + public static generateRandom(): HDPrivateNode { + // Attempt to derive HD Private Node from randomly generated bytes. + const deriveResult = deriveHdPrivateNodeFromSeed(generateRandomBytes(32)); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return the new HD Private Node from the random bytes. + return new this(deriveResult); + } + + /** + * Checks if the given XPriv string is a valid HD Private Key. + * + * @param xpriv The XPriv string to check. + * + * @returns True if the XPriv string is valid, false otherwise. + */ + public static isXPriv(xpriv: string): boolean { + try { + this.fromXPriv(xpriv); + return true; + } catch (_error) { + return false; + } + } + + /** + * Returns the Private Key of this node (as a Uint8Array). + * + * @returns The Node's Private Key (as a Uint8Array). + */ + public toBytes(): Bytes { + // Encode the XPriv from our node info. + return Bytes.from(this.node.privateKey); + } + + /** + * Gets the Private Key entity for this node. + * + * @returns The Private Key Entity for this node. + */ + public toPrivateKey(): PrivateKey { + // Return a Private Key Entity from the private key of our node. + return PrivateKey.fromRaw(this.node.privateKey); + } + + /** + * Converts this node to an XPriv Key for export. + * + * @param network {HdKeyNetwork} The network to encode for. + * + * @returns The XPriv Key. + */ + public toXPriv(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo: DecodedHdKey = { + network: network, + node: this.node, + }; + + // Encode the XPriv from our node info. + return encodeHdPrivateKey(nodeInfo).hdPrivateKey; + } + + /** + * Returns the XPriv string for this node. + * + * @returns The XPriv string for this node. + */ + public toString(): string { + // Return the Mainnet XPriv. + return this.toXPriv(); + } + + /** + * Returns the raw node data. + * + * @returns The raw node data. + */ + public toRaw(): HdPrivateNodeValid { + return this.node; + } + + /** + * Derives a HD Public Node from this Private Node. + * + * @returns The derived HD Public Node + */ + public deriveHDPublicNode(): HDPublicNode { + // Derive a HD Public Node from this HD Private Node. + const publicNode = deriveHdPublicNode(this.node); + + // Return a new HD Public Node entity from our derived Public Node. + return HDPublicNode.fromRaw(publicNode); + } + + /** + * Derives a HD Private Node from the given BIP32 path. + * + * @remarks + * + * This method automatically detects whether the path is absolute (starts with 'm/') + * or relative (e.g. '0/1') and uses the appropriate derivation method. + * + * @param path {string} The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1). + * + * @throws {Error} If HD Private Node cannot be derived. + * + * @returns The derived HD Private Node + */ + public derivePath(path: string): HDPrivateNode { + // Determine if this is an absolute path (starts with 'm' or 'M'). + const isAbsolutePath = path.startsWith('m') || path.startsWith('M'); + + // Use the appropriate derivation function. + const derivePathResult = isAbsolutePath + ? deriveHdPath(this.node, path) + : deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Private Node Entity derived from the given path. + return new HDPrivateNode(derivePathResult); + } + + /** + * Derives a HD Private Node from the given relative BIP32 path. + * + * @param path {string} The BIP32 Path (e.g. 0/0). + * + * @throws {Error} If HD Private Node cannot be derived. + * + * @returns The derived HD Private Node + */ + public deriveRelativePath(path: string): HDPrivateNode { + // Attempt to derive the given path. + const derivePathResult = deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Private Node Entity derived from the given path. + return new HDPrivateNode(derivePathResult); + } +} diff --git a/src/xo-extensions/hd-public-node.ts b/src/xo-extensions/hd-public-node.ts new file mode 100644 index 0000000..c3b9d68 --- /dev/null +++ b/src/xo-extensions/hd-public-node.ts @@ -0,0 +1,175 @@ +import { PublicKey } from '@xocash/stack'; + +import { + encodeHdPublicKey, + decodeHdPublicKey, + deriveHdPath, + deriveHdPathRelative, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPublicNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Public Node Entity. + */ +export class HDPublicNode { + // The HD Public Node. + protected readonly node: HdPublicNodeValid; + + /** + * Construct a new HD Public Node. + * + * @param node {HdPublicNode} The HD Public Node. + */ + protected constructor(node: HdPublicNodeValid) { + this.node = node; + } + + /** + * Creates a HD Public Node from the given XPriv Key. + * + * @param xpub {string} The XPriv Key. + * + * @throws {Error} If HD Public Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Public Node. + */ + public static fromXPub(xpub: string): HDPublicNode { + // Attempt to decode the XPub Key. + const decodeResult = decodeHdPublicKey(xpub); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Public Node from the given XPub. + return new HDPublicNode(decodeResult.node); + } + + /** + * Creates a HDPublicNode instance from a raw object representation (LibAuth's HdPublicNodeValid). + * + * @remarks This method is UNSAFE and MUST only be used where input is already verified or trusted. + * + * @param obj - LibAuth Object of type HdPublicNodeValid + * @returns A new HDPublicNode instance + */ + public static fromRaw(node: HdPublicNodeValid) { + return new this(node); + } + + public static isXPub(xpub: string): boolean { + try { + this.fromXPub(xpub); + return true; + } catch (_error) { + return false; + } + } + + /** + * Gets the Public Key Entity for this node. + * + * @returns The Public Key Entity for this node. + */ + public toPublicKey(): PublicKey { + // Return a Public Key Entity from the public key of our node. + return PublicKey.fromRaw(this.node.publicKey); + } + + /** + * Converts this node to an XPub Key for export. + * + * @param network The network to encode for. + * + * @returns The XPub Key. + */ + public toXPub(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo = { + network: network, + node: this.node, + } as DecodedHdKey; + + // Encode the XPub from our node info. + return encodeHdPublicKey(nodeInfo).hdPublicKey; + } + + /** + * Returns the XPub string for this node. + * + * @returns {string} The XPub string for this node. + */ + public toString(): string { + // Return the Mainnet XPub. + return this.toXPub(); + } + + /** + * Converts the HDPublicKey to its raw Libauth representation (HDPublicNodeValid). + * + * @returns A LibAuth HDPublicNodeValid object + */ + public toRaw(): HdPublicNodeValid { + return { ...this.node }; + } + + /** + * Derives a HD Public Node from the given BIP32 path. + * + * @remarks + * + * This method automatically detects whether the path is absolute (starts with 'm/') + * or relative (e.g. '0/1') and uses the appropriate derivation method. + * + * @param path The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1). + * + * @throws {Error} If HD Public Node cannot be derived. + * + * @returns The derived HD Public Node + */ + public derivePath(path: string): HDPublicNode { + // Determine if this is an absolute path (starts with 'm' or 'M'). + const isAbsolutePath = path.startsWith('m') || path.startsWith('M'); + + // Use the appropriate derivation function. + const derivePathResult = isAbsolutePath + ? deriveHdPath(this.node, path) + : deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Public Node Entity derived from the given path. + return new HDPublicNode(derivePathResult); + } + + /** + * Derives a HD Public Node from the given relative BIP32 path. + * + * @param path The relative BIP32 Path (e.g. 0/0 or 0/1). + * + * @throws {Error} If HD Public Node cannot be derived. + * + * @returns The derived HD Public Node + */ + public deriveRelativePath(path: string): HDPublicNode { + // Attempt to derive the given relative path. + const derivePathResult = deriveHdPathRelative(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Public Node Entity derived from the given path. + return new HDPublicNode(derivePathResult); + } +} diff --git a/src/xo-extensions/wallet-hd-watch.ts b/src/xo-extensions/wallet-hd-watch.ts new file mode 100644 index 0000000..354bb26 --- /dev/null +++ b/src/xo-extensions/wallet-hd-watch.ts @@ -0,0 +1,525 @@ +import { + BaseWallet, + type TransactionTemplate, + type WalletBlockchain, + type WalletDependencies, + TransactionBuilder, + ADDRESS_GAP, + CHAIN_EXTERNAL, + CHAIN_INTERNAL, + BaseStore, + Activities, + StoreInMemory, + type DistributiveOmit, + type TokenBalances, + ExtMap, + calculateBalanceSats, + calculateBalanceTokens, +} from '@xocash/stack'; + +import { HDPublicNode } from './hd-public-node.js'; +import { WalletP2PKHWatch } from './wallet-p2pkh-watch.js'; + +/** + * Type for creating a watch-only HD wallet from an xpub. + */ +export type WalletHDWatchEntropy = { xpub: string }; + +/** + * Derivation data for the HD wallet. + */ +export type WalletHDWatchDerivationData = { + /** + * The full derivation path for display purposes. + * Example: "m/44'/145'/0'" for an account-level xpub. + */ + derivationPath: string; + + addressGap?: number; +}; + +/** + * Genesis data for the watch-only HD wallet. + */ +export type WalletHDWatchGenesisData = { + type: 'WalletHDWatch'; +} & WalletHDWatchEntropy & + WalletHDWatchDerivationData; + +/** + * A Watch-Only Hierarchical Deterministic (BIP44) Wallet. + * + * @remarks + * + * This wallet uses an extended public key (xpub) to derive addresses and monitor + * transactions. It cannot sign transactions because it does not have access to + * private keys. + * + * @example + * + * ```ts + * // Define services to use. + * const services = { + * blockchain, + * cache, + * } + * + * // Instantiate an instance of WalletHDWatch. + * const walletHDWatch = await WalletHDWatch.from({ + * derivationPath: "m/44'/145'/0'", + * xpub: 'xpub...' + * }, services); + * + * // Scan for transactions and start monitoring. + * await walletHDWatch.start(); + * + * // Get the balance (watch-only). + * const balance = await walletHDWatch.getBalanceSats(); + * ``` + */ +export class WalletHDWatch extends BaseWallet { + /** + * Factory method to create a new WalletHDWatch instance. + * + * @param deps - The wallet dependencies (blockchain, cache, activities). + * @param genesisData - The genesis data containing the xpub and derivation info. + * @returns A new WalletHDWatch 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(); + + // Assign our final dependencies. + const depsFinal = { + activities, + cache, + blockchain: deps.blockchain, + }; + + // Return a new instance of WalletHDWatch. + return new WalletHDWatch(depsFinal, genesisData); + } + + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: WalletBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletHDWatchGenesisData; + + // Optimizations/Simplifications. + public readonly hdPublicNode: HDPublicNode; + + // Mutable State. + public shouldMonitor = false; + + // Store child wallets, grouped by chain ("change") path in BIP44 spec. + public wallets: { [chainPath: number]: Array } = {}; + + /** + * Constructs a new WalletHDWatch instance. + * + * @param dependencies - The required wallet dependencies. + * @param genesisData - The genesis data containing the xpub and derivation info. + */ + constructor( + dependencies: Required, + genesisData: DistributiveOmit, + ) { + super(); + + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our state. + this.genesisData = { + type: 'WalletHDWatch', + ...genesisData, + }; + + // Instantiate the HDPublicNode from the xpub in our genesis data. + this.hdPublicNode = HDPublicNode.fromXPub(genesisData.xpub); + + // Initialize empty arrays for our wallets. + this.wallets[CHAIN_EXTERNAL] = []; + this.wallets[CHAIN_INTERNAL] = []; + } + + /** + * Starts monitoring all derived addresses for transaction activity. + */ + async start() { + // Get the address gap. + // TODO: This should come from activities. + const addressGap = this.genesisData.addressGap || ADDRESS_GAP; + + // Scan the HDWallet for addresses with activity. + 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); + } + + /** + * Stops monitoring all derived addresses. + */ + 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); + } + + /** + * Removes all event listeners from this wallet. + */ + async destroy() { + this.removeAllListeners(); + } + + /** + * Fetches all transactions involving any of the watched addresses. + * + * @returns A map of transactions indexed by their transaction hash. + */ + 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; + } + + /** + * Fetches all unspent outputs belonging to any of the watched addresses. + * + * @returns A map of unspent outputs. + */ + 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; + } + + /** + * Throws an error because watch-only wallets cannot produce signing directives. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async getUnspentDirectives(): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot produce signing directives. ' + + 'Use WalletHD with a private key or seed phrase to sign transactions.', + ); + } + + /** + * Calculates the total satoshi balance across all watched addresses. + * + * @returns The total balance in satoshis. + */ + 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); + } + + /** + * Calculates the token balances across all watched addresses. + * + * @returns A map of token balances by category. + */ + 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); + } + + /** + * Gets all addresses currently being watched. + * + * @returns A map of addresses indexed by their lockscript hex. + */ + 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()); + } + + /** + * Gets the next receiving address for this wallet. + * + * @param chainPath - The chain path to use (default: CHAIN_EXTERNAL). + * @returns The receiving address. + */ + 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.publicKey.deriveAddress(); + } + + /** + * Throws an error because watch-only wallets cannot sign transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async signTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot sign transactions. ' + + 'Use WalletHD with a private key or seed phrase to sign transactions.', + ); + } + + /** + * Throws an error because watch-only wallets cannot send transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async sendTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletHDWatch is a watch-only wallet and cannot send transactions. ' + + 'Use WalletHD with a private key or seed phrase to send transactions.', + ); + } + + /** + * Derives a specified number of watch-only wallets from the HD public key. + * + * @param count - The number of wallets to derive. + * @param chainPath - The chain path (0 = external, 1 = internal). + * @param startIndex - The starting index for derivation. + * @returns An array of newly derived WalletP2PKHWatch instances. + */ + 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 (relative from the account-level xpub). + // The xpub should already be at account level, so we only need chainPath/index. + const relativePath = `${chainPath}/${startIndex + i}`; + + // Derive the public key at the given path. + const publicKeyHex = await this.cache.getOrSet(relativePath, () => { + const childNode = this.hdPublicNode.derivePath(relativePath); + return childNode.toPublicKey().toHex(); + }); + + // Create a child P2PKHWatch wallet for this Public Key. + const wallet = await WalletP2PKHWatch.from( + { + activities: this.activities, + blockchain: this.blockchain, + cache: this.cache, + }, + { + publicKey: publicKeyHex, + }, + ); + + // 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; + } + + /** + * Scans the blockchain for addresses with transaction activity. + * + * @param startIndex - The starting index for scanning. + * @param addressGap - The number of consecutive empty addresses before stopping. + * @param chainPaths - The chain paths to scan. + */ + 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); + } +} + +//----------------------------------------------------------------------------- +// Factory Types/Functions +//----------------------------------------------------------------------------- + +/** + * Factory type for creating WalletHDWatch instances. + */ +export type WalletHDWatchFactory = ( + services: WalletDependencies, + genesisData: WalletHDWatchGenesisData, +) => Promise; + +/** + * Factory function to create a new WalletHDWatch instance. + * + * @param services - The wallet dependencies. + * @param genesisData - The genesis data containing the xpub and derivation info. + * @returns A new WalletHDWatch instance. + */ +export async function useWalletHDWatch< + T extends WalletHDWatch = WalletHDWatch, +>( + services: WalletDependencies, + genesisData: WalletHDWatchGenesisData, +): Promise { + return (await WalletHDWatch.from(services, genesisData)) as T; +} diff --git a/src/xo-extensions/wallet-p2pkh-watch.ts b/src/xo-extensions/wallet-p2pkh-watch.ts new file mode 100644 index 0000000..6bf145a --- /dev/null +++ b/src/xo-extensions/wallet-p2pkh-watch.ts @@ -0,0 +1,459 @@ +import { + BaseWallet, + type TransactionTemplate, + type WalletBlockchain, + type WalletDependencies, + TransactionBuilder, + type AddressStatusPayload, + type BlockchainTransaction, + type BaseStore, + Activities, + StoreInMemory, + ExtMap, + PublicKey, + calculateBalanceSats, + calculateBalanceTokens, +} from '@xocash/stack'; + +import { type Input, binToHex, type Output } from '@bitauth/libauth'; + +/** + * Genesis data for a watch-only P2PKH wallet. + */ +export type WalletP2PKHWatchGenesisData = { + type: 'WalletP2PKHWatch'; + publicKey: string; +}; + +/** + * A Watch-Only P2PKH (Pay-to-Public-Key-Hash) Wallet. + * + * @remarks + * + * This wallet uses a public key to derive an address and monitor transactions. + * It cannot sign transactions because it does not have access to the private key. + * + * @example + * ```ts + * // Instantiate P2PKH Watch Wallet from a public key. + * const walletP2PKHWatch = await WalletP2PKHWatch.from( + * { blockchain }, + * { publicKey: '02...' } + * ); + * + * // Start monitoring the balance. + * await walletP2PKHWatch.start(); + * + * // Get the balance (watch-only). + * const balance = await walletP2PKHWatch.getBalanceSats(); + * ``` + */ +export class WalletP2PKHWatch extends BaseWallet { + /** + * Factory method to create a new WalletP2PKHWatch instance. + * + * @param deps - The wallet dependencies (blockchain, cache, activities). + * @param genesisData - The genesis data containing the public key. + * @returns A new WalletP2PKHWatch instance. + */ + static async from( + this: T, + deps: WalletDependencies, + genesisData: Omit, + ) { + // Assign defaults if not otherwise provided. + 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 WalletP2PKHWatch. + const wallet = new this(depsFinal, genesisData) as InstanceType; + + // Return the wallet instance. + return wallet; + } + + /** + * Creates a WalletP2PKHWatch from raw public key bytes. + * + * @param services - The wallet dependencies. + * @param publicKeyBytes - The public key as a Uint8Array. + * @returns A new WalletP2PKHWatch instance. + */ + static fromBytes( + this: T, + services: WalletDependencies, + publicKeyBytes: Uint8Array, + ) { + // Create a Public Key entity from the provided bytes. + const publicKey = PublicKey.fromBytes(publicKeyBytes); + + // Return a new instance of WalletP2PKHWatch. + return this.from(services, { + publicKey: publicKey.toHex(), + }); + } + + /** + * Creates a WalletP2PKHWatch from a hex-encoded public key. + * + * @param services - The wallet dependencies. + * @param publicKeyHex - The public key as a hex string. + * @returns A new WalletP2PKHWatch instance. + */ + static fromHex( + this: T, + services: WalletDependencies, + publicKeyHex: string, + ) { + // Create a Public Key entity from the provided hex. + const publicKey = PublicKey.fromHex(publicKeyHex); + + // Return a new instance of WalletP2PKHWatch. + return this.from(services, { + publicKey: publicKey.toHex(), + }); + } + + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: WalletBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletP2PKHWatchGenesisData; + + // Optimizations/Simplifications. + public readonly publicKey: PublicKey; + + // Mutable State. + public isStarted = false; + public status: string | null = null; + + /** + * Constructs a new WalletP2PKHWatch instance. + * + * @param dependencies - The required wallet dependencies. + * @param genesisData - The genesis data containing the public key. + */ + constructor( + dependencies: Required, + genesisData: Omit, + ) { + super(); + + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our Genesis Data. + this.genesisData = { + type: 'WalletP2PKHWatch', + ...genesisData, + }; + + // Optimizations/Simplifications. + this.publicKey = PublicKey.fromHex(genesisData.publicKey); + } + + /** + * Starts monitoring this wallet's address for transaction activity. + */ + async start() { + // Do nothing if already started. + if (this.isStarted) { + return; + } + + // Get the receiving address of this wallet so we can monitor it. + const receivingAddress = await this.getReceivingAddress(); + + // Start monitoring the receiving address. + await this.blockchain.subscribeAddress( + receivingAddress.toCashAddr(), + this.onAddressNotification.bind(this), + ); + + // Mark this wallet as started. + this.isStarted = true; + } + + /** + * Stops monitoring this wallet's address. + */ + async stop() { + // Do nothing if not started. + if (!this.isStarted) { + return; + } + + // Get the receiving address of this wallet so we can stop monitoring it. + const receivingAddress = await this.getReceivingAddress(); + + // Stop monitoring the receiving address. + await this.blockchain.unsubscribeAddress( + receivingAddress.toCashAddr(), + this.onAddressNotification.bind(this), + ); + + // Mark this wallet as stopped. + this.isStarted = false; + } + + /** + * Removes all event listeners from this wallet. + */ + async destroy() { + this.removeAllListeners(); + } + + /** + * Fetches all transactions involving this wallet's address. + * + * @returns A map of transactions indexed by their transaction hash. + */ + async getTransactions() { + // Get this node's address. + const address = await this.getReceivingAddress(); + + // Get transactions involving this address. + const transactions = await this.blockchain.fetchAddressHistory( + address.toCashAddr(), + ); + + // Define a function to get a single source output for an input. + const getSourceOutput = async (input: Input) => { + const sourceTx = await this.blockchain.fetchTransaction( + binToHex(input.outpointTransactionHash), + ); + + const sourceOutput = sourceTx.getOutputs().at(input.outpointIndex); + + if (!sourceOutput) { + throw new Error(`Failed to find source output`); + } + + return sourceOutput; + }; + + // Define a function to get all source outputs for a tx. + const getSourceOutputs = async (tx: BlockchainTransaction) => { + const sourceOutputPromises = tx.transaction + .getInputs() + // Coinbase transactions do not have source output, so we return null. + .map((input) => + getSourceOutput(input).catch(() => { + return undefined; + }), + ); + + return (await Promise.all(sourceOutputPromises)).filter( + (output): output is Output => output !== undefined, + ); + }; + + // Get the source outputs for each of the transactions. + const walletTxs = await Promise.all( + transactions.toArray().map(async (tx) => ({ + ...tx, + sourceOutputs: await getSourceOutputs(tx), + })), + ); + + // Wait for source outputs to be populated. + const walletTxsIndexed = ExtMap.fromArray(walletTxs, (tx) => + tx.hash.toHex(), + ); + + // TODO: Merge activity information into a WalletTransaction type. + // 2024-11-17: Actually, that might not belong here? + + // Emit an event to notify that transactions have been updated. + this.emit('transactionsUpdated', walletTxsIndexed); + + // Return the fetched transactions. + return walletTxsIndexed; + } + + /** + * Fetches all unspent outputs belonging to this wallet's address. + * + * @returns A map of unspent outputs. + */ + async getUnspents() { + // Get this wallet's address. + const address = await this.getReceivingAddress(); + + // Fetch unspents belonging to this address. + const unspents = await this.blockchain.fetchUnspents(address.toCashAddr()); + + // TODO: Merge activity information into a WalletUnspent type. + + // Emit an event to notify that unspents have been updated. + this.emit('unspentsUpdated', unspents); + + // Return the unspents. + return unspents; + } + + /** + * Throws an error because watch-only wallets cannot produce signing directives. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async getUnspentDirectives(): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot produce signing directives. ' + + 'Use WalletP2PKH with a private key to sign transactions.', + ); + } + + /** + * Calculates the satoshi balance of this wallet. + * + * @returns The balance in satoshis. + */ + 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); + } + + /** + * Calculates the token balances of this wallet. + * + * @returns A map of token balances by category. + */ + async getBalanceTokens() { + // 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); + } + + /** + * Gets all addresses belonging to this wallet. + * + * @returns A map of addresses indexed by their lockscript hex. + */ + async getAddresses() { + const addresses = await Promise.all([this.getReceivingAddress()]); + + return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); + } + + /** + * Gets the receiving address for this wallet. + * + * @returns The address derived from the public key. + */ + async getReceivingAddress() { + return this.publicKey.deriveAddress(); + } + + /** + * Throws an error because watch-only wallets cannot sign transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async signTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot sign transactions. ' + + 'Use WalletP2PKH with a private key to sign transactions.', + ); + } + + /** + * Throws an error because watch-only wallets cannot send transactions. + * + * @throws {Error} Always throws because this is a watch-only wallet. + */ + async sendTransaction( + _txTemplate: Partial, + ): Promise { + throw new Error( + 'WalletP2PKHWatch is a watch-only wallet and cannot send transactions. ' + + 'Use WalletP2PKH with a private key to send transactions.', + ); + } + + /** + * Handles notifications when the address status changes. + * + * @param _address - The address string (unused). + * @param payload - The status payload containing the new status. + */ + private async onAddressNotification( + _address: string, + payload?: AddressStatusPayload, + ) { + // Not all blockchain adapters will provide a status payload. But if they do, we check if it's the same as our current status. + // If it is, we don't want the wallet to try to update its state as it will be the same and is a waste of resources. + if (payload && payload.status === this.status) { + return; + } + + if (payload) { + // Set our new status. + this.status = payload.status; + } + + // Emit an event. + this.emit('stateUpdated', this.status); + } +} + +//----------------------------------------------------------------------------- +// Factory Types/Functions +//----------------------------------------------------------------------------- + +/** + * Factory type for creating WalletP2PKHWatch instances. + */ +export type WalletP2PKHWatchFactory< + T extends WalletP2PKHWatch = WalletP2PKHWatch, +> = ( + dependencies: WalletDependencies, + genesisData: WalletP2PKHWatchGenesisData, +) => Promise; + +/** + * Factory function to create a new WalletP2PKHWatch instance. + * + * @param dependencies - The wallet dependencies. + * @param genesisData - The genesis data containing the public key. + * @returns A new WalletP2PKHWatch instance. + */ +export async function useWalletP2PKHWatch< + T extends WalletP2PKHWatch = WalletP2PKHWatch, +>( + dependencies: WalletDependencies, + genesisData: WalletP2PKHWatchGenesisData, +): Promise { + return (await WalletP2PKHWatch.from(dependencies, genesisData)) as T; +} +