Monorepo stuff

Signed-off-by: James Zuccon <zuccon@gmail.com>
This commit is contained in:
James Zuccon
2024-09-02 20:05:35 +10:00
parent f2210e631a
commit 2ac7af4069
62 changed files with 13227 additions and 149 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './utils/index.js';
export * from './wallet/index.js';
@@ -0,0 +1,61 @@
export class EventEmitter<
T extends Record<string, unknown>
> extends EventTarget {
private listeners: Map<keyof T, Set<EventListener>> = new Map();
constructor() {
super();
}
on<K extends keyof T>(type: K, listener: (detail: T[K]) => void) {
const wrappedListener = ((e: CustomEvent<T[K]>) =>
listener(e.detail)) as EventListener;
this.addEventListener(type as string, wrappedListener);
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type)?.add(wrappedListener);
return wrappedListener;
}
off<K extends keyof T>(type: K, listener: EventListener) {
this.removeEventListener(type as string, listener);
this.listeners.get(type)?.delete(listener);
}
emit<K extends keyof T>(type: K, detail: T[K]) {
const event = new CustomEvent(type as string, { detail });
return this.dispatchEvent(event);
}
removeAllListeners() {
this.listeners.forEach((listeners, type) => {
listeners.forEach((listener) => {
this.removeEventListener(type as string, listener);
});
});
this.listeners.clear();
}
monitorProperty<ObjectType, Property extends keyof ObjectType>(
target: ObjectType,
key: Property,
eventName: keyof T
) {
let value = target[key];
Object.defineProperty(target, key, {
get: () => {
return value;
},
set: (newValue) => {
value = newValue;
this.emit(eventName as string, newValue);
},
enumerable: true,
configurable: true,
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from './event-emitter.js';
@@ -0,0 +1,131 @@
// TODO: I think @monsterbitar (Jonathan) is working on something like this too.
// So I haven't bothered making this complete. We'll just use his once it's done.
// Or, if he's busy with other stuff, we can complete this and create an MR into Electrum-Cash.
import type { RequestResponse } from '@electrum-cash/network';
// TODO: This should be imported from electrum-cash library.
// But it is not exported yet.
export type RPCParameter = string | number | boolean | null;
export interface ElectrumRequest {
method: string;
params: Array<RPCParameter>;
response: RequestResponse;
}
export interface AddressGetBalance extends ElectrumRequest {
method: 'blockchain.address.get_balance';
params: [string];
response: {
confirmed: number;
unconfirmed: number;
};
}
export interface AddressGetHistory extends ElectrumRequest {
method: 'blockchain.address.get_history';
// TODO: RPCParameter accepts a null type, but this should probably be changed to undefined instead.
params: [string, number | null, number | null];
response: Array<{
height: number;
tx_hash: string;
fee?: number;
}>;
}
export interface AddressGetMempool extends ElectrumRequest {
method: 'blockchain.address.get_mempool';
params: [string];
response: Array<{
height: number;
tx_hash: string;
fee?: number;
}>;
}
export interface AddressGetScriptHash extends ElectrumRequest {
method: 'blockchain.address.get_scripthash';
params: [string];
response: string;
}
export interface AddressListUnspent extends ElectrumRequest {
method: 'blockchain.address.listunspent';
params: [string, 'include_tokens' | 'exclude_tokens' | 'tokens_only'];
response: Array<{
height: number;
token_data?: {
amount: string;
category: string;
nft?: {
capability: 'none' | 'mutable' | 'minting';
commitment: string;
};
};
tx_hash: string;
tx_pos: number;
value: number;
}>;
}
export interface AddressSubscribe extends ElectrumRequest {
method: 'blockchain.address.subscribe';
params: [string];
// [Address, Status]
response: [string, string | null];
}
export interface AddressUnsubscribe extends ElectrumRequest {
method: 'blockchain.address.unsubscribe';
params: [string];
response: boolean;
}
export interface ScriptHashGetBalance extends ElectrumRequest {
method: 'blockchain.scripthash.get_balance';
params: [string];
response: {
confirmed: number;
unconfirmed: number;
};
}
export interface TransactionBroadcast extends ElectrumRequest {
method: 'blockchain.transaction.broadcast';
params: [string];
response: string;
}
export interface TransactionGet extends ElectrumRequest {
method: 'blockchain.transaction.get';
params: [string, boolean];
response: string;
}
export interface TransactionGetVerbose extends ElectrumRequest {
method: 'blockchain.transaction.get';
params: [string, true];
response: {
// TODO
};
}
export interface TransactionSubscribe extends ElectrumRequest {
method: 'blockchain.transaction.subscribe';
params: [string];
response: number | null;
}
export interface TransactionUnsubscribe extends ElectrumRequest {
method: 'blockchain.transaction.unsubscribe';
params: [string];
response: boolean;
}
export interface AddressNotification {
jsonrpc: '2.0';
method: 'blockchain.address.subscribe';
// [Address, Status]
params: [string, string | null];
}
+122
View File
@@ -0,0 +1,122 @@
import { AddressNotification, ElectrumRequest } from './blockchain-types.js';
import { ElectrumClient } from '@electrum-cash/network';
export type AddressCallback = (status: string | null) => void;
// TODO: Rename this to Blockcain Service and refactor such that it can store local state.
// For local state, we'll want to track a tree of dependencies locally.
// Might be able to copy whatever structure a proper node uses (DAG?).
export class Blockchain {
electrumClient!: ElectrumClient;
// Address subscriptions.
addressSubscriptions: { [address: string]: AddressCallback } = {};
constructor(
public readonly servers: string[],
public readonly application = 'BCHApp',
public readonly version = '1.5'
) {}
async start(): Promise<void> {
// 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.servers) {
// Initialize an Electrum client using the given server.
const electrum = new ElectrumClient(
this.application,
this.version,
electrumServer,
50004,
'wss'
);
// Attempt to connect to this Electrum Server.
try {
await electrum.connect();
} 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.
this.electrumClient.on('notification', this.onNotification.bind(this));
// 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 request<T extends ElectrumRequest>(
endpoint: T['method'],
...params: T['params']
): Promise<T['response']> {
const response = await this.electrumClient.request(endpoint, ...params);
if (response instanceof Error) {
throw response;
}
return response;
}
async subscribeAddress(address: string, callback: AddressCallback) {
await this.electrumClient.subscribe(
'blockchain.address.subscribe',
address
);
this.addressSubscriptions[address] = callback;
}
async unsubscribeAddress(address: string, _callback: AddressCallback) {
if (!this.addressSubscriptions[address]) {
return;
}
delete this.addressSubscriptions[address];
try {
await this.electrumClient.unsubscribe(
'blockchain.address.unsubscribe',
address
);
} catch (error) {
console.warn(`${error}`);
}
}
onNotification(data: AddressNotification) {
// Handle Address notification.
if (data.method === 'blockchain.address.subscribe') {
const address = data.params[0];
const status = data.params[1];
const subscription = this.addressSubscriptions[address];
if (!subscription) {
console.warn(
`Notification for address ${address} subscribed to, but has no handler`
);
return;
}
// Trigger the subscription's callback.
subscription(status);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Blockchain } from './blockchain.js';
export type ServiceConfig<BlockchainType, WalletType> = {
createBlockchain: () => BlockchainType;
createWallet: (
privateKeyBytes: Uint8Array,
electrum: Blockchain
) => WalletType;
};
+5
View File
@@ -0,0 +1,5 @@
export * from './blockchain.js';
export * from './blockchain-types.js';
export * from './storage.js';
export * from './wallet-hd.js';
export * from './wallet-p2pkh.js';
+14
View File
@@ -0,0 +1,14 @@
/**
* TODO
*
* @remarks
* Storage should support Adapters.
* We don't want to extend this - we want to compose it.
* Reason being that we want to support multiple storage adapters.
* And we should allow the possibility of having non-critical storage adapters fail (e.g. remote vs local).
*/
export class Storage {
constructor() {
console.log('TODO');
}
}
+52
View File
@@ -0,0 +1,52 @@
import { type AddressListUnspent } from './blockchain-types.js';
import { Blockchain } from './blockchain.js';
import { PrivateKey, Transaction } from '@xo/primitives';
import {
type CompilerBCH,
type CompilationData,
type InputTemplate,
} from '@bitauth/libauth';
export type InputTemplateBCH = InputTemplate<
CompilerBCH,
false,
CompilationData
>;
export abstract class WalletBase extends PrivateKey {
// Dependencies/Services
public electrum: Blockchain;
constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) {
super(privateKeyBytes);
this.electrum = electrum;
}
abstract startMonitoring(): Promise<void>;
abstract stopMonitoring(): Promise<void>;
abstract getAddress(): string;
abstract fetchHistory(): Promise<Array<Transaction>>;
abstract fetchUnspentOutputs(): Promise<AddressListUnspent['response']>;
abstract getUnspentDirectives(): Promise<Array<InputTemplateBCH>>;
async refresh(): Promise<void> {
await Promise.all([this.fetchUnspentOutputs(), this.fetchHistory()]);
}
async onAddressNotification(status: string | null) {
// If status is null, it simply means that our subscribe call to Electrum was successful.
if (!status) {
return;
}
// Refresh our wallet's state.
await this.refresh();
}
}
+251
View File
@@ -0,0 +1,251 @@
// Import the Stamp class to derive the private keys
import { HDPrivateNode } from '@xo/primitives';
import { EventEmitter } from '../utils/index.js';
import { Blockchain } from './blockchain.js';
import {
WalletP2PKH,
WalletP2PKHFactory,
useWalletP2PKH,
} from './wallet-p2pkh.js';
import {
type HdPrivateNodeValid,
deriveSeedFromBip39Mnemonic,
encodeTransaction,
generateTransaction,
getMinimumFee,
} from '@bitauth/libauth';
export const DERIVATION_PATH = "m/44'/145'/0'";
export const ADDRESS_GAP = 20;
export type WalletHDEvents<WalletType> = {
shouldMonitorUpdated: boolean;
walletsUpdated: Array<WalletType>;
};
export class WalletHD<
WalletType extends WalletP2PKH = WalletP2PKH
> extends HDPrivateNode {
// Events.
public events: EventEmitter<WalletHDEvents<WalletType>> = new EventEmitter();
// Mutable State.
public shouldMonitor = false;
public wallets: Array<WalletType> = [];
constructor(
public node: HdPrivateNodeValid,
public electrum: Blockchain,
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH
) {
// Instantiate parent class.
super(node);
// Monitor the following properties and emit an event when they change.
this.events.monitorProperty(this, 'shouldMonitor', 'shouldMonitorUpdated');
this.events.monitorProperty(this, 'wallets', 'walletsUpdated');
}
static fromMnemonic<T extends WalletHD<any>>(
this: WalletHDConstructor<T>,
mnemonic: string,
electrum: Blockchain,
walletFactory?: WalletP2PKHFactory<any>
): T {
// Derive the seed from the mnemonic.
const seed = deriveSeedFromBip39Mnemonic(mnemonic);
// Derive node from mnemonic.
const hdPrivateNode = HDPrivateNode.fromSeed(seed);
// Return a new instance of this class.
return new this(hdPrivateNode.node, electrum, walletFactory);
}
async destroy() {
this.events.removeAllListeners();
}
async setQuantity(quantity: number) {
// Clear existing nodes.
this.wallets = this.deriveWallets(quantity);
}
async startMonitoring() {
this.shouldMonitor = true;
await Promise.all(this.wallets.map((wallet) => wallet.startMonitoring()));
}
async stopMonitoring() {
this.shouldMonitor = false;
await Promise.all(this.wallets.map((wallet) => wallet.stopMonitoring()));
}
deriveWallets(count: number, startIndex = 0) {
// Create an array to store our wallets.
const wallets: Array<WalletType> = [];
// Create the given count of wallets.
for (let i = 0; i < count; i++) {
const childNode = this.derivePath(
`${DERIVATION_PATH}/0/${startIndex + i}`
);
const wallet = this.walletFactory(
childNode.privateKey().toBytes(),
this.electrum
);
if (this.shouldMonitor) {
wallet.startMonitoring();
}
wallets.push(wallet);
}
this.wallets = wallets;
// Return the wallets.
return wallets;
}
async scan(startIndex = 0, addressGap = 20) {
// Array to store active wallet nodes
const nodes: 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> = this.deriveWallets(
addressGap,
currentIndex
);
// Get the transaction history of each wallet in the batch
const results = await Promise.all(
batch.map((wallet) => wallet.fetchHistory())
);
// Process the results
for (let i = 0; i < results.length; i++) {
if (results[i].length > 0) {
// This address has transactions, add it to nodes and reset empty address count
nodes.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.wallets = nodes;
return nodes;
}
async refreshChildNodes() {
await Promise.all(this.wallets.map((node) => node.refresh()));
}
async sweep(payoutBytecode: Uint8Array) {
// Get a list of inputs belonging to each wallet.
const inputs = await Promise.all(
this.wallets.map((wallet) => wallet.getUnspentDirectives())
);
// Flatten the inputs.
const inputsFlattened = inputs.flat();
// Calculate the total sats available in our inputs.
const totalSats = inputsFlattened.reduce((total, input) => {
if (input.unlockingBytecode instanceof Uint8Array) {
return (total += 0n);
}
return (total += input.unlockingBytecode.valueSatoshis);
}, 0n);
/*
// TODO: Determine the amounts and categories of any tokens in the inputs.
const tokens = inputsFlattened.reduce((tokens, input) => {
// Improve legibility by getting the token.
const token = input.unlockingBytecode.token;
if(!token) {
return tokens;
}
tokens[token.
return tokens;
}, {});
*/
// TODO: Add an output for each token.
// We need to calculate the number of bytes so that we can calculate the fee.
// So we loop twice and store the final transaction here each time.
// 1st time will have zero fee. 2nd time will accommodate the fee.
let encodedTransaction = new Uint8Array();
// Create the transaction by looping twice.
// 1st loop: Transaction without a fee.
// 2nd loop: Accommodate the fee.
for (let i = 0; i < 2; i++) {
// Get the fee using 1000 sats/KB.
const feeSats = getMinimumFee(BigInt(encodedTransaction.length), 1000n);
// Attempt to generate the transaction.
const generatedTransaction = generateTransaction({
version: 2,
locktime: 0,
inputs: inputsFlattened,
outputs: [
{
lockingBytecode: payoutBytecode,
valueSatoshis: totalSats - feeSats,
},
],
});
if (!generatedTransaction.success) {
console.error(generatedTransaction.errors);
throw new Error('Failed to generate transaction');
}
// Encode the transaction for broadcasting (and fee estimation).
encodedTransaction = encodeTransaction(generatedTransaction.transaction);
}
return encodedTransaction;
}
}
//-----------------------------------------------------------------------------
// Factories Types/Functions
//-----------------------------------------------------------------------------
export type WalletHDParams = ConstructorParameters<typeof WalletHD>;
export type WalletHDConstructor<T> = new (...args: WalletHDParams) => T;
export type WalletHDFactory<T extends WalletHD = WalletHD> = (
...args: WalletHDParams
) => T;
export function useWalletHD<T extends WalletHD = WalletHD>(
...args: WalletHDParams
): T {
return new WalletHD(...args) as T;
}
+183
View File
@@ -0,0 +1,183 @@
import { Transaction } from '@xo/primitives';
import { EventEmitter } from '../utils/index.js';
import {
type AddressListUnspent,
type AddressGetHistory,
type TransactionGet,
} from './blockchain-types.js';
import { Blockchain } from './blockchain.js';
import { WalletBase } from './wallet-base.js';
import {
hexToBin,
walletTemplateP2pkhNonHd,
walletTemplateToCompilerBCH,
} from '@bitauth/libauth';
export type WalletP2PKHEvents = {
transactionsUpdated: Array<Transaction>;
unspentsUpdated: AddressListUnspent['response'];
};
/**
* @example
* ```ts
* // Instantiate P2PKH Wallet.
* const walletP2PKH = WalletP2PKH.fromWIF('someWif');
*
* // Start monitoring the balance.
* await walletP2PKH.startMonitoring();
* ```
*/
export class WalletP2PKH extends WalletBase {
// Events.
public events: EventEmitter<WalletP2PKHEvents> = new EventEmitter();
// State.
public transactions: Array<Transaction> = [];
public unspents: AddressListUnspent['response'] = [];
constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) {
super(privateKeyBytes, electrum);
// Monitor the following properties and emit an event when they change.
this.events.monitorProperty(this, 'transactions', 'transactionsUpdated');
this.events.monitorProperty(this, 'unspents', 'unspentsUpdated');
}
async destroy() {
this.events.removeAllListeners();
}
async startMonitoring() {
await this.electrum.subscribeAddress(
this.getAddress(),
this.onAddressNotification.bind(this)
);
}
async stopMonitoring() {
await this.electrum.unsubscribeAddress(
this.getAddress(),
this.onAddressNotification.bind(this)
);
}
getAddress() {
return this.derivePublicKey().deriveAddress().toCashAddr();
}
async fetchHistory() {
// Get this node's address.
const address = this.getAddress();
// Get the history of the address from electrum
const history = await this.electrum.request<AddressGetHistory>(
'blockchain.address.get_history',
address,
0,
-1
);
// Get the transactions for each history item.
const transactions = await Promise.all(
history.map((item) =>
this.electrum.request<TransactionGet>(
'blockchain.transaction.get',
item.tx_hash,
false
)
)
);
// Decode the transactions.
const decodedTransactions = transactions.map((tx) =>
Transaction.fromHex(tx)
);
// Store the transactions.
this.transactions = decodedTransactions;
return decodedTransactions;
}
async fetchUnspentOutputs() {
const address = this.getAddress();
const unspentOutputs = await this.electrum.request<AddressListUnspent>(
'blockchain.address.listunspent',
address,
'include_tokens'
);
// Emit event with unspent outputs.
this.unspents = unspentOutputs;
return unspentOutputs;
}
async getUnspentDirectives() {
// Get a list of unspent outputs.
const unspentOutputs = await this.fetchUnspentOutputs();
// Create our P2PKH Compiler.
const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd);
// Transform our inputs into unlocking directives.
const inputDirectives = unspentOutputs.map((unspent) => ({
outpointIndex: unspent.tx_pos,
outpointTransactionHash: hexToBin(unspent.tx_hash),
sequenceNumber: 0,
unlockingBytecode: {
compiler: compilerP2PKH,
data: {
keys: { privateKeys: { key: this.toBytes() } },
},
script: 'unlock',
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,
valueSatoshis: BigInt(unspent.value),
},
}));
// Return the directives.
return inputDirectives;
}
async refresh(): Promise<void> {
await Promise.all([this.fetchUnspentOutputs(), this.fetchHistory()]);
}
async onAddressNotification(status: string | null) {
// If status is null, it simply means that our subscribe call to Electrum was successful.
if (!status) {
return;
}
// Refresh our wallet's state.
await this.refresh();
}
}
export type WalletP2PKHFactory<T extends WalletP2PKH = WalletP2PKH> = (
privateKeyBytes: Uint8Array,
electrum: Blockchain
) => T;
export function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
privateKeyBytes: Uint8Array,
electrum: Blockchain
): T {
return new WalletP2PKH(privateKeyBytes, electrum) as T;
}