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
+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;
}