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
@@ -0,0 +1,155 @@
import { EventEmitter } from '../utils';
import {
type AddressCallback,
type BlockchainUTXOs,
type BlockchainTransactions,
type TransactionCallback,
} from './types.js';
import { type Transaction } from '@xocash/primitives';
export type BlockchainEvents = {
isConnectedUpdated: boolean;
blockHeightUpdated: number | undefined;
};
/**
* Abstract base class representing a blockchain interface implementation.
* Extends EventEmitter to provide blockchain-specific event handling capabilities.
*
* This class serves as a template for concrete blockchain implementations,
* providing a standardized interface for common blockchain operations such as
* fetching transactions, managing UTXOs, and handling blockchain subscriptions.
*
* @abstract
* @extends {EventEmitter<BlockchainEvents>}
*/
export abstract class BaseBlockchain extends EventEmitter<BlockchainEvents> {
// TODO: Lockscript might be better as the Subscribe Handler.
// From this, we can convert it to address.
// Fulcrum has Scripthash too - but I'm hesitant about this.
// Scripthash means SHA256'ing the Lockscript.
// This is fine - but, at scale, will become expensive.
// For example, imagine a HDWallet with 2000+ addresses.
// And we want to subscribe to all of them.
// Alternatively, we could just pass in an Address Primitive?
// And then have the Adapter decide what it wants to use?
/**
* Initializes the blockchain connection and required resources.
* Should be called before performing any blockchain operations.
*
* @abstract
* @returns {Promise<void>} A promise that resolves when the blockchain connection is established
* @throws {Error} If the connection cannot be established
*/
abstract start(): Promise<void>;
/**
* Gracefully terminates the blockchain connection and cleans up resources.
* Should be called when blockchain operations are no longer needed.
*
* @abstract
* @returns {Promise<void>} A promise that resolves when the shutdown is complete
*/
abstract stop(): Promise<void>;
/**
* Retrieves all unspent transaction outputs (UTXOs) for a given address.
*
* @abstract
* @param {string} address - The blockchain address to query
* @returns {Promise<BlockchainUTXOs>} A promise that resolves with the unspent outputs
* @throws {Error} If the address is invalid or the query fails
*/
abstract fetchUnspents(address: string): Promise<BlockchainUTXOs>;
/**
* Retrieves detailed information about a specific transaction.
*
* @abstract
* @param {string} txHash - The transaction hash/ID to look up
* @returns {Promise<Transaction>} A promise that resolves with the transaction details
* @throws {Error} If the transaction cannot be found or the query fails
*/
abstract fetchTransaction(txHash: string): Promise<Transaction>;
/**
* Retrieves all transactions associated with a specific address.
*
* @abstract
* @param {string} address - The blockchain address to query
* @returns {Promise<BlockchainTransactions>} A promise that resolves with the list of transactions
* @throws {Error} If the address is invalid or the query fails
*/
abstract fetchTransactions(address: string): Promise<BlockchainTransactions>;
/**
* Broadcasts a raw transaction to the blockchain network.
*
* @abstract
* @param {Uint8Array} transaction - The serialized transaction data to broadcast
* @returns {Promise<void>} A promise that resolves when the transaction is successfully broadcast
* @throws {Error} If the transaction is invalid or broadcasting fails
*/
abstract broadcastTransaction(transaction: Uint8Array): Promise<void>;
/**
* Subscribes to updates for a specific blockchain address.
* The provided callback will be invoked when relevant events occur for the address.
*
* @abstract
* @param {string} address - The blockchain address to monitor
* @param {AddressCallback} callback - The callback function to handle address-related events
* @returns {Promise<void>} A promise that resolves when the subscription is established
* @throws {Error} If the subscription cannot be created
*/
abstract subscribeAddress(
address: string,
callback: AddressCallback,
): Promise<void>;
/**
* Removes a subscription for a specific blockchain address.
*
* @abstract
* @param {string} address - The blockchain address to unsubscribe from
* @param {AddressCallback} callback - The callback function to remove
* @returns {Promise<void>} A promise that resolves when the subscription is removed
* @throws {Error} If the subscription cannot be removed
*/
abstract unsubscribeAddress(
address: string,
callback: AddressCallback,
): Promise<void>;
/**
* Subscribes to updates for a specific transaction.
* The provided callback will be invoked when relevant events occur for the transaction.
*
* @abstract
* @param {string} transactionHash - The transaction hash to monitor
* @param {TransactionCallback} callback - The callback function to handle transaction-related events
* @returns {Promise<void>} A promise that resolves when the subscription is established
* @throws {Error} If the subscription cannot be created
*/
abstract subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
): Promise<void>;
/**
* Removes a subscription for a specific transaction.
*
* @abstract
* @param {string} transactionHash - The transaction hash to unsubscribe from
* @param {TransactionCallback} callback - The callback function to remove
* @returns {Promise<void>} A promise that resolves when the subscription is removed
* @throws {Error} If the subscription cannot be removed
*/
abstract unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
): Promise<void>;
}
@@ -0,0 +1,544 @@
import { BaseBlockchain } from './base-blockchain.js';
import {
type AddressCallback,
type TransactionCallback,
type BlockchainTransaction,
type BlockchainUTXO,
} from './types.js';
import { type BaseStore, StoreInMemory } from '../storage';
import {
Address,
ExtMap,
Outpoint,
Transaction,
TransactionHash,
UTXO,
} from '@xocash/primitives';
import { ElectrumClient, RPCParameter } from '@electrum-cash/network';
import {
type ElectrumProtocolEvents,
type AddressSubscribeNotification,
type HeadersSubscribeNotification,
type TransactionSubscribeNotification,
initializeElectrumClient,
broadcastTransaction,
fetchUnspentTransactionOutputs,
fetchHistory,
fetchTransaction,
subscribeToBlockheaderUpdates,
} from '@electrum-cash/protocol';
import { hexToBin } from '@bitauth/libauth';
export interface BroadcastOpts {
timeoutMs: number;
waitUntilSeen: boolean;
}
export type BlockchainElectrumEvents = {
isConnectedUpdated: boolean;
blockHeightUpdated: number | undefined;
};
export type BlockchainElectrumOpts = {
servers: Array<string>;
application: string;
};
export class BlockchainElectrum extends BaseBlockchain {
// Composables.
electrumClient!: ElectrumClient<ElectrumProtocolEvents>;
// State.
isConnected = false;
blockHeight: number | undefined = undefined;
// Subscriptions State.
addressSubscriptions: Map<string, Set<AddressCallback>> = new Map();
transactionSubscriptions: Map<string, Set<TransactionCallback>> = new Map();
constructor(
public readonly store: BaseStore = new StoreInMemory(),
public readonly opts: BlockchainElectrumOpts = {
servers: [
'bch.imaginary.cash',
'cashnode.bch.ninja',
'electrum.imaginary.cash',
],
application: 'XOApp',
},
) {
super();
}
async start() {
// TODO: We want to use a Cluster instead of a Client, but as of 2022-07-07 there are issues with Subscriptions on Cluster.
// See: https://gitlab.com/GeneralProtocols/anyhedge/whitelabel/frontend/-/issues/76
// Iterate over each server in our list of servers.
// NOTE: We are still using an Electrum Client due to the above bug.
// But, if we cannot connect to the first server, we will try the second (and third if given, etc) as a fallback.
for (const electrumServer of this.opts.servers) {
// Initialize an Electrum client using the given server.
const electrum = await initializeElectrumClient(
this.opts.application,
electrumServer,
);
// Attempt to connect to this Electrum Server.
try {
await electrum.connect();
this.isConnected = true;
this.emit('isConnectedUpdated', this.isConnected);
} catch (error) {
console.warn(error);
// If it fails to connect, try the next server in our list.
continue;
}
// Save the client.
this.electrumClient = electrum;
// Setup notification handler for connected event.
this.electrumClient.on('connected', () => {
// Set the isConnected property to true.
this.isConnected = true;
// Emit an event.
this.emit('isConnectedUpdated', this.isConnected);
});
// Setup notification handler for disconnected event.
this.electrumClient.on('disconnected', () => {
// Set the isConnected property to false.
this.isConnected = false;
// Emit an event.
this.emit('isConnectedUpdated', this.isConnected);
});
// TODO: Implement me and work out interface.
this.electrumClient.on(
'blockchain.headers.subscribe',
this.handleNewBlockchainHeaders.bind(this),
);
this.electrumClient.on(
'blockchain.address.subscribe',
this.handleAddressStatusUpdates.bind(this),
);
this.electrumClient.on(
'blockchain.transaction.subscribe',
this.handleTransactionStatusUpdates.bind(this),
);
// Subscribe to block header notifications.
await subscribeToBlockheaderUpdates(this.electrumClient);
// Return to prevent further execution.
return;
}
// Throw an error if we failed to connect to any of our Electrum Servers.
throw new Error(
'Failed to connect to Electrum. Please try restarting your browser and ensure you are currently connected to the internet.',
);
}
async stop() {
// Disconnect from Electrum.
this.electrumClient.disconnect();
}
async fetchUnspents(address: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `addressUnspents.${address}`;
// Attempt to get the address unspents from the cache or fetch them if not available.
const addressUnspents = await this.store.getOrSet(cacheKey, async () => {
return await fetchUnspentTransactionOutputs(
this.electrumClient,
address,
true,
true,
);
});
// Work out the locking byecode from the address.
const lockingBytecode = Address.from(address).toLockscriptBytes();
// Transform the unspents into our UTXO format.
const utxos: Array<BlockchainUTXO> = addressUnspents.map((unspent) => {
const outpoint = new Outpoint(hexToBin(unspent.tx_hash), unspent.tx_pos);
return {
height: unspent.height,
utxo: UTXO.from(outpoint, {
valueSatoshis: BigInt(unspent.value),
token: unspent.token_data
? {
amount: BigInt(unspent.token_data.amount),
category: hexToBin(unspent.token_data.category),
nft: unspent.token_data.nft
? {
capability: unspent.token_data.nft.capability,
commitment: hexToBin(unspent.token_data.nft.commitment),
}
: undefined,
}
: undefined,
lockingBytecode,
}),
};
});
// Index the UTXOs by hashInHex:index.
const utxosIndexed = ExtMap.fromArray(utxos, (blockchainUTXO) => {
return blockchainUTXO.utxo.outpoint.toString();
});
// Return the unspents.
return utxosIndexed;
}
async fetchTransaction(txHash: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `transactions.${txHash}`;
// Attempt to get the transaction hex from the cache or fetch it if not available.
const transactionHex = await this.store.getOrSet(cacheKey, async () => {
return await fetchTransaction(this.electrumClient, txHash);
});
// Decode the hex into our transaction primitive.
const transaction = Transaction.fromHex(transactionHex);
// Attempt to get the transaction hex from the cache or fetch it if not available.
// NOTE: We store JSON as opposed to hex because it is MUCH faster to decode.
/*
const transaction = new Transaction(
await this.store.getOrSet(cacheKey, async () => {
const tx = await fetchTransaction(this.electrumClient, txHash);
return Transaction.fromHex(tx).transaction;
}),
);
*/
// Return the transaction hex.
return transaction;
}
async fetchTransactions(address: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `addressHistory.${address}`;
// Attempt to get the transaction history from the cache or fetch them if not available.
const addressHistory = await this.store.getOrSet(cacheKey, async () => {
return await fetchHistory(this.electrumClient, address);
});
// Get the transactions for each history item.
const transactions: Array<BlockchainTransaction> = await Promise.all(
addressHistory.map(async (item) => ({
hash: TransactionHash.fromHex(item.tx_hash),
height: item.height,
transaction: await this.fetchTransaction(item.tx_hash),
})),
);
// Index the transactions by their hash (in hex).
const transactionsIndexed = ExtMap.fromArray(transactions, (tx) =>
tx.hash.toHex(),
);
// Return the transactions.
return transactionsIndexed;
}
async waitForTransaction(
tx: Uint8Array | Transaction | string,
timeoutMs = 10_000,
) {
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.from(tx);
}
// Attempt to broadcast the transaction.
const waitForTxPromise = new Promise<void>(async (resolve, reject) => {
// Set a timeout.
const timeout = setTimeout(() => {
reject(new Error(`Failed to broadcast ${tx.toHashHex()}`));
}, timeoutMs);
// Create a subscribe function so that we get notified when transaction is seen by network
const subscribeCallback = (_height: number | null) => {
clearTimeout(timeout);
this.unsubscribeTransaction(tx.toHashHex(), subscribeCallback);
resolve();
};
// Subscribe to the transaction.
await this.subscribeTransaction(tx.toHashHex(), subscribeCallback);
});
return waitForTxPromise;
}
// TODO: Split out the "waitUntilSeen" into own function.
// This'll be useful for protocols like BIP70/JPP/CashConnect, etc.
// Also consider a fail callback:
// If a "waitFor" fails, we probably want to invalidate the tx by sending those inputs elsewhere.
async broadcastTransaction(
tx: Transaction | Uint8Array | string,
opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true },
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.from(tx);
}
// Create a promise that will resolve when transaction is seen.
const waitForTxPromise = this.waitForTransaction(tx, opts.timeoutMs);
// Broadcast the transaction to our Electrum node.
await broadcastTransaction(this.electrumClient, tx.toHex());
// Wait for the transaction to be seen.
await waitForTxPromise;
}
async subscribeAddress(address: string, callback: AddressCallback) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get or create the set of callbacks for this transaction
let callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
callbacks = new Set();
this.addressSubscriptions.set(address, callbacks);
await this.electrumClient.subscribe(
'blockchain.address.subscribe',
address,
);
}
// Add the callback to the set
callbacks.add(callback);
}
async unsubscribeAddress(address: string, callback: AddressCallback) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get the callbacks set for this address
const callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
return;
}
// Remove the specific callback
callbacks.delete(callback);
// If there are no more callbacks, remove the address entry
// and potentially unsubscribe from blockchain updates
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(address);
await this.electrumClient.unsubscribe(
'blockchain.address.unsubscribe',
address,
);
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get or create the set of callbacks for this transaction
let callbacks = this.transactionSubscriptions.get(transactionHash);
if (!callbacks) {
callbacks = new Set();
this.transactionSubscriptions.set(transactionHash, callbacks);
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash,
);
}
// Add the callback to the set
callbacks.add(callback);
}
async unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get the callbacks set for this transaction
const callbacks = this.transactionSubscriptions.get(transactionHash);
if (!callbacks) {
return;
}
// Remove the specific callback
callbacks.delete(callback);
// If there are no more callbacks, remove the transaction entry
// and potentially unsubscribe from blockchain updates
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(transactionHash);
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash,
);
}
}
async request(method: string, ...parameters: Array<RPCParameter>) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
const response = await this.electrumClient.request(method, ...parameters);
if (response instanceof Error) {
throw response;
}
return response;
}
protected handleNewBlockchainHeaders(
notification: HeadersSubscribeNotification,
) {
// Extract height from the first param.
// NOTE: This is an object.
// https://electrum-cash-protocol.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe
const { height } = notification.params[0];
// Assign the block height.
this.blockHeight = height;
// Emit an event to update the blockheight.
this.emit('blockHeightUpdated', this.blockHeight);
}
protected async handleAddressStatusUpdates(
notification: AddressSubscribeNotification,
) {
// Extract parameters.
const address = notification.params[0];
const status = notification.params[1];
// Get the cached status hash for this address.
const cachedStatus = await this.store.get(`addressStatus.${address}`);
// If our cache is outdated, clear the caches for this address.
if (cachedStatus !== status) {
await this.store.delete(`addressUnspents.${address}`);
await this.store.delete(`addressHistory.${address}`);
}
// Cache the new status.
await this.store.set(`addressStatus.${address}`, status);
// Get the callbacks for this transaction
const callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
return;
}
// Execute all callbacks for this transaction.
for (const callback of callbacks) {
try {
callback(status);
} catch (error) {
// Handle or log any callback execution errors
console.error(
`Error executing callback for address ${address}:`,
error,
);
}
}
}
protected handleTransactionStatusUpdates(
notification: TransactionSubscribeNotification,
) {
const txHash = notification.params[0];
const height = notification.params[1];
// Get the callbacks for this transaction
const callbacks = this.transactionSubscriptions.get(txHash);
if (!callbacks) {
return;
}
// Execute all callbacks for this transaction.
for (const callback of callbacks) {
try {
callback(height);
} catch (error) {
// Handle or log any callback execution errors
console.error(
`Error executing callback for transaction ${txHash}:`,
error,
);
}
}
}
}
@@ -0,0 +1,297 @@
import { BaseBlockchain } from './base-blockchain.js';
import {
type AddressCallback,
type TransactionCallback,
type BlockchainTransaction,
type BlockchainTransactions,
type BlockchainUTXO,
type BlockchainUTXOs,
} from './types.js';
import {
Address,
ExtMap,
Outpoint,
Transaction,
UTXO,
} from '@xocash/primitives';
import {
type Output,
binToHex,
createVirtualMachineBCH,
} from '@bitauth/libauth';
// Local Blockchain Adapter.
// This simulates the BCH Blockchain.
export class BlockchainLocal extends BaseBlockchain {
private transactions: BlockchainTransactions;
private addressSubscriptions: Map<string, Set<AddressCallback>>;
private transactionSubscriptions: Map<string, Set<TransactionCallback>>;
constructor() {
super();
this.transactions = new ExtMap<BlockchainTransaction>();
this.addressSubscriptions = new Map();
this.transactionSubscriptions = new Map();
}
async start() {
console.log('BlockchainLocal: Started');
}
async stop() {
console.log('BlockchainLocal: Stopped');
}
async fetchUnspents(address: string) {
// Derive the current UTXO set.
const utxoSet = this.deriveUtxoSet().filter((blockchainUTXO) => {
// Decode the address and locking bytecode.
const utxoAddress = Address.fromLockscriptBytes(
blockchainUTXO.utxo.output.lockingBytecode,
);
const needleAddress = Address.from(address);
// Compare the lockscript hex.
return utxoAddress.toLockscriptHex() === needleAddress.toLockscriptHex();
});
return utxoSet;
}
async fetchTransaction(txHash: string) {
const tx = this.transactions.get(txHash);
if (!tx) {
throw new Error(`Transaction "${txHash}" does not exist`);
}
return tx.transaction;
}
async fetchTransactions(address: string) {
return this.transactions.filter((tx) => {
// Check if any input or output involves the address
// This is a simplified check and might need to be more sophisticated in a real implementation
return (
tx.transaction
.getInputs()
.some((input) =>
binToHex(input.unlockingBytecode).includes(address),
) ||
tx.transaction
.getOutputs()
.some((output) => binToHex(output.lockingBytecode).includes(address))
);
});
}
async broadcastTransaction(
tx: Transaction | Uint8Array | string,
opts: { skipValidation: boolean } = { skipValidation: false },
) {
// Cast to transaction object.
if (!(tx instanceof Transaction)) {
tx = Transaction.from(tx);
}
// Derive the UTXO set so that we can find source outputs.
const utxoSet = this.deriveUtxoSet();
if (!opts.skipValidation) {
// Find the source outputs from our UTXO set so that we can make sure the transaction is valid.
const sourceOutputs = tx.getInputs().map((input) => {
const utxoId = `${binToHex(input.outpointTransactionHash)}:${
input.outpointIndex
}`;
const blockchainUTXO = utxoSet.get(utxoId);
if (!blockchainUTXO) {
throw new Error(`BlockchainUTXO "${utxoId}" does not exist`);
}
return blockchainUTXO.utxo;
});
// Validate the transaction.
BlockchainLocal.validateTransaction(
tx,
sourceOutputs.map((utxo) => utxo.output),
);
}
// Add the transaction.
this.addTransaction({
hash: tx.toHash(),
transaction: tx,
height: 0,
});
// Notify our address and transaction subscribers.
this.notifyAddressSubscribers(tx);
this.notifyTransactionSubscribers(tx);
}
async subscribeAddress(address: string, callback: AddressCallback) {
// Convert the address to it's lockscript hex so that it's consistently represented.
const lockscriptHex = Address.from(address).toLockscriptHex();
// If no subscriptions exist yet on this lockscript, create a new Set.
if (!this.addressSubscriptions.has(lockscriptHex)) {
this.addressSubscriptions.set(lockscriptHex, new Set());
}
// Add the address notification callback.
this.addressSubscriptions.get(address)!.add(callback);
}
async unsubscribeAddress(address: string, callback: AddressCallback) {
// Convert the address to it's lockscript hex so that it's consistently represented.
const lockscriptHex = Address.from(address).toLockscriptHex();
// Find all callbacks this lockscript is subscribed to.
const callbacks = this.addressSubscriptions.get(lockscriptHex);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.addressSubscriptions.delete(address);
}
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
if (!this.transactionSubscriptions.has(transactionHash)) {
this.transactionSubscriptions.set(transactionHash, new Set());
}
this.transactionSubscriptions.get(transactionHash)!.add(callback);
}
async unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
const callbacks = this.transactionSubscriptions.get(transactionHash);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(transactionHash);
}
}
}
private notifyAddressSubscribers(tx: Transaction) {
// Derive the UTXO state so that we can lookup source outputs.
const utxoState = this.deriveUtxoSet();
// First, notify all source output listeners.
tx.getInputs().forEach((input) => {
const sourceOutput = utxoState.get(
`${input.outpointTransactionHash};${input.outpointIndex}`,
);
// If there is no corresponding source output, skip this notification.
// NOTE: This can occur if skipValidation is set to true in broadcast (which would be used for "coinbase" transactions).
if (!sourceOutput) {
return;
}
const address = Address.fromLockscriptBytes(
sourceOutput.utxo.output.lockingBytecode,
).toLockscriptHex();
const callbacks = this.addressSubscriptions.get(address);
if (callbacks) {
callbacks.forEach((callback) => callback('updated'));
}
});
// Then notify all output listeners.
tx.getOutputs().forEach((output) => {
const address = Address.fromLockscriptBytes(
output.lockingBytecode,
).toLockscriptHex();
const callbacks = this.addressSubscriptions.get(address);
if (callbacks) {
callbacks.forEach((callback) => callback('updated'));
}
});
}
private notifyTransactionSubscribers(tx: Transaction) {
const txHash = tx.toHashHex();
const callbacks = this.transactionSubscriptions.get(txHash);
if (callbacks) {
// In this local simulation, we're not tracking block height, so we'll use null
callbacks.forEach((callback) => callback(null));
}
}
addTransaction(tx: BlockchainTransaction) {
this.transactions.set(tx.hash.toHex(), tx);
}
removeTransaction(txHash: string) {
this.transactions.delete(txHash);
}
deriveUtxoSet(): BlockchainUTXOs {
const utxoMap: BlockchainUTXOs = new ExtMap();
// First, add all outputs as potential UTXOs
for (const tx of this.transactions.values()) {
tx.transaction.getOutputs().forEach((output, outpointIndex) => {
const utxo: BlockchainUTXO = {
height: 0,
utxo: UTXO.from(new Outpoint(tx.hash.toBytes(), outpointIndex), {
valueSatoshis: output.valueSatoshis,
lockingBytecode: output.lockingBytecode,
}),
};
utxoMap.set(`${tx.hash.toHex()}:${outpointIndex}`, utxo);
});
}
// Then, remove all spent outputs
for (const tx of this.transactions.values()) {
tx.transaction.getInputs().forEach((input) => {
const outpointTransactionHashHex = binToHex(
input.outpointTransactionHash,
);
utxoMap.delete(`${outpointTransactionHashHex}:${input.outpointIndex}`);
});
}
return utxoMap;
}
/**
* @remarks Consider moving this to Transaction Primitive. Trade-off is that it's probably big. Maybe standalone util?
*/
static validateTransaction(tx: Transaction, sourceOutputs: Array<Output>) {
const virtualMachine = createVirtualMachineBCH();
const virtualMachineResult = virtualMachine.verify({
transaction: {
version: tx.getVersion(),
locktime: tx.getLocktime(),
inputs: tx.getInputs(),
outputs: tx.getOutputs(),
},
sourceOutputs,
});
// If simulation of the transaction fails...
if (typeof virtualMachineResult === 'string') {
throw new Error(virtualMachineResult);
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { BaseBlockchain } from './base-blockchain.js';
import { type AddressCallback, type TransactionCallback } from './types.js';
import { BlockchainElectrum } from './blockchain-electrum.js';
import { BlockchainLocal } from './blockchain-local.js';
import { Transaction } from '@xocash/primitives';
/**
* The idea here is that this will implement the IBlockchain interface.
* But, it will allow the dev to specify multiple child IBlockchain interfaces for configuration.
* So, if the network is not available, it can fallback to the local and sync later.
*
* @example
* ```ts
* // Actual interface will be more complex, but this is the rough idea.
* const blockchain = new Blockchain([
* new BlockchainLocal(...),
* new BlockchainElectrum(...)
* ])
* ```
*/
export class Blockchain extends BaseBlockchain {
blockchainLocal: BlockchainLocal;
blockchainElectrum: BlockchainElectrum;
constructor() {
super();
this.blockchainLocal = new BlockchainLocal();
this.blockchainElectrum = new BlockchainElectrum();
}
async start() {
await Promise.all([
this.blockchainLocal.start(),
this.blockchainElectrum.start(),
]);
}
async stop() {
await Promise.all([
this.blockchainLocal.stop(),
this.blockchainElectrum.stop(),
]);
}
async fetchUnspents(address: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchUnspents(address);
} else {
return await this.blockchainLocal.fetchUnspents(address);
}
}
async fetchTransaction(txHash: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchTransaction(txHash);
} else {
return await this.blockchainLocal.fetchTransaction(txHash);
}
}
async fetchTransactions(address: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchTransactions(address);
} else {
return await this.blockchainLocal.fetchTransactions(address);
}
}
async broadcastTransaction(tx: Uint8Array | Transaction | string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.broadcastTransaction(tx);
} else {
return await this.blockchainLocal.broadcastTransaction(tx);
}
}
async subscribeAddress(_address: string, _callback: AddressCallback) {
throw new Error('Not yet implemented');
}
async unsubscribeAddress(_address: string, _callback: AddressCallback) {
throw new Error('Not yet implemented');
}
async subscribeTransaction(
_transactionHash: string,
_callback: TransactionCallback,
) {
throw new Error('Not yet implemented');
}
async unsubscribeTransaction(
_transactionHash: string,
_callback: TransactionCallback,
) {
throw new Error('Not yet implemented');
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './base-blockchain.js';
export * from './blockchain.js';
export * from './blockchain-electrum.js';
export * from './blockchain-local.js';
export * from './types.js';
+28
View File
@@ -0,0 +1,28 @@
import { Transaction, TransactionHash, UTXO, ExtMap } from '@xocash/primitives';
export type AddressCallback = (status: string | null) => void;
export type TransactionCallback = (height: number | null) => void;
export type BlockchainTransaction = {
hash: TransactionHash;
height: number;
transaction: Transaction;
// TODO: DSP
// Wait, why the fuck would I need a DSP for Transaction? Did I mean to do this on UTXO?
};
export type BlockchainTransactions = ExtMap<BlockchainTransaction>;
// TODO: You were considering using classes that extend INdexedCollection. E.g.
// class BlockchainUTXOs extends ExtMap<BlockchainUTXO>
// This probably isn't a bad idea:
// 1. You nest calculateBalanceSats and calculateBalanceTokens under it
// 2. You could also do sorting methods (more pertinent for BLockchainTransactions above)
export type BlockchainUTXO = {
height: number;
utxo: UTXO;
// TODO: DSP
};
// NOTE: To minimize refactors later, use this name elsewhere; not the `ExtMap<Type>` form.
export type BlockchainUTXOs = ExtMap<BlockchainUTXO>;
+5
View File
@@ -0,0 +1,5 @@
// Export modules that work across all platforms.
export * from './index.js';
// These exports will only work in a browser environment.
export * from './storage/store-indexeddb.js';
+6
View File
@@ -0,0 +1,6 @@
// Export modules that work across all platforms.
export * from './index.js';
// These exports will only work in a NodeJS (backend) environment.
// NOTE: We import and export each module with a common alias so that any leveraging libraries can use the common name and maintain cross-platform compatibility.
// export * from './storage/storage-local.node.js';
+8
View File
@@ -0,0 +1,8 @@
// Export all our primitives as a convenience.
export * from '@xocash/primitives';
// Export all of our stack-specific modules.
export * from './blockchain/index.js';
export * from './storage/index.js';
export * from './utils/index.js';
export * from './wallet/index.js';
+3
View File
@@ -0,0 +1,3 @@
// Export all of our stack-specific modules.
export * from './index.browser.js';
export * from './index.node.js';
+1
View File
@@ -0,0 +1 @@
Will be nice if we can make this adapter based as with other services.
+271
View File
@@ -0,0 +1,271 @@
import { Encryption, EventEmitter } from '../utils';
export type StorageOpts = {
syncInMemory?: boolean;
encryption?: Encryption;
};
export type StoreEvents = {
set: { key: string; value: any };
delete: { key: string };
clear: undefined;
};
export abstract class BaseStorage {
abstract listStores(): Promise<Array<string>>;
abstract createStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract getStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore | null>;
abstract createOrGetStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract deleteStore(storeName: string): Promise<void>;
abstract deleteDatabase(): Promise<void>;
}
/**
* Represents a wrapper object for accessing and manipulating a stored value.
*/
export type StoreValueProxy<T> = {
/**
* Gets the current value from the store
*/
get(): Promise<T | undefined>;
/**
* Gets the current value from the store or fails if undefined.
*/
getOrFail(): Promise<T>;
/**
* Sets a new value in the store
* @param newValue The value to store
*/
set(newValue: T): Promise<void>;
};
/**
* Abstract base class for implementing key-value storage with event emission capabilities.
* Extends EventEmitter to provide event handling for store operations.
*
* @example
* ```typescript
* class MyStore extends BaseStore {
* async keys(): Promise<Array<string>> {
* // Implementation
* }
*
* async get<T>(key: string): Promise<T | undefined> {
* // Implementation
* }
*
* async set<T>(key: string, value: T): Promise<void> {
* // Implementation
* }
*
* async delete(key: string): Promise<void> {
* // Implementation
* }
* }
* ```
*/
export abstract class BaseStore extends EventEmitter<StoreEvents> {
/**
* Returns an array of all keys in the store.
*/
abstract keys(): Promise<Array<string>>;
/**
* Retrieves a value from the store by its key.
*
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
abstract get<T>(key: string): Promise<T | undefined>;
/**
* Stores a value in the store with the specified key.
*
* @template T The type of the value to store
* @param key The key under which to store the value
* @param value The value to store
*/
abstract set<T>(key: string, value: T): Promise<void>;
/**
* Removes a value from the store by its key.
*
* @param key The key to remove
*/
abstract delete(key: string): Promise<void>;
/**
* Retrieves all key-value pairs from the store.
*
* @template T The type of the values in the store
*/
async all<T>(): Promise<{ [key: string]: T }> {
const keys = await this.keys();
const values = await Promise.all(keys.map((key) => this.get<T>(key)));
return keys.reduce(
(acc, key, index) => {
const value = values[index];
if (value !== undefined) {
acc[key] = value;
}
return acc;
},
{} as { [key: string]: T },
);
}
/**
* Removes all key-value pairs from the store.
*/
async clear(): Promise<void> {
const keys = await this.keys();
await Promise.all(keys.map((key) => this.delete(key)));
}
/**
* Checks if a key exists in the store.
*
* @param key The key to check
*/
async has(key: string): Promise<boolean> {
return (await this.get(key)) ? true : false;
}
/**
* Retrieves multiple values from the store by their keys.
* Filters out undefined values from the results.
*
* @template T The type of the values to retrieve
* @param keys Array of keys to retrieve
*/
async batchGet<T>(keys: Array<string>): Promise<Map<string, T>> {
const results = await Promise.all(
keys.map(async (key) => {
const value = await this.get(key);
return [key, value] as const;
}),
);
return new Map(
results.filter(([_, value]) => value !== undefined) as [string, T][],
);
}
/**
* Stores multiple key-value pairs in the store.
* Emits 'set' events for each key-value pair.
*
* @template T The type of the values to store
* @param items Map of key-value pairs to store
*/
async batchSet<T>(items: Map<string, T>): Promise<void> {
await Promise.all(
Array.from(items).map(([key, value]) => {
return this.set(key, value);
}),
);
items.forEach((value, key) => {
this.emit('set', { key, value });
});
}
/**
* Closes the store connection. Override this method in implementing classes if needed.
*/
async close(): Promise<void> {}
/**
* Retrieves a value from the store by its key, throwing an error if the value doesn't exist.
*
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
async getOrFail<T>(key: string): Promise<T> {
// Attempt to get the value from the store.
const value = await this.get<T>(key);
// If the value does not exist, throw an error.
if (typeof value === 'undefined') {
throw new Error(`Failed to get key "${key}"`);
}
// Return the value.
return value;
}
/**
* Retrieves a value from the store by its key, or sets it using the provided setter function if it doesn't exist.
*
* @template T The type of the value to retrieve or set
* @param key The key to lookup
* @param setter Function to generate the value if it doesn't exist
*/
async getOrSet<T>(key: string, setter: () => T | Promise<T>): Promise<T> {
// Attempt to get the value from the store.
let value = await this.get<T>(key);
// If it is undefined, use the setter to set it.
if (typeof value === 'undefined') {
value = await setter();
await this.set(key, value);
}
// Return the value.
return value;
}
/**
* Creates a wrapper object that provides convenient access to a stored value.
* The wrapper allows getting and setting the value using async property access.
*
* @template T The type of the value to wrap
* @param key The key under which the value is stored
* @returns An object with a getter and setter for the stored value
*
* @example
* ```typescript
* const numValue = store.proxy<number>('myNumber');
* const value = await numValue.get(); // gets the stored value
* await numValue.set(42); // sets the stored value
* ```
*/
proxy<T>(key: string): StoreValueProxy<T> {
const self = this;
return {
/**
* Gets the current value from the store
*/
get(): Promise<T | undefined> {
return self.get<T>(key);
},
/**
* Gets the current value from the store or fails if undefined
*/
getOrFail(): Promise<T> {
return self.getOrFail<T>(key);
},
/**
* Sets a new value in the store
*/
async set(newValue: T): Promise<void> {
await self.set(key, newValue);
},
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './base-store.js';
export * from './store-inmemory.js';
export * from './store-inmemory-synced.js';
@@ -0,0 +1,255 @@
import { BaseStorage, BaseStore, StorageOpts } from './base-store.js';
import { StoreInMemorySynced } from './store-inmemory-synced.js';
import {
Encryption,
} from '../utils';
import {
encodeExtendedJsonObject,
decodeExtendedJsonObject,
} from '@xocash/primitives';
import { openDB, deleteDB, IDBPDatabase } from 'idb';
export class StorageIndexedDB extends BaseStorage {
private constructor(
private db: IDBPDatabase,
private dbName: string,
) {
super();
}
public static async createOrOpen(dbName: string) {
try {
// Check if database exists by attempting to open it at current version
const existingDb = await openDB(dbName, undefined);
return new StorageIndexedDB(existingDb, dbName);
} catch (error) {
// If database doesn't exist, create it at version 1
const newDb = await openDB(dbName, 1, {
upgrade(_db) {
// Initial database creation - no stores needed by default
},
});
return new StorageIndexedDB(newDb, dbName);
}
}
async listStores() {
return Array.from(this.db.objectStoreNames);
}
async createStore(storeName: string, opts: StorageOpts = {}) {
if (!this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) {
db.createObjectStore(storeName);
},
});
this.db = db;
}
const store = await StoreIndexedDB.create(
this.db,
storeName,
opts.encryption,
);
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
}
async getStore(storeName: string, opts: StorageOpts = {}) {
try {
if (!this.db.objectStoreNames.contains(storeName)) {
return null;
}
const store = await StoreIndexedDB.create(
this.db,
storeName,
opts.encryption,
);
return opts.syncInMemory ? StoreInMemorySynced.create(store) : store;
} catch (error) {
return null;
}
}
async createOrGetStore(storeName: string, opts: StorageOpts = {}) {
const existing = await this.getStore(storeName, opts);
if (existing) {
return existing;
}
return this.createStore(storeName, opts);
}
async deleteStore(storeName: string) {
if (this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) {
db.deleteObjectStore(storeName);
},
});
this.db = db;
}
}
async deleteDatabase() {
this.db.close();
await deleteDB(this.dbName);
}
}
/**
* Implementation of BaseStore using IndexedDB as the storage backend.
*/
export class StoreIndexedDB extends BaseStore {
constructor(
public db: IDBPDatabase,
public storeName: string,
public encryption?: Encryption,
) {
super();
/*
this.broadcastChannel = new BroadcastChannel(
`StoreIndexedDB:${db.name}.${this.storeName}`,
);
this.broadcastChannel.onmessage = (message) => {
this.emit('set', message);
};
*/
}
/**
* Factory method to create a new instance of StoreIndexedDB.
* Provides a more convenient way to instantiate the store when working with promises.
*
* @param db - An instance of an upgraded and opened IndexedDB database
* @param storeName - The name of the object store within the database to use
*
* @example
* ```typescript
* const store = await StoreIndexedDB.create(db, 'myStore');
* ```
*/
static async create(
db: IDBPDatabase,
storeName: string,
encryption?: Encryption,
) {
return new this(db, storeName, encryption);
}
async all<T>() {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const keys = await store.getAllKeys();
const values = await store.getAll();
return keys.reduce(
(acc, key, index) => {
acc[key.toString()] = decodeExtendedJsonObject(values[index]);
return acc;
},
{} as { [key: string]: T },
);
}
async keys() {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const keys = await store.getAllKeys();
return keys.map((key) => key.toString());
}
async get<T>(key: string) {
const encodedValue = await this.db.get(this.storeName, key);
const decryptedValue = this.encryption
? this.encryption.decrypt(encodedValue)
: encodedValue;
return decodeExtendedJsonObject(decryptedValue) as T;
}
async set<T>(key: string, value: T) {
const encodedValue = encodeExtendedJsonObject(value);
const encryptedValue = this.encryption
? this.encryption.encrypt(encodedValue)
: encodedValue;
await this.db.put(this.storeName, encryptedValue, key);
this.emit('set', { key, value });
}
async delete(key: string) {
await this.db.delete(this.storeName, key);
this.emit('delete', { key });
}
async batchGet<T>(keys: string[]): Promise<Map<string, T>> {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const results = await Promise.all(
keys.map(async (key) => {
const value = await store.get(key);
return [
key,
value ? decodeExtendedJsonObject(value) : undefined,
] as const;
}),
);
await tx.done;
return new Map(
results.filter(([_, value]) => value !== undefined) as [string, T][],
);
}
async batchSet<T>(items: Map<string, T>) {
const tx = this.db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
await Promise.all(
Array.from(items).map(([key, value]) => {
const encodedValue = encodeExtendedJsonObject(value);
return store.put(encodedValue, key);
}),
);
await tx.done;
items.forEach((value, key) => {
this.emit('set', { key, value });
});
}
async clear() {
await this.db.clear(this.storeName);
this.emit('clear', undefined);
}
async has(key: string) {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const result = await store.getKey(key);
return result !== undefined;
}
async close() {
this.db.close();
}
}
@@ -0,0 +1,82 @@
import { BaseStore } from './base-store.js';
import { StoreInMemory } from './store-inmemory.js';
/**
* Storage Adapter that takes another Storage Adapter (e.g. IndexedDB) and "syncs" its contents to system memory for faster read access.
*
* All read operations will use system memory - all write operations will use the provided adapter.
*/
export class StoreInMemorySynced extends BaseStore {
constructor(
private inMemoryCache: StoreInMemory,
private store: BaseStore,
) {
super();
// Hook into all write operations so that we can sync the In-Memory cache.
this.store.on('set', async (payload) => {
await this.inMemoryCache.set(payload.key, payload.value);
this.emit('set', { key: payload.key, value: payload.value });
});
this.store.on('delete', async (payload) => {
await this.inMemoryCache.delete(payload.key);
this.emit('delete', { key: payload.key });
});
this.store.on('clear', async () => {
await this.inMemoryCache.clear();
this.emit('clear', undefined);
});
}
static async create(store: BaseStore) {
// Instantiate in-memory cache and IndexedDB.
const inMemoryCache = new StoreInMemory();
// Create instance of this store.
const memorySyncedStore = new this(inMemoryCache, store);
// Sync the data from IndexedDB into the In-Memory cache.
const indexedDBEntries = await store.all();
for (let [key, value] of Object.entries(indexedDBEntries)) {
inMemoryCache.set(key, value);
}
// Return our instance of this store.
return memorySyncedStore;
}
async all<T>() {
return await this.inMemoryCache.all<T>();
}
async keys() {
return await this.inMemoryCache.keys();
}
async get<T>(key: string) {
// TODO: Consider whether you want to structuredClone this to prevent mutation.
return await this.inMemoryCache.get<T>(key);
}
async set<T>(key: string, value: T) {
await this.store.set(key, value);
}
async delete(key: string) {
await this.store.delete(key);
}
async clear() {
await this.store.clear();
}
async has(key: string) {
return await this.inMemoryCache.has(key);
}
async close() {
await this.store.close();
}
}
@@ -0,0 +1,52 @@
import { BaseStore } from './base-store.js';
/**
* Implementation of BaseStore using Memory as the storage backend.
*
* @remarks
* This implementation can be used testing and caching of expensive operations.
*/
export class StoreInMemory extends BaseStore {
private store: Map<string, any>;
constructor() {
super();
this.store = new Map();
}
async all<T>() {
const result: { [key: string]: T } = {};
for (const [key, value] of this.store.entries()) {
result[key] = value as T;
}
return result;
}
async keys() {
return Array.from(this.store.keys());
}
async get<T>(key: string) {
return this.store.get(key) as T;
}
async set<T>(key: string, value: T) {
this.store.set(key, value);
this.emit('set', { key, value });
}
async delete(key: string) {
this.store.delete(key);
this.emit('delete', { key });
}
async clear() {
this.store.clear();
this.emit('clear', undefined);
}
async has(key: string) {
return this.store.has(key);
}
}
+122
View File
@@ -0,0 +1,122 @@
// TODO: Consider whether we should shuffle this to Stack Util (as opposed to Primitive).
// It is async which breaks from our Primitive patterns.
import { utf8ToBin } from '@bitauth/libauth';
// NOTE: We need to provide a default salt otherwise we would be deriving a key per each operation.
// This would be far too computationally expensive.
// TODO: Allow the salt to be customized in an options argument to the constructor.
export const DEFAULT_XO_SALT =
'cash.xo.default_salt_for_encryption_aabbccddeeff00112233445566778899';
export type EncryptionOpts = {
algorithm: string;
keyLength: number;
salt: Uint8Array;
ivLength: number;
iterations: number;
};
export class Encryption {
constructor(
private key: CryptoKey,
public opts: EncryptionOpts,
) {}
static async fromBytes(key: Uint8Array, opts: Partial<EncryptionOpts> = {}) {
// Merge the provided options into our default options.
const encryptionOpts = {
algorithm: 'AES-GCM',
keyLength: 256,
salt: utf8ToBin(DEFAULT_XO_SALT),
ivLength: 16,
iterations: 100_000,
...opts,
};
// Create a key derivation key
const keyMaterialBase = await crypto.subtle.importKey(
'raw',
key,
'PBKDF2',
false,
['deriveBits', 'deriveKey'],
);
// Derive the actual encryption key
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encryptionOpts.salt,
iterations: encryptionOpts.iterations,
hash: 'SHA-256',
},
keyMaterialBase,
{
name: encryptionOpts.algorithm,
length: encryptionOpts.keyLength,
},
false,
['encrypt', 'decrypt'],
);
return new Encryption(derivedKey, encryptionOpts);
}
static async fromPassphrase(
passphrase: string,
opts: Partial<EncryptionOpts> = {},
) {
const key = utf8ToBin(passphrase);
return this.fromBytes(key, opts);
}
async encrypt(payload: Uint8Array | string): Promise<Uint8Array> {
// Convert string payload to Uint8Array if needed
const data = typeof payload === 'string' ? utf8ToBin(payload) : payload;
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(this.opts.ivLength));
// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{
name: this.opts.algorithm,
iv,
},
this.key,
data,
);
// Combine IV and encrypted data
const result = new Uint8Array(iv.length + encryptedData.byteLength);
result.set(iv);
result.set(new Uint8Array(encryptedData), iv.length);
return result;
}
async decrypt(payload: Uint8Array | string): Promise<Uint8Array> {
// Ensure payload is Uint8Array
if (typeof payload === 'string') {
throw new Error('Encrypted payload must be Uint8Array');
}
// Extract IV and encrypted data
const iv = payload.slice(0, this.opts.ivLength);
const encryptedData = payload.slice(this.opts.ivLength);
// Decrypt the data
const decryptedData = await crypto.subtle.decrypt(
{
name: this.opts.algorithm,
iv,
},
this.key,
encryptedData,
);
return new Uint8Array(decryptedData);
}
}
+146
View File
@@ -0,0 +1,146 @@
// TODO: You'll probably want to use WeakRef's here.
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: T) => void;
interface ListenerEntry<T> {
listener: Listener<T>;
wrappedListener: Listener<T>;
debounceTime?: number;
once?: boolean;
}
export class EventEmitter<T extends EventMap> {
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
on<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
debounceMilliseconds?: number,
): Listener<T[K]> {
const wrappedListener =
debounceMilliseconds && debounceMilliseconds > 0
? this.debounce(listener, debounceMilliseconds)
: listener;
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
debounceTime: debounceMilliseconds,
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
return listener;
}
once<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
debounceMilliseconds?: number,
): Listener<T[K]> {
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
this.off(type, listener);
listener(detail);
};
const debouncedListener =
debounceMilliseconds && debounceMilliseconds > 0
? this.debounce(wrappedListener, debounceMilliseconds)
: wrappedListener;
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener: debouncedListener,
debounceTime: debounceMilliseconds,
once: true,
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
return listener;
}
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
const listeners = this.listeners.get(type);
if (!listeners) return;
const listenerEntry = Array.from(listeners).find(
(entry) =>
entry.listener === listener || entry.wrappedListener === listener,
);
if (listenerEntry) {
listeners.delete(listenerEntry);
}
}
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
const listeners = this.listeners.get(type);
if (!listeners) return false;
listeners.forEach((entry) => {
entry.wrappedListener(payload);
});
return listeners.size > 0;
}
removeAllListeners(): void {
this.listeners.clear();
}
async waitFor<K extends keyof T>(
type: K,
predicate: (payload: T[K]) => boolean,
timeoutMs?: number,
): Promise<T[K]> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const listener = (payload: T[K]) => {
if (predicate(payload)) {
// Clean up
this.off(type, listener);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
resolve(payload);
}
};
// Set up timeout if specified
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener);
reject(new Error(`Timeout waiting for event "${String(type)}"`));
}, timeoutMs);
}
this.on(type, listener);
});
}
private debounce<K extends keyof T>(
func: Listener<T[K]>,
wait: number,
): Listener<T[K]> {
let timeout: ReturnType<typeof setTimeout>;
return (detail: T[K]) => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
}
+20
View File
@@ -0,0 +1,20 @@
export class ExtError extends Error {
constructor(
message: string,
public readonly details?: string,
) {
super(message);
// Capture the stack trace (V8 engines only)
// This step ensures the stack trace is captured correctly
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ExtError);
} else {
this.stack = new Error().stack;
console.error(this.stack);
}
// 👇️ because we are extending a built-in class
Object.setPrototypeOf(this, ExtError.prototype);
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './encryption.js';
export * from './event-emitter.js';
export * from './ext-error.js';
export * from './memoizer.js';
export * from './misc.js';
export * from './uuid.js';
+55
View File
@@ -0,0 +1,55 @@
// TODO: We may want to move this to @xocash/primitives.
export class Memoizer {
private cache = new Map<string, any>();
/**
* Gets a cached value for the given key, computing it if it doesn't exist.
*
* @param key - The key to look up or store the value under
* @param compute - Function to compute the value if it's not cached
* @returns The cached or computed value
*/
get<K extends string, V>(key: K, compute: () => V): V {
if (!this.cache.has(key)) {
this.cache.set(key, compute());
}
return this.cache.get(key) as V;
}
/**
* Checks if a value exists for the given key.
*
* @param key - The key to check
* @returns Whether the key exists in the cache
*/
has(key: string): boolean {
return this.cache.has(key);
}
/**
* Deletes a cached value.
*
* @param key - The key to delete
* @returns Whether a value was deleted
*/
delete(key: string): boolean {
return this.cache.delete(key);
}
/**
* Clears all cached values.
*/
clear(): void {
this.cache.clear();
}
/**
* Gets all cached keys.
*
* @returns Array of cached keys
*/
keys(): string[] {
return Array.from(this.cache.keys());
}
}
+17
View File
@@ -0,0 +1,17 @@
import { binToHex } from '@bitauth/libauth';
// TODO: Put unsorted stuff in here and then try to sort them somewhere more sensible later.
export function getOutpointId(outpoint: {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
}): string {
const outpointHashHex = binToHex(outpoint.outpointTransactionHash);
return `${outpointHashHex}:${outpoint.outpointIndex}`;
}
// TODO: Jason uses form timestampMs_entityId
export function generateActivityId() {
return Date.now().toString();
}
+205
View File
@@ -0,0 +1,205 @@
// TODO: This probably needs some work.
// The reason this exists is that Browser's native EventSource is rubbish:
// 1. It does not allow passing of headers.
// 2. It is browser-only (this implementation aims to work for Node also)
// 3. It does not handle disconnects/reconnects gracefully
// 4. It does not handle last-event-id (important for history).
// 5. It does not allow a custom fetch function (probably not necessary, but there's a reason I'd rather support this which I'll explain some other time).
// This might eventually be shifted out of XO/stack package and into its own.
// Q: "Why not just leverage an existing package?"
// A: They're all bad - the "go to" is a Microsoft (@azure/fetch-event-soruce) that is no longer maintained.
// It does not support NodeJS (or Workers) and we cannot depend upon forks of it.
export interface SSEOptions {
fetch?: typeof fetch;
headers?: Record<string, string>;
onMessage?: (event: SSEMessageEvent) => void;
onError?: (error: Error) => void;
onOpen?: () => void;
onClose?: () => void;
maxRetries?: number;
retryDelay?: number;
}
export interface SSEMessageEvent {
data: string;
type: string;
id: string | null;
lastEventId: string;
origin: string;
}
export interface SSEEvent {
data: string;
event: string;
id: string | null;
}
export class SSEClient {
private url: string;
private options: Required<Omit<SSEOptions, 'headers'>> & {
headers: Record<string, string>;
};
private abortController: AbortController | null;
private retryCount: number;
private lastEventId: string;
private connected: boolean;
constructor(url: string, options: SSEOptions = {}) {
this.url = url;
this.options = {
fetch: globalThis.fetch,
headers: {},
onMessage: () => {},
onError: () => {},
onOpen: () => {},
onClose: () => {},
maxRetries: 5,
retryDelay: 3000,
...options,
};
this.abortController = null;
this.retryCount = 0;
this.lastEventId = '';
this.connected = false;
}
public async connect(): Promise<void> {
if (this.connected) {
return;
}
this.abortController = new AbortController();
try {
const response = await this.options.fetch(this.url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
'Last-Event-ID': this.lastEventId,
...this.options.headers,
},
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('ReadableStream not supported');
}
this.connected = true;
this.options.onOpen();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
this.handleDisconnect();
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r\n|\r|\n/);
buffer = this.processEventStream(lines);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.options.onError(
error instanceof Error ? error : new Error(String(error)),
);
this.handleDisconnect();
if (this.retryCount < this.options.maxRetries) {
this.retryCount++;
setTimeout(() => this.connect(), this.options.retryDelay);
}
}
}
private processEventStream(lines: string[]): string {
let event: SSEEvent = {
data: '',
event: 'message',
id: null,
};
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.length === 0) {
// Empty line means dispatch the event
if (event.data.length > 0) {
this.dispatchEvent(event);
event = {
data: '',
event: 'message',
id: null,
};
}
continue;
}
const [fieldName, ...fieldValue] = line.split(':');
const value = fieldValue.join(':').trim();
switch (fieldName) {
case 'event':
event.event = value;
break;
case 'data':
event.data += value + '\n';
break;
case 'id':
event.id = value;
this.lastEventId = value;
break;
case 'retry': {
const retryInt = parseInt(value, 10);
if (!isNaN(retryInt)) {
this.options.retryDelay = retryInt;
}
break;
}
}
}
// Return the last incomplete line as the new buffer
return lines[lines.length - 1];
}
private dispatchEvent(event: SSEEvent): void {
const messageEvent: SSEMessageEvent = {
data: event.data.slice(0, -1), // Remove trailing newline
type: event.event,
id: event.id,
lastEventId: this.lastEventId,
origin: this.url,
};
this.options.onMessage(messageEvent);
}
private handleDisconnect(): void {
this.connected = false;
this.options.onClose();
}
public close(): void {
if (this.abortController) {
this.abortController.abort();
this.handleDisconnect();
}
}
}
+31
View File
@@ -0,0 +1,31 @@
export function generateUUIDV7(customTimestamp?: bigint) {
// Step 1: Get the current timestamp in milliseconds
const timestamp = customTimestamp ?? BigInt(Date.now()); // Use BigInt to handle 48-bit values
// Step 2: Convert the timestamp to a 48-bit hexadecimal string
const timestampHex = timestamp.toString(16).padStart(12, '0'); // Ensure 12 hex characters
// Step 3: Generate 74 bits (19 hex characters) of random data
const randomHex = crypto
.getRandomValues(new Uint8Array(10))
.map((b) => Number(b.toString(16).padStart(2, '0')))
.join(''); // 10 bytes = 20 hex characters
// Step 4: Combine fields to form the UUID
const version = '7'; // Version 7 (0111)
const variant = '8'; // Variant 1 (10xx) is "8", "9", "a", or "b"
return (
`${timestampHex.substring(0, 8)}-${timestampHex.substring(8, 12)}` + // Timestamp (48 bits)
`-${version}${randomHex.substring(0, 3)}` + // Version (4 bits) + 12 random bits
`-${variant}${randomHex.substring(3, 6)}` + // Variant (2 bits) + 14 random bits
`-${randomHex.substring(6)}` // Remaining 48 random bits
);
}
/**
* Extract the timestamp from a UUID v7
*/
export function getTimestampFromUUID(uuid: string): bigint {
return BigInt(parseInt(uuid.slice(0, 8), 16));
}
+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');
}
}