Resolve "WIP: UTXO Selection and Storage"

This commit is contained in:
Jim Hamill
2025-01-11 08:39:24 +00:00
parent f934216d68
commit 33fd9bac4d
156 changed files with 13304 additions and 4595 deletions
+240
View File
@@ -0,0 +1,240 @@
// An activity represents an event that should be saved to persistent storage(s).
// TODO: Read more into LibAuth's concept of these.
// Jason mentions pruning, but I don't quite understand how that will be done yet.
// He also has the concept of "extensions" - but I'm not sure this has been fleshed out yet.
import { generateUUIDV7, getTimestampFromUUID } from '../utils';
import { BaseStore } from '../storage';
// TODO: Think about different activities
// TemplateActivity
// CashConnectActivity
// CoinLockActivity
// TODO: StorageMany adapter should be configurable to make it so set() cannot fail.
// However, for optimization, get() should probably only retrieve from a primary (usually local) adapter.
// Otherwise, shit will be too slow if we're doing a get() or all() from a remote source.
// This may also solve the AES256 problem: Local does not need to be encrypted per se.
// Sync from Remote (encrypted) to Local (non-encrypted) can happen in the background.
// TODO: I still haven't quite wrapped my head around LibAuth approach.
// But I imagine there'll be some kind of "Mutable" vs "Immutable" concept for Activities.
// Because we need to compile a "snapshot" based on Activities, if we add a Mutable Activity, we can prune previous Mutable Activities of the same identifier.
// This will help massively with optimization:
// 1. Our set of Activities becomes smaller (computation/storage = faster/better)
// 2. Syncing will take less time
// For the above, this probably means that we want to keep our Activities limited to one mutation.
// For example, we ideally wouldn't want an Activity that modifies/refers to multiple values.
// { type: 'freezeCoins', coins: [...] }
// ... because we cannot sync them efficiently (we need the entire history).
// Whereas, if we do:
// { type: 'freezeCoin' }
// ... and have an identifer, we can ensure that each wallet only syncs the latest frozen coin.
// Amendment 2024-11-20:
// For mutable, we could use property "unique: boolean".
// For priority (e.g. txTime activity), we could use property "policy: "earlist" | latest"
// Type definitions
export type Activity<Type extends string, ValueType> = {
timestamp?: bigint;
type: Type;
id: string;
value: ValueType;
};
// Standardized Activities.
export type WalletName = Activity<'WalletName', string>;
export type WalletDescription = Activity<'WalletDescription', string>;
export type TransactionFirstSeen = Activity<'TransactionFirstSeen', string>;
export type TransactionMemo = Activity<'TransactionMemo', string>;
// XO-specific Activities.
// TODO: These probably don't belong here and should be moved to the Wallet Codebase.
export type XOWalletColor = Activity<'XOWalletColor', string>;
export type XOTransactionCategory = Activity<'XOTransactionCategory', string>;
export type XOFrozenCoin = Activity<'XOFrozenCoin', boolean>;
export type SnapshotByType = {
[type: string]: {
[id: string]: Activity<string, any>;
};
};
// Policy type definitions
export type PolicyFunction<T = any> = (
existingActivity: Activity<string, T> | undefined,
newActivity: Activity<string, T>,
) => Activity<string, T>;
export class Activities {
// TODO: Consider splitting into its own mutable class: ActivitySnapshot.
// This might be cleaner as we'll likely want to be able to view a raw timeline of activities.
// So we can make Activities have its own methods for that without cluttering the class.
public snapshot: SnapshotByType = {};
private policies = new Map<string, PolicyFunction>();
// Add a map to track store keys for each snapshot key
private storeKeyMap = new Map<string, string>();
constructor(public store: BaseStore) {
this.store.on(
'set',
async (payload: { key: string; value: Activity<string, any> }) => {
// When a new activity is added, process it individually
await this.processActivity(payload.key, payload.value);
},
);
}
/**
* Get all activities of a specific type
*/
public getActivitiesByType<T>(type: string): Activity<string, T>[] {
return Object.values(this.snapshot[type] || {});
}
/**
* Get a specific activity by type and id
*/
public getActivity<T>(
type: string,
id: string,
): Activity<string, T> | undefined {
return this.snapshot[type]?.[id];
}
/**
* Process a single activity and update the snapshot
*/
private async processActivity(
storeKey: string,
activity: Activity<string, any>,
) {
if (!activity.timestamp) {
console.warn('Activity without timestamp encountered:', storeKey);
return;
}
const snapshotKey = this.getSnapshotKey(activity);
const existingActivity = this.getActivityFromSnapshot(
activity.type,
activity.id,
);
// Apply policy to determine which activity to keep
const keptActivity = this.applyPolicy(existingActivity, activity);
// Update snapshot with the kept activity
this.setActivityInSnapshot(keptActivity);
if (keptActivity === activity) {
// If we kept the new activity, update the store key mapping
const oldStoreKey = this.storeKeyMap.get(snapshotKey);
if (oldStoreKey) {
// Delete the old activity from the store
await this.store.delete(oldStoreKey);
}
this.storeKeyMap.set(snapshotKey, storeKey);
} else {
// If we kept the existing activity, delete the new activity from the store
await this.store.delete(storeKey);
}
}
/**
* Add a new activity to the store
*/
public async addActivity(activity: Activity<string, any>) {
const timestamp = activity.timestamp ?? BigInt(Date.now());
const activityWithTimestamp = {
...activity,
timestamp,
};
const key = generateUUIDV7(timestamp);
await this.store.set(key, activityWithTimestamp);
// Note: We don't need to call processActivity here as the 'set' event will trigger it
}
/**
* Compile a full snapshot from scratch (only needed for initialization or recovery)
*/
public async compileSnapshot() {
// Clear existing snapshot
this.snapshot = {};
// Get all activity data from our store
const activities = await this.store.all<Activity<string, any>>();
const entries = Object.entries(activities);
// Sort activities by timestamp using UUID v7's embedded timestamp
const activitiesSorted = entries.sort(([keyA], [keyB]) => {
const timestampA = getTimestampFromUUID(keyA);
const timestampB = getTimestampFromUUID(keyB);
return Number(timestampA - timestampB);
});
// Process each activity in order
for (const [storeKey, activity] of activitiesSorted) {
await this.processActivity(storeKey, activity);
}
return this.snapshot;
}
/**
* Register a policy for handling conflicts between activities of the same type
* @param type Activity type to register policy for
* @param policyFn Function that decides which activity to keep
*/
public registerPolicy(type: string, policyFn: PolicyFunction) {
this.policies.set(type, policyFn);
}
/**
* Get an activity from the snapshot by type and id
*/
private getActivityFromSnapshot(
type: string,
id: string,
): Activity<string, any> | undefined {
return this.snapshot[type]?.[id];
}
/**
* Set an activity in the snapshot, creating necessary object structure
*/
private setActivityInSnapshot(activity: Activity<string, any>) {
if (!this.snapshot[activity.type]) {
this.snapshot[activity.type] = {};
}
this.snapshot[activity.type][activity.id] = activity;
}
/**
* Get the snapshot key for an activity
*/
private getSnapshotKey(activity: Activity<string, any>): string {
return `${activity.type}:${activity.id}`;
}
/**
* Apply policy to determine which activity should be kept
*/
private applyPolicy(
existingActivity: Activity<string, any> | undefined,
newActivity: Activity<string, any>,
): Activity<string, any> {
const policy = this.policies.get(newActivity.type);
if (!policy) {
// Default policy: compare timestamps
if (!existingActivity) return newActivity;
return existingActivity.timestamp! >= newActivity.timestamp!
? existingActivity
: newActivity;
}
return policy(existingActivity, newActivity);
}
}
+154
View File
@@ -0,0 +1,154 @@
import {
type InputTemplateBCH,
type TransactionTemplate,
type WalletAddresses,
type WalletTransactions,
} from './types.js';
import { type BlockchainUTXOs } from '../blockchain';
import { type EventMap, EventEmitter } from '../utils';
import { type TokenBalances, type Address } from '@xocash/primitives';
/**
* Common interface for all wallet types.
*
* @remarks
*
* All function signatures are deliberately asynchronous to accommodate for cases where wallets might be remote.
* For example, we might eventually want to support a multisig service.
* Or we might have to support some other custodial "Apple Pay"-like OS-interface to get accepted onto their app store.
*/
export abstract class BaseWallet<
WalletEvents extends EventMap,
> extends EventEmitter<WalletEvents> {
/**
* Start monitoring this wallet for any transaction activity.
*/
abstract start(): Promise<void>;
/**
* Stop monitoring this wallet for any transaction activity.
*/
abstract stop(): Promise<void>;
/**
* Remove any event listeners from this wallet.
*/
abstract destroy(): Promise<void>;
/**
* Fetch all transactions from the blockchain involving this wallet.
*
* @remarks
*
* We will want to add a query argument so that workers can use this efficiently:
*
* ```ts
* {
* limit?: number;
* offset?: number;
* sort?: () => number;
* filter?: () => boolean;
* }
* ```
*
* This "query" argument should be turned into a generic type as we will want to re-use it for Activities.
*/
abstract getTransactions(): Promise<WalletTransactions>;
/**
* Fetch all unspents from the blockchain belonging to this wallet.
*/
abstract getUnspents(): Promise<BlockchainUTXOs>;
/**
* Fetch all unspents from the blockchain belonging to this wallet.
*
* @remarks
*
* I was considering removing this and just storing unspents as "UTXOSignable"s.
* But that might not be ideal if we want to have Watch-only wallets.
* A better approach might be to have Fetch Unspents return `Array<UTXO | UTXOSignable>`
* Still need to flesh that out.
*
* EDIT: We're going to want to store state for caching anyway...
* May as well just getUnspentDirectives() from the local state instead of fetch.
* Likewise for balances.
*/
abstract getUnspentDirectives(): Promise<Array<InputTemplateBCH>>;
/**
* Get the satoshi balance of this wallet.
*
* @remarks
*
* Consider confirmed/unconfirmed balances (or some other representation).
*
* This becomes important in some instances because services like BitPay require one confirmation.
* This'll also have bearing on how we define our UTXO Selection strategies.
*/
abstract getBalanceSats(): Promise<bigint>;
/**
* Get the token balance of this wallet.
*
* @remarks
*
* Consider confirmed/unconfirmed balances (or some other representation).
*
* This becomes important in some instances because services like BitPay require one confirmation.
* This'll also have bearing on how we define our UTXO Selection strategies.
*/
abstract getBalanceTokens(): Promise<TokenBalances>;
/**
* Get all addresses belonging to this wallet.
*/
abstract getAddresses(): Promise<WalletAddresses>;
/**
* Get the address that this wallet should receive to.
*
* @remarks
*
* Not all wallets will have an address (e.g. a HD Wallet), but should still be able to return an address that:
* a) Change will be sent to.
* b) Funds should be received to.
*/
abstract getReceivingAddress(): Promise<Address>;
/**
* Select UTXOs from this wallet to meet the amounts required.
*
* @remarks
*
* TODO: We are going to want to return the Transaction that was compiled.
*
* TODO: This should accept a second argument (probably function) that can filter/sory UTXOs.
* The intent here is to provide a "spending policy". E.g. Only use (or prioritise) UTXOs with 1-conf.
*/
abstract signTransaction(
template: Partial<TransactionTemplate>,
): Promise<Uint8Array>;
/**
* Select UTXOs from this wallet to meet the amounts required and broadcast it to the Blockchain.
*
* @remarks
*
* TODO: We are going to want to return the Transaction that was compiled.
*/
abstract sendTransaction(
template: Partial<TransactionTemplate>,
): Promise<void>;
// TODO: This should be a push-only list.
// Need to understand how Jason's pruning approach works.
// It might make more sense to "key" activities by type:
// This would allow us to filter more effectively each time a new activity is added.
// As it stands, we would have to collate the entire list if it's done by timestamp.
abstract addActivity<T>(activity: T): Promise<void>;
abstract export(): Promise<string>;
}
+9
View File
@@ -0,0 +1,9 @@
export * from './activities.js';
export * from './base-wallet.js';
export * from './transaction-builder.js';
export * from './types.js';
export * from './wallet-hd.js';
export * from './wallet-p2pkh.js';
export * from './wallet-p2sh20.js';
export * from './wallet-p2sh32.js';
export * from './wallet-set.js';
@@ -0,0 +1,289 @@
import { type WalletUTXO } from './types.js';
import { type UTXOSet, ExtMap, Outpoint } from '@xocash/primitives';
import {
type TransactionTemplateFixed,
type CompilerBCH,
type TransactionBCH,
binsAreEqual,
binToHex,
hexToBin,
encodeTransactionBCH,
encodeTransactionOutput,
generateTransaction,
getDustThreshold,
} from '@bitauth/libauth';
// TODO: This could probably still use a refactor, but isn't terrible anymore.
/**
* Transaction Builder that takes a template and can populate appropriate UTXOs.
*
* @remarks
*
* Note that this will only populate required Satoshis and Mutable Tokens.
*
* @experimental
*/
export class TransactionBuilder {
public unspents: Array<WalletUTXO>;
constructor(
public readonly transaction: TransactionTemplateFixed<CompilerBCH>,
unspents: Array<WalletUTXO> = [],
public sourceOutputs: UTXOSet = new ExtMap(),
) {
// This class will "shift" (remove) unspents from the array so to ensure we don't mutate the source array, we clone it.
// NOTE: We cannot do a structuredClone here. It will not work in all cases.
this.unspents = [...unspents];
}
public calculateInputSats(): bigint {
return this.transaction.inputs.reduce((inputSats, input) => {
// Get the satoshi amount for this input.
// NOTE: If this is not a signable UTXO, we attempt to find the satoshi amount in the provided source outputs.
const valueSatoshis =
input.unlockingBytecode instanceof Uint8Array
? this.sourceOutputs.getOrFail(Outpoint.fromInput(input).toString())
.output.valueSatoshis
: input.unlockingBytecode.valueSatoshis;
// Return the accumulated satoshi amount.
return (inputSats += valueSatoshis);
}, 0n);
}
public calculateInputTokens(): Map<string, bigint> {
// NOTE: We cannot index by Uint8Array, so we convert the category to hexadecimal form.
const tokensRequired: Map<string, bigint> = new Map();
// Iterate over each input so that we can calculate the total required for each token category.
this.transaction.inputs.forEach((input) => {
// Get the source output for this input.
// NOTE: If this is not a signable UTXO, we attempt to find the source output in the provided source outputs.
const sourceOutput =
input.unlockingBytecode instanceof Uint8Array
? this.sourceOutputs.getOrFail(Outpoint.fromInput(input).toString())
.output
: input.unlockingBytecode;
// If the source output contains fungible tokens...
if (sourceOutput.token && !sourceOutput.token.nft) {
// Convert the token category to hex so we can index it in a map.
const categoryHex = binToHex(sourceOutput.token.category);
// Get the current total for the given category.
const currentTokenTotal = tokensRequired.get(categoryHex) || 0n;
// Calculate the new total for the given category.
const newTotal = currentTokenTotal + sourceOutput.token.amount;
// Set the new total for the given category.
tokensRequired.set(categoryHex, newTotal);
}
});
return tokensRequired;
}
public calculateOutputSats(): bigint {
return this.transaction.outputs.reduce(
(outputTotal, output) => (outputTotal += output.valueSatoshis),
0n,
);
}
public calculateOutputTokens(): Map<string, bigint> {
// NOTE: We must use a Map (as opposed to object) if we're indexing by Uint8Array.
const tokensRequired: Map<string, bigint> = new Map();
this.transaction.outputs.forEach((output) => {
// If this output has a token and it is not an NFT...
if (output.token && !output.token.nft) {
const currentTokenTotal =
tokensRequired.get(binToHex(output.token.category)) || 0n;
const newTotal = currentTokenTotal + output.token.amount;
tokensRequired.set(binToHex(output.token.category), newTotal);
}
});
return tokensRequired;
}
public calculateFeeSats(): bigint {
return BigInt(this.toBytes().length);
}
public calculateRequiredSats(): bigint {
const outputSats = this.calculateOutputSats();
const feeSats = this.calculateFeeSats();
const requiredSats = feeSats + outputSats;
return requiredSats;
}
public addTokensFromUnspents(): TransactionBuilder {
// Calculate the tokens required for this transaction.
const tokensRequired = this.calculateOutputTokens();
// Interate through each token and add inputs to meet the required amount.
tokensRequired.forEach((requiredAmountForToken, categoryId) => {
// Find unspents containing this category ID.
const unspentsContainingToken = this.unspents.filter((unspent) =>
binsAreEqual(
unspent.unlockingBytecode.token?.category || new Uint8Array(),
hexToBin(categoryId),
),
);
// Sort them so that we prioritize the largest amounts first.
const unspentsContainingTokenSorted = unspentsContainingToken.sort(
(a, b) =>
Number(b.unlockingBytecode.token?.amount || 0) -
Number(a.unlockingBytecode.token?.amount || 0),
);
// Loop until we have enough input tokens to match the required amount of output tokens.
while (
(this.calculateInputTokens().get(categoryId) || 0n) <
requiredAmountForToken
) {
// Get the unspent to add from the top of our list and remove it.
const unspent = unspentsContainingTokenSorted.shift();
// If there are no more unspents, it means we have insufficient funds, so throw an error.
if (!unspent) {
throw new Error(
`Failed to select UTXOs: ${
this.calculateInputTokens().get(categoryId) || 0n
} of token (${categoryId}) available, ${requiredAmountForToken} required`,
);
}
// Append this Input.
this.transaction.inputs.push(unspent);
}
});
return this;
}
public addSatoshisFromUnspents(): TransactionBuilder {
// Find unspents without tokens.
const unspentsWithoutTokens = this.unspents.filter(
(unspent) => typeof unspent.unlockingBytecode.token === 'undefined',
);
// Sort them so that we prioritize the largest amounts first.
const unspentsWithoutTokensSorted = unspentsWithoutTokens.sort((a, b) =>
Number(
b.unlockingBytecode.valueSatoshis - a.unlockingBytecode.valueSatoshis,
),
);
// Iterate over each unspent until we get the amount desired.
while (
this.calculateInputSats() <
this.calculateOutputSats() + this.calculateFeeSats()
) {
// Get the unspent to add from the top of our list and remove it.
const unspent = unspentsWithoutTokensSorted.shift();
// If there are no more unspents, it means we have insufficient funds, so throw an error.
if (!unspent) {
throw new Error(
`Failed to select UTXOs: ${this.calculateInputSats()} of sats available, ${this.calculateRequiredSats()} required`,
);
}
// Append this unspent.
this.transaction.inputs.push(unspent);
}
return this;
}
public addTokenChangeOutputs(
changeLockingBytecode: Uint8Array,
): TransactionBuilder {
// Calculate input and output token totals.
const inputTokens = this.calculateInputTokens();
const outputTokens = this.calculateOutputTokens();
// Iterate over each input and add a token change output (if there is change).
inputTokens.forEach((amount, categoryId) => {
const inputAmount = amount;
const outputAmount = outputTokens.get(categoryId) || 0n;
const changeAmount = inputAmount - outputAmount;
// Add the change output.
this.transaction.outputs.push({
lockingBytecode: changeLockingBytecode,
valueSatoshis: 1000n,
token: {
category: hexToBin(categoryId),
amount: changeAmount,
},
});
});
return this;
}
public addSatoshiChangeOutput(
changeLockingBytecode: Uint8Array,
): TransactionBuilder {
// NOTE: This might lead to over-paying the tx fee slightly.
const changeOutputTemplate = {
lockingBytecode: changeLockingBytecode,
valueSatoshis: 0n,
};
const changeOutputFee =
BigInt(encodeTransactionOutput(changeOutputTemplate).length) + 4n;
// Calculate the input satoshis and the current transaction fee.
const inputSats = this.calculateInputSats();
const outputSats = this.calculateOutputSats();
const transactionFee = this.calculateFeeSats() + changeOutputFee;
// Calculate the change satoshis.
const changeSats = inputSats - (outputSats + transactionFee);
// If the Change Output would be less than the dust threshold, do nothing.
if (changeSats < getDustThreshold(changeOutputTemplate)) {
return this;
}
// Add the change output.
this.transaction.outputs.push({
lockingBytecode: changeLockingBytecode,
valueSatoshis: changeSats,
});
// Return transaction with Satoshi Change Output.
return this;
}
public generate(): TransactionBCH {
// Generate the transaction
const generatedTransaction = generateTransaction(this.transaction);
// Ensure the transaction generated successfully.
if (!generatedTransaction.success) {
// lmao but better to show it to help devs.
const errors = generatedTransaction.errors
.map((error) => error.errors.map((error) => error.error).join(','))
.join(',');
throw new Error(`Failed to generate transaction: ${errors}`);
}
return generatedTransaction.transaction;
}
public toBytes(): Uint8Array {
const generatedTransaction = this.generate();
return encodeTransactionBCH(generatedTransaction);
}
}
+51
View File
@@ -0,0 +1,51 @@
import { type BaseBlockchain, type BlockchainTransaction } from '../blockchain';
import { type BaseStore } from '../storage';
import { Address, ExtMap } from '@xocash/primitives';
import {
type InputTemplate,
type CompilerBCH,
type CompilationData,
type CompilationDirectiveUnlocking,
type TransactionTemplateFixed,
type Output,
} from '@bitauth/libauth';
export type WalletServices = {
blockchain: BaseBlockchain;
store?: BaseStore;
cache?: BaseStore;
};
export type InputTemplateBCH = InputTemplate<
CompilerBCH,
false,
CompilationData
>;
// TODO: Rename me to WalletUTXO
export type WalletUTXO = {
outpointIndex: number;
outpointTransactionHash: Uint8Array;
sequenceNumber: number;
unlockingBytecode: CompilationDirectiveUnlocking<
CompilerBCH,
CompilationData<never>
>;
};
export type TransactionTemplate = TransactionTemplateFixed<CompilerBCH>;
export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>;
export type IWalletActivities<T> = { [id: string]: T };
// NOTE: We index this by Lockscript Hex so that we can find CashAddr, Legacy or other.
export type WalletAddresses = ExtMap<Address>;
export type WalletTransaction = BlockchainTransaction & {
sourceOutputs: Array<Output>;
};
export type WalletTransactions = ExtMap<WalletTransaction>;
+523
View File
@@ -0,0 +1,523 @@
// Import the Stamp class to derive the private keys
import { BaseWallet } from './base-wallet.js';
import { type TransactionTemplate } from './types.js';
import { TransactionBuilder } from './transaction-builder.js';
import { type WalletServices } from './types.js';
import {
WalletP2PKH,
WalletP2PKHFactory,
useWalletP2PKH,
} from './wallet-p2pkh.js';
import {
type BaseBlockchain,
type BlockchainTransactions,
type BlockchainUTXOs,
} from '../blockchain';
import {
type BaseStore,
type StoreValueProxy,
StoreInMemory,
} from '../storage';
import {
type TokenBalances,
HDPrivateNode,
ExtMap,
calculateBalanceSats,
calculateBalanceTokens,
encodeExtendedJson,
} from '@xocash/primitives';
import { hexToBin } from '@bitauth/libauth';
export const DERIVATION_PATH = "m/44'/145'/0'";
export const ADDRESS_GAP = 20;
// These constants correspond to the "change" path in BIP44.
// See: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki
export const CHAIN_EXTERNAL = 0;
export const CHAIN_INTERNAL = 1;
export type WalletHDGenesisData = {
type: 'WalletHD';
derivationPath: string;
addressGap?: number;
} & ({ seed: string } | { mnemonic: string } | { xpriv: string });
export type WalletHDEvents<WalletType> = {
walletDataUpdated: WalletHDGenesisData;
shouldMonitorUpdated: boolean;
walletsUpdated: Array<WalletType>;
transactionsUpdated: BlockchainTransactions;
unspentsUpdated: BlockchainUTXOs;
stateUpdated: string | null;
};
/**
* A Hierarhical Determinstic (BIP44) Wallet.
*
* @example
*
* ```ts
* // Define services to use.
* const services = {
* blockchain,
* cache,
* storage,
* }
*
* // Instantiate an instance of WalletHD.
* const walletHD = await WalletHD.from({
* derivationPath: "m/44'/145'/0'",
* mnemonic: 'page pencil stock planet limb cluster assault speak off joke private pioneer'
* // or "seed" or "xpub"
* }, services);
*
* // Scan for transactions and start monitoring.
* await wallet = await walletHD.start();
* ```
*/
export class WalletHD<
WalletType extends WalletP2PKH = WalletP2PKH,
> extends BaseWallet<WalletHDEvents<WalletType>> {
// Mutable State.
public shouldMonitor = false;
// Store values that are proxied for convenience/clarity.
// NOTE: We define these for clarity so that we can see the state stored with this wallet.
public genesisData: StoreValueProxy<WalletHDGenesisData>;
// Store child wallets, grouped by chain ("change") path in BIP44 spec.
public wallets: { [chainPath: number]: Array<WalletType> } = {};
constructor(
public blockchain: BaseBlockchain,
public store: BaseStore,
public cache: BaseStore,
public hdPrivateNode: HDPrivateNode,
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH,
) {
super();
// Initialize empty arrays for our wallets.
this.wallets[CHAIN_EXTERNAL] = [];
this.wallets[CHAIN_INTERNAL] = [];
// Initialize proxies to our stored data.
this.genesisData = this.store.proxy('genesisData');
}
static async from<T extends typeof WalletHD>(
this: T,
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
) {
// Assign in memory stores as defaults in case otherwise not provided.
const servicesFinal = {
...{
store: new StoreInMemory(),
cache: new StoreInMemory(),
},
...services,
};
// If we are attempting to create a new wallet...
if (genesisData) {
// Ensure that the provided store does not already contain data.
if (Object.keys(await servicesFinal.store.all()).length) {
throw new Error(`Provided store already contains data`);
}
// Store the wallet genesis data.
await servicesFinal.store.set<WalletHDGenesisData>(
'genesis',
genesisData,
);
}
// Retrieve our genesis data and extract the private key.
const retrievedGenesisData =
await servicesFinal.store.getOrFail<WalletHDGenesisData>('genesis');
let hdPrivateNode: HDPrivateNode;
// Instantiate the HDPrivateNode from the type specified in metadata.
if ('seed' in retrievedGenesisData) {
hdPrivateNode = HDPrivateNode.fromSeed(
hexToBin(retrievedGenesisData.seed),
);
} else if ('mnemonic' in retrievedGenesisData) {
hdPrivateNode = HDPrivateNode.fromMnemonic(retrievedGenesisData.mnemonic);
} else {
hdPrivateNode = HDPrivateNode.fromXPriv(retrievedGenesisData.xpriv);
}
// Return a new instance of WalletHD.
return new this(
servicesFinal.blockchain,
servicesFinal.store,
servicesFinal.cache,
hdPrivateNode,
walletFactory,
) as InstanceType<T>;
}
async start() {
// Get the genesis data.
const genesisData = await this.genesisData.getOrFail();
// Get the address gap.
const addressGap = genesisData.addressGap || ADDRESS_GAP;
// Scan the HDWallet for child wallets.
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);
}
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);
}
async destroy() {
this.removeAllListeners();
}
async getTransactions() {
// Declare storage for our fetch promises.
const fetchPromises = [];
// Iterate over each chain path and fetch transactions for each wallet.
for (const chainWallets of Object.values(this.wallets)) {
for (const wallet of chainWallets) {
fetchPromises.push(wallet.getTransactions());
}
}
// Wait for all of our fetch promises to complete.
const txs = await Promise.all(fetchPromises);
// Flatten (and deduplicate) the transactions.
const txsFlattened = ExtMap.flatten(txs);
// Emit an event to notify that transactions have been updated.
this.emit('transactionsUpdated', txsFlattened);
// Return the deduplicated transactions.
return txsFlattened;
}
async getUnspents() {
// Declare storage for our fetch promises.
const fetchPromises = [];
// Iterate over each chain path and fetch unspents for each wallet.
for (const chainWallets of Object.values(this.wallets)) {
for (const wallet of chainWallets) {
fetchPromises.push(wallet.getUnspents());
}
}
// Wait for all of our fetch promises to complete.
const unspents = await Promise.all(fetchPromises);
// Flatten them.
const unspentsFlattened = ExtMap.flatten(unspents);
// Return the unspents.
return unspentsFlattened;
}
async getUnspentDirectives() {
// Declare storage for our fetch promises.
const fetchPromises = [];
// Iterate over each chain path and fetch unspent directives for each wallet.
for (const chainWallets of Object.values(this.wallets)) {
for (const wallet of chainWallets) {
fetchPromises.push(wallet.getUnspentDirectives());
}
}
// Wait for all of our fetch promises to complete.
const unspentDirectives = await Promise.all(fetchPromises);
// Flatten them.
const unspentsFlattened = unspentDirectives.flat();
// Return the unspents.
return unspentsFlattened;
}
async getBalanceSats() {
// Fetch our unspents.
const unspents = await this.getUnspents();
// Get the individual UTXOs.
const utxos = unspents
.map((blockchainUTXO) => blockchainUTXO.utxo)
.toArray();
// Calculate the balance.
return calculateBalanceSats(utxos);
}
async getBalanceTokens(): Promise<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);
}
async getAddresses() {
const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL];
let addressPromises = [];
for (const chainPath of chainPaths) {
addressPromises.push(
...this.wallets[chainPath].map((wallet) =>
wallet.getReceivingAddress(),
),
);
}
const addresses = await Promise.all(addressPromises);
return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex());
}
async getReceivingAddress(chainPath = CHAIN_EXTERNAL) {
// TODO: Derive a new wallet, not the first one you lazy fuckhead.
// You'll probably need to refactor your scan() approach.
let receivingWallet = this.wallets[chainPath][0];
// If there is no receiving wallet, create one.
// TODO: This is fucked. You need to rethink deriveWallets.
if (!receivingWallet) {
this.deriveWallets(1, chainPath);
receivingWallet = this.wallets[chainPath][0];
}
// Return the receiving address of the derived wallet.
return receivingWallet.privateKey.derivePublicKey().deriveAddress();
}
async signTransaction(
txTemplate: Partial<TransactionTemplate>,
): Promise<Uint8Array> {
// Populate the final transaction template by merging with defaults.
// NOTE: This is just a convenience to make "send" easier to use.
const txTemplateFinal: TransactionTemplate = {
...{
version: 2,
locktime: 0,
inputs: [],
outputs: [],
},
...txTemplate,
};
// Get the receiving address of this wallet.
// NOTE: As per BIP44 spec, we set the change address to use the internal chain.
const changeAddress = await this.getReceivingAddress(CHAIN_EXTERNAL);
// Get the change locking bytecode (change address) for this wallet.
const changeLockingBytecode = changeAddress.toLockscriptBytes();
// Fetch unspents that are usable by this wallet.
const unspents = await this.getUnspentDirectives();
// Create an instance of our transaction builder and populate with our Wallet's UTXOs.
// NOTE: We must deal with tokens first and satoshis last.
// The reason is that the change output will be determined based on transaction fee.
const txBuilder = new TransactionBuilder(txTemplateFinal, unspents)
.addTokensFromUnspents()
.addTokenChangeOutputs(changeLockingBytecode)
.addSatoshisFromUnspents()
.addSatoshiChangeOutput(changeLockingBytecode);
// Generate the transaction.
const generatedTxBytes = txBuilder.toBytes();
return generatedTxBytes;
}
async sendTransaction(txTemplate: Partial<TransactionTemplate>) {
// Sign the transaction template.
const generatedTxBytes = await this.signTransaction(txTemplate);
// Broadcast the transaction to the blockchain.
await this.blockchain.broadcastTransaction(generatedTxBytes);
}
async addActivity<T>(activity: T) {
// Generate an Activity ID.
// TODO: Look more into how LibAuth intends to do this.
const activityId = Date.now().toString();
// Store the data.
await this.store.set<T>(activityId, activity);
}
async export() {
const allData = await this.store.all();
const asJson = encodeExtendedJson(allData);
return asJson;
}
async deriveWallets(
count: number,
chainPath = CHAIN_EXTERNAL,
startIndex = 0,
) {
// Get the derivation path from the genesis data.
const genesisData = await this.genesisData.getOrFail();
// Create an array to store our wallets.
const newWallets: Array<WalletType> = [];
// Create the given count of wallets.
for (let i = 0; i < count; i++) {
// Define the derivation path.
const derivationPath = `${genesisData.derivationPath}/${chainPath}/${startIndex + i}`;
// Derive the private key at the given path.
const privateKeyHex = await this.cache.getOrSet(derivationPath, () => {
const childNode = this.hdPrivateNode.derivePath(derivationPath);
return childNode.privateKey().toHex();
});
// Create a child P2PKH wallet for this Private Key.
const wallet = await this.walletFactory(
{
blockchain: this.blockchain,
cache: this.cache,
},
{
type: 'WalletP2PKH',
privateKey: privateKeyHex,
},
);
// Subscribe to wallet-state updates.
wallet.on('stateUpdated', async () => {
this.emit('stateUpdated', null);
});
// Start monitoring the wallet.
wallet.start();
// Add the wallet to our list of wallets.
newWallets.push(wallet);
}
// Set our wallets.
this.wallets[chainPath] = [...this.wallets[chainPath], ...newWallets];
// Return the wallets.
return newWallets;
}
async scan(
startIndex = 0,
addressGap = 20,
chainPaths: Array<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<WalletType> = [];
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<WalletType> = await this.deriveWallets(
addressGap,
chainPath,
currentIndex,
);
// Get the transaction history of each wallet in the batch
const results = await Promise.all(
batch.map((wallet) => wallet.getTransactions()),
);
// Process the results
for (let i = 0; i < results.length; i++) {
if (results[i].size > 0) {
// This address has transactions, add it to nodes and reset empty address count
wallets.push(batch[i]);
emptyAddressCount = 0;
} else {
// This address is empty, increment the empty address count
emptyAddressCount++;
}
currentIndex++;
// If we've found 'addressGap' consecutive empty addresses, stop scanning
if (emptyAddressCount >= addressGap) {
break;
}
}
}
}
this.emit('stateUpdated', null);
}
}
//-----------------------------------------------------------------------------
// Factories Types/Functions
//-----------------------------------------------------------------------------
export type WalletHDFactory<T extends WalletHD = WalletHD> = (
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
) => Promise<T>;
export async function useWalletHD<T extends WalletHD = WalletHD>(
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
): Promise<T> {
return (await WalletHD.from(services, genesisData, walletFactory)) as T;
}
+450
View File
@@ -0,0 +1,450 @@
import { BaseWallet } from './base-wallet.js';
import { type WalletTransactions, type TransactionTemplate } from './types.js';
import { TransactionBuilder } from './transaction-builder.js';
import { type WalletServices } from './types.js';
import {
type BaseBlockchain,
type BlockchainTransaction,
type BlockchainUTXOs,
} from '../blockchain';
import { type BaseStore, StoreInMemory } from '../storage';
import {
Address,
ExtMap,
PrivateKey,
calculateBalanceSats,
calculateBalanceTokens,
encodeExtendedJson,
} from '@xocash/primitives';
import {
type Input,
binToHex,
sha256,
utf8ToBin,
walletTemplateP2pkhNonHd,
walletTemplateToCompilerBCH,
} from '@bitauth/libauth';
export type WalletP2PKHGenesisData = {
type: 'WalletP2PKH';
privateKey: string;
};
export type WalletP2PKHEvents = {
transactionsUpdated: WalletTransactions;
unspentsUpdated: BlockchainUTXOs;
stateUpdated: string | null;
};
/**
* @example
* ```ts
* // Instantiate P2PKH Wallet.
* const walletP2PKH = WalletP2PKH.fromWIF('someWif');
*
* // Start monitoring the balance.
* await walletP2PKH.startMonitoring();
* ```
*/
export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
// Internal State.
public isStarted = false;
public status: string | null = null;
constructor(
// Dependencies.
public blockchain: BaseBlockchain,
// Persistent Storage.
public store: BaseStore,
// Cache.
public cache: BaseStore,
// Properties.
public privateKey: PrivateKey,
) {
super();
}
static async from<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
genesisData?: WalletP2PKHGenesisData,
) {
// Assign in memory stores as defaults in case otherwise not provided.
const servicesFinal = {
...{
store: new StoreInMemory(),
cache: new StoreInMemory(),
},
...services,
};
// If we are attempting to create a new wallet...
if (genesisData) {
// Ensure that the provided store does not already contain data.
if (await servicesFinal.store.get('genesis')) {
throw new Error(`Provided store already contains data`);
}
// Store the wallet genesis data.
await servicesFinal.store.set<WalletP2PKHGenesisData>(
'genesis',
genesisData,
);
}
// Retrieve our genesis data and extract the private key.
const retrievedGenesisData =
await servicesFinal.store.getOrFail<WalletP2PKHGenesisData>('genesis');
// Get the Private Key as hex.
const privateKey = PrivateKey.fromHex(retrievedGenesisData.privateKey);
// Return a new instance of Wallet P2PKH.
return new this(
servicesFinal.blockchain,
servicesFinal.store,
servicesFinal.cache,
privateKey,
) as InstanceType<T>;
}
static fromBytes<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
privateKeyBytes: Uint8Array,
) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromBytes(privateKeyBytes);
// Return a new instance of Wallet P2PKH.
return this.from<T>(services, {
type: 'WalletP2PKH',
privateKey: privateKey.toHex(),
});
}
static fromHex<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
privateKeyHex: string,
) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromHex(privateKeyHex);
// Return a new instance of Wallet P2PKH.
return this.from<T>(services, {
type: 'WalletP2PKH',
privateKey: privateKey.toHex(),
});
}
static fromWIF<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
privateKeyWif: string,
) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromWIF(privateKeyWif);
// Return a new instance of Wallet P2PKH.
return this.from<T>(services, {
type: 'WalletP2PKH',
privateKey: privateKey.toHex(),
});
}
static generateRandom<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
) {
// Create a Private Key entity generated from random bytes.
const privateKey = PrivateKey.generateRandom();
// Return a new instance of Wallet P2PKH.
return this.from<T>(services, {
type: 'WalletP2PKH',
privateKey: privateKey.toHex(),
});
}
async getId() {
const genesisData = await this.store.getOrFail('genesis');
return sha256.hash(utf8ToBin(JSON.stringify(genesisData)));
}
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;
}
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;
}
async destroy() {
this.removeAllListeners();
}
async getTransactions() {
// Get this node's address.
const address = await this.getReceivingAddress();
// Get transactions involving this address.
const transactions = await this.blockchain.fetchTransactions(
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()
.map((input) => getSourceOutput(input));
return await Promise.all(sourceOutputPromises);
};
// 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;
}
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;
}
async getUnspentDirectives() {
// Get a list of unspent outputs.
const unspentOutputs = await this.getUnspents();
// Create our P2PKH Compiler.
const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd);
// Transform our inputs into unlocking directives.
const inputDirectives = unspentOutputs
.map((blockchainUTXO) => ({
outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex,
outpointTransactionHash:
blockchainUTXO.utxo.outpoint.outpointTransactionHash,
sequenceNumber: 0,
unlockingBytecode: {
compiler: compilerP2PKH,
data: {
keys: { privateKeys: { key: this.privateKey.toBytes() } },
},
script: 'unlock',
token: blockchainUTXO.utxo.output.token,
valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis,
},
}))
.toArray();
// Return the directives.
return inputDirectives;
}
async getBalanceSats() {
// Fetch our unspents.
const unspents = await this.getUnspents();
// Get the individual UTXOs.
const utxos = unspents
.map((blockchainUTXO) => blockchainUTXO.utxo)
.toArray();
// Calculate the balance.
return calculateBalanceSats(utxos);
}
async getBalanceTokens() {
// Fetch our unspents.
const unspents = await this.getUnspents();
// Get the individual UTXOs.
const utxos = unspents
.map((blockchainUTXO) => blockchainUTXO.utxo)
.toArray();
// Calculate the token balances.
return calculateBalanceTokens(utxos);
}
async getAddresses() {
const addresses = await Promise.all([this.getReceivingAddress()]);
return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex());
}
async getReceivingAddress() {
return this.privateKey.derivePublicKey().deriveAddress();
}
async signTransaction(txTemplate: Partial<TransactionTemplate>) {
// Populate the final transaction template by merging with defaults.
// NOTE: This is just a convenience to make "send" easier to use.
const txTemplateFinal: TransactionTemplate = {
...{
version: 2,
locktime: 0,
inputs: [],
outputs: [],
},
...txTemplate,
};
// Get the receiving address of this wallet.
const receivingAddress = await this.getReceivingAddress();
// Get the change locking bytecode (change address) for this wallet.
const changeLockingBytecode = receivingAddress.toLockscriptBytes();
// Fetch unspents that are usable by this wallet.
const unspents = await this.getUnspentDirectives();
// Create an instance of our transaction builder.
const txBuilder = new TransactionBuilder(txTemplateFinal, unspents);
// Append necessary unspents to satisfy amount requirements.
// NOTE: The order here is very important.
// We must deal with tokens first and satoshis last.
// The reason is that the change output will be determined based on transaction fee.
// TODO: Consider replacing this with just a single util function.
txBuilder
.addTokensFromUnspents()
.addTokenChangeOutputs(changeLockingBytecode)
.addSatoshisFromUnspents()
.addSatoshiChangeOutput(changeLockingBytecode);
// Generate the transaction.
const generatedTxBytes = txBuilder.toBytes();
return generatedTxBytes;
}
async sendTransaction(txTemplate: Partial<TransactionTemplate>) {
// Generate the transaction.
const generatedTxBytes = await this.signTransaction(txTemplate);
// Broadcast the transaction to the blockchain.
await this.blockchain.broadcastTransaction(generatedTxBytes);
}
private async onAddressNotification(status: string | null) {
// If wallet state has changed...
if (status !== this.status) {
// Set our new status.
this.status = status;
// Emit an event.
this.emit('stateUpdated', this.status);
}
}
async export() {
const allData = await this.store.all();
const asJson = encodeExtendedJson(allData);
return asJson;
}
async addActivity<T>(activity: T) {
// Generate an Activity ID.
// TODO: Look more into how LibAuth intends to do this.
const activityId = Date.now().toString();
// Store the data.
await this.store.set<T>(activityId, activity);
}
}
//-----------------------------------------------------------------------------
// Factories Types/Functions
//-----------------------------------------------------------------------------
export type WalletP2PKHFactory<T extends WalletP2PKH = WalletP2PKH> = (
services: WalletServices,
genesisData?: WalletP2PKHGenesisData,
) => Promise<T>;
export async function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
services: WalletServices,
genesisData?: WalletP2PKHGenesisData,
): Promise<T> {
return (await WalletP2PKH.from(services, genesisData)) as T;
}
@@ -0,0 +1,11 @@
/**
* Placeholder.
*
* @remarks
* TODO Use AddressData
*/
export class WalletP2SH20 {
constructor() {
console.log('TODO');
}
}
@@ -0,0 +1,11 @@
/**
* Placeholder.
*
* @remarks
* TODO Use AddressData
*/
export class WalletP2SH32 {
constructor() {
console.log('TODO');
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Placeholder.
*
* @remarks
* One difficult thing here is going to be storage of the subwallets.
* We're going to have child wallets and we need to propagate their storage to the root WalletSet.
* See remarks on Storage - it's semi-related and I'm still playing with approaches.
*/
// https://github.com/bitjson/libauth/blob/d3d610de088cd98f5c391fe62dce81874eb5d95a/src/lib/engine/types/wallet-set-types.ts
export class WalletSet {
constructor() {
console.log('TODO');
}
}