Remove local stack extensions

This commit is contained in:
2026-01-15 15:58:05 +11:00
parent 86b54e5293
commit 463524b6f6
6 changed files with 1463 additions and 6 deletions

View File

@@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router';
import { import {
BlockchainElectrum, BlockchainElectrum,
WalletP2PKHWatch,
WalletHDWatch,
HDPublicNode,
PublicKey, PublicKey,
type Address, type Address,
} from '@xocash/stack'; } 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'; import { ReactiveWallet } from '../services/wallet.js';
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------

View File

@@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router';
import { import {
BlockchainElectrum, BlockchainElectrum,
WalletP2PKHWatch,
WalletHDWatch,
HDPublicNode,
PublicKey, PublicKey,
BlockHeader, BlockHeader,
} from '@xocash/stack'; } 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'; import { ReactiveWallet } from '../services/wallet.js';
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------

View File

@@ -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<HdPrivateNodeValid> = {
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);
}
}

View File

@@ -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<HdPublicNodeValid>;
// 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);
}
}

View File

@@ -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<T extends typeof WalletHDWatch>(
this: T,
deps: WalletDependencies,
genesisData: DistributiveOmit<WalletHDWatchGenesisData, 'type'>,
) {
// 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<WalletP2PKHWatch> } = {};
/**
* 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<WalletDependencies>,
genesisData: DistributiveOmit<WalletHDWatchGenesisData, 'type'>,
) {
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<void>[] = [];
// 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<void>[] = [];
// 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<never> {
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<TokenBalances> {
// 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<TransactionTemplate>,
): Promise<TransactionBuilder> {
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<TransactionTemplate>,
): Promise<Uint8Array> {
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<WalletP2PKHWatch> = [];
// 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<number> = [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<WalletP2PKHWatch> = [];
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<WalletP2PKHWatch> = 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<T extends WalletHDWatch = WalletHDWatch> = (
services: WalletDependencies,
genesisData: WalletHDWatchGenesisData,
) => Promise<T>;
/**
* 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<T> {
return (await WalletHDWatch.from(services, genesisData)) as T;
}

View File

@@ -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<T extends typeof WalletP2PKHWatch>(
this: T,
deps: WalletDependencies,
genesisData: Omit<WalletP2PKHWatchGenesisData, 'type'>,
) {
// 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<T>;
// 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<T extends typeof WalletP2PKHWatch>(
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<T>(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<T extends typeof WalletP2PKHWatch>(
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<T>(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<WalletDependencies>,
genesisData: Omit<WalletP2PKHWatchGenesisData, 'type'>,
) {
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<never> {
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<TransactionTemplate>,
): Promise<TransactionBuilder> {
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<TransactionTemplate>,
): Promise<Uint8Array> {
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<T>;
/**
* 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<T> {
return (await WalletP2PKHWatch.from(dependencies, genesisData)) as T;
}