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