Files
xo-stack/packages/stack/src/wallet/wallet-xo.ts
T
2026-08-07 09:09:42 +00:00

700 lines
24 KiB
TypeScript

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<T extends typeof WalletXO>(
this: T,
deps: WalletDependencies,
genesisData: DistributiveOmit<WalletXOGenesisData, 'type'>,
) {
// 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<T>;
}
// 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<templateIdentifier, { [useCaseIndex]: WalletP2PKH[] }>
*/
public wallets: Map<string, { [useCaseIndex: number]: WalletP2PKH[] }> =
new Map();
constructor(
dependencies: Required<WalletDependencies>,
genesisData: DistributiveOmit<WalletXOGenesisData, 'type'>,
) {
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<void>[] = [];
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<void>[] = [];
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<void>[] = [];
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<void>[] = [];
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<TokenBalances> {
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<TransactionTemplate>,
): Promise<TransactionBuilder> {
// 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<TransactionTemplate>) {
// 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<WalletP2PKH[]> {
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<T extends WalletXO = WalletXO> = (
services: WalletDependencies,
genesisData: WalletXOGenesisData,
) => Promise<T>;
export async function useWalletXO<T extends WalletXO = WalletXO>(
services: WalletDependencies,
genesisData: WalletXOGenesisData,
): Promise<T> {
return (await WalletXO.from(services, genesisData)) as T;
}