Resolve "WIP: UTXO Selection and Storage"
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user