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