WIP (BlockchainLocal working)

Signed-off-by: James Zuccon <zuccon@gmail.com>
This commit is contained in:
James Zuccon
2024-09-12 21:33:11 +10:00
parent 52da32ad4d
commit e308206d9f
33 changed files with 1274 additions and 397 deletions
+54
View File
@@ -0,0 +1,54 @@
import { Transaction } from '@xo/primitives';
import { BlockchainLocal, WalletP2PKH } from '@xo/wallet';
const blockchainLocal = new BlockchainLocal();
await blockchainLocal.start();
const walletP2PKH = WalletP2PKH.generateRandom(blockchainLocal);
const walletP2PKHReceive = WalletP2PKH.generateRandom(blockchainLocal);
const coinbaseTransaction = new Transaction({
version: 2,
locktime: 0,
inputs: [
{
outpointIndex: 0,
outpointTransactionHash: new Uint8Array(),
unlockingBytecode: new Uint8Array(),
sequenceNumber: 0,
},
],
outputs: [
{
lockingBytecode: walletP2PKH.getAddress().toLockscriptBytes(),
valueSatoshis: 100_000n,
},
{
lockingBytecode: walletP2PKH.getAddress().toLockscriptBytes(),
valueSatoshis: 150_000n,
},
],
});
await blockchainLocal.broadcastTransaction(coinbaseTransaction, {
skipValidation: true,
});
console.log('walletP2PKH unspents', await walletP2PKH.fetchUnspentDirectives());
console.log(
'walletP2PKHReceive unspents',
await walletP2PKHReceive.fetchUnspentDirectives()
);
const sweepTx = await walletP2PKH.sweep(
walletP2PKHReceive.getAddress().toLockscriptBytes()
);
await blockchainLocal.broadcastTransaction(sweepTx);
console.log('walletP2PKH unspents', await walletP2PKH.fetchUnspentDirectives());
console.log(
'walletP2PKHReceive unspents',
await walletP2PKHReceive.fetchUnspentDirectives()
);
console.log(blockchainLocal.deriveUtxoSet());
+1 -1
View File
@@ -19,7 +19,7 @@ await wallet.scan();
const transactions = wallet.getTransactions();
transactions.forEach((tx) => {
blockchain.broadcastTransaction(tx);
blockchain.commitTransaction(tx);
});
console.log(blockchain.pendingTransactions.deriveUtxoState());
+8 -1
View File
@@ -6007,6 +6007,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -10940,7 +10946,8 @@
"license": "ISC",
"dependencies": {
"@electrum-cash/network": "^3.3.0",
"@xo/primitives": "^0.0.1"
"@xo/primitives": "^0.0.1",
"idb-keyval": "^6.2.1"
}
}
}
+4 -4
View File
@@ -31,7 +31,7 @@ export class Address {
/**
* Decode a CashAddr into an Address Entity.
*
* @param address {string} The CashAddr to decode.
* @param cashAddr The CashAddr to decode.
*
* @throws {Error} If address cannot be decoded.
*
@@ -71,7 +71,7 @@ export class Address {
/**
* Decode a Legacy Address into an Address Entity.
*
* @param address {string} The Legacy (Base58) Address to decode.
* @param legacyAddress The Legacy (Base58) Address to decode.
*
* @throws {Error} If address cannot be decoded.
*
@@ -128,7 +128,7 @@ export class Address {
/**
* Instantiate an address from Contract Bytecode.
*
* @param contractBytes {Uint8Array} The contract bytecode
* @param contractBytes The contract bytecode
*
* @throws {Error} If address cannot be instantiated from Contract Bytecode.
*
@@ -156,7 +156,7 @@ export class Address {
/**
* Instantiate an address from Contract Bytecode as a hex string.
*
* @param contractBytes {Uint8Array} The contract bytecode
* @param hex The contract bytecode as hex
*
* @throws {Error} If address cannot be instantiated from Contract Bytecode.
*
+1 -1
View File
@@ -30,7 +30,7 @@ export class Bytes {
/**
* Instantiates bytes from a Base64 string.
*
* @param utf8 {string} The Base64 string.
* @param base64 The Base64 string.
*
* @throws {Error} If Base64 is invalid.
*
+42 -24
View File
@@ -7,6 +7,7 @@ import {
deriveHdPath,
deriveHdPublicNode,
deriveHdPrivateNodeFromSeed,
deriveSeedFromBip39Mnemonic,
generateRandomBytes,
} from '@bitauth/libauth';
@@ -26,38 +27,16 @@ export class HDPrivateNode {
/**
* Construct a new HD Private Node.
*
* @param node {HdPrivateNodeValid} The HD Private Node.
* @param node The HD Private Node.
*/
constructor(node: HdPrivateNodeValid) {
this.node = node;
}
/**
* Creates a HD Private Node from the given XPriv Key.
*
* @param xpriv {string} The XPriv Key.
*
* @throws {Error} If HD Private Node cannot be created.
*
* @returns {HDPrivateNode} The created HD Private Node.
*/
public static fromXPriv(xpriv: string): HDPrivateNode {
// Attempt to decode the XPriv Key.
const decodeResult = decodeHdPrivateKey(xpriv);
// If a string is returned, this indicates an error...
if (typeof decodeResult === 'string') {
throw new Error(decodeResult);
}
// Return a new HD Private Node from the given XPriv.
return new HDPrivateNode(decodeResult.node);
}
/**
* Creates a HD Private Node from a raw seed.
*
* @param seed {Uint8Array} The raw seed bytes.
* @param seed The raw seed bytes.
*
* @throws {Error} If HD Private Node cannot be created.
*
@@ -83,6 +62,45 @@ export class HDPrivateNode {
return new HDPrivateNode(deriveResult);
}
/**
* Creates a HD Private Node from the given mnemonic.
*
* @param mnemonic The mnemonic.
*
* @throws {Error} If HD Private Node cannot be created.
*
* @returns {HDPrivateNode} The created HD Private Node.
*/
public static fromMnemonic(mnemonic: string): HDPrivateNode {
// Derive the seed from the mnemonic.
const seed = deriveSeedFromBip39Mnemonic(mnemonic);
// Return a new HDPrivateNode from the given seed.
return HDPrivateNode.fromSeed(seed);
}
/**
* Creates a HD Private Node from the given XPriv Key.
*
* @param xpriv The XPriv Key.
*
* @throws {Error} If HD Private Node cannot be created.
*
* @returns {HDPrivateNode} The created HD Private Node.
*/
public static fromXPriv(xpriv: string): HDPrivateNode {
// Attempt to decode the XPriv Key.
const decodeResult = decodeHdPrivateKey(xpriv);
// If a string is returned, this indicates an error...
if (typeof decodeResult === 'string') {
throw new Error(decodeResult);
}
// Return a new HD Private Node from the given XPriv.
return new HDPrivateNode(decodeResult.node);
}
/**
* Generates a random HD Private Node.
*
+14 -1
View File
@@ -44,6 +44,19 @@ export class PrivateKey {
this.bytes = bytes;
}
/**
* Create a Private Key Entity from the given bytes.
*
* @param privateKeyBytes {string} Raw Private Key bytes.
*
* @throws {Error} If Private Key could not be created.
*
* @returns {PrivateKey} The created Private Key Entity.
*/
public static fromBytes(privateKeyBytes: Uint8Array): PrivateKey {
return new PrivateKey(privateKeyBytes);
}
/**
* Create a Private Key Entity from the given bytes (as hexadecimal string).
*
@@ -232,7 +245,7 @@ export class PrivateKey {
*
* NOTE: Electron can verify this message successfully, but generates them differently internally.
*
* @param messageHash {Uint8Array} The hash of the message.
* @param message The message.
*
* @throws {Error} If message hash cannot be signed.
*
+21
View File
@@ -106,4 +106,25 @@ export class Transaction {
public getLocktime() {
return this.transaction.locktime;
}
public isCoinbase(): boolean {
// TODO: This definitely needs checking.
// A coinbase transaction MUST only have one input.
if (this.getInputs().length > 1) {
return false;
}
// Get the input's unlocking bytecode.
const unlockingBytecode = this.getInputs()[0].unlockingBytecode;
// Get the firt byte.
const firstByte = unlockingBytecode[0];
if (firstByte >= 0x01 && firstByte <= 0x4b) {
return true;
}
return false;
}
}
View File
+5 -6
View File
@@ -1,10 +1,9 @@
import { Blockchain, WalletHD } from '@xo/wallet';
import { HDPrivateNode } from '@xo/primitives';
import { IBlockchain, WalletHD } from '@xo/wallet';
import { WalletP2PKHVue, useWalletP2PKHVue } from './wallet-p2pkh-vue.js';
import { computed, ref, shallowRef } from 'vue';
import { HdPrivateNodeValid } from '@bitauth/libauth';
export class WalletHDVue extends WalletHD<WalletP2PKHVue> {
// Reactive State.
public rShouldMonitor = ref(false);
@@ -14,13 +13,13 @@ export class WalletHDVue extends WalletHD<WalletP2PKHVue> {
public rBalance = computed(() => {
return this.rWallets.value.reduce(
(total, node) => total + node.rBalance.value,
0
0n
);
});
constructor(
node: HdPrivateNodeValid,
electrum: Blockchain,
node: HDPrivateNode,
electrum: IBlockchain,
walletFactory = useWalletP2PKHVue
) {
super(node, electrum, walletFactory);
+10 -10
View File
@@ -1,5 +1,5 @@
import { Transaction } from '@xo/primitives';
import { type AddressListUnspent, Blockchain, WalletP2PKH } from '@xo/wallet';
import { PrivateKey, Transaction } from '@xo/primitives';
import { type IBlockchain, type UTXO, WalletP2PKH } from '@xo/wallet';
import { computed, shallowRef } from 'vue';
/**
@@ -15,19 +15,19 @@ import { computed, shallowRef } from 'vue';
*/
export class WalletP2PKHVue extends WalletP2PKH {
// Reactive State.
public rUnspents = shallowRef<AddressListUnspent['response']>([]);
public rUnspents = shallowRef<Array<UTXO>>([]);
public rTransactions = shallowRef<Array<Transaction>>([]);
// Computeds.
public rBalance = computed(() => {
return this.rUnspents.value.reduce(
(total, unspent) => total + unspent.value,
0
(total, unspent) => total + unspent.valueSatoshis,
0n
);
});
constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) {
super(privateKeyBytes, electrum);
constructor(privateKey: PrivateKey, electrum: IBlockchain) {
super(privateKey, electrum);
this.events.on('unspentsUpdated', (unspents) => {
this.rUnspents.value = unspents;
@@ -40,8 +40,8 @@ export class WalletP2PKHVue extends WalletP2PKH {
}
export function useWalletP2PKHVue(
privateKeyBytes: Uint8Array,
electrum: Blockchain
privateKey: PrivateKey,
electrum: IBlockchain
): WalletP2PKHVue {
return new WalletP2PKHVue(privateKeyBytes, electrum);
return new WalletP2PKHVue(privateKey, electrum);
}
+2 -1
View File
@@ -17,6 +17,7 @@
"license": "ISC",
"dependencies": {
"@electrum-cash/network": "^3.3.0",
"@xo/primitives": "^0.0.1"
"@xo/primitives": "^0.0.1",
"idb-keyval": "^6.2.1"
}
}
@@ -129,3 +129,14 @@ export interface AddressNotification {
// [Address, Status]
params: [string, string | null];
}
export interface TransactionNotification {
jsonrpc: '2.0';
method: 'blockchain.transaction.subscribe';
// [TransactionHash, Blockheight]
params: [txHash: string, blockheight: number | null];
}
export type ElectrumNotification =
| AddressNotification
| TransactionNotification;
@@ -0,0 +1,289 @@
import type {
IBlockchain,
AddressCallback,
TransactionCallback,
UTXO,
} from './interface.js';
import {
ElectrumNotification,
ElectrumRequest,
AddressGetHistory,
AddressListUnspent,
TransactionGet,
} from './blockchain-electrum-types.js';
import { Address, Transaction } from '@xo/primitives';
import { ElectrumClient } from '@electrum-cash/network';
import { hexToBin } from '@bitauth/libauth';
export interface BroadcastOpts {
timeout: number;
waitUntilSeen: boolean;
}
export class BlockchainElectrum implements IBlockchain {
electrumClient!: ElectrumClient;
// Address subscriptions.
addressSubscriptions: { [address: string]: AddressCallback } = {};
transactionSubscriptions: { [txHash: string]: TransactionCallback } = {};
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 stop(): Promise<void> {
// Disconnect from Electrum.
this.electrumClient.disconnect();
}
async fetchUnspents(address: string): Promise<UTXO[]> {
// Fetch unspents for the given address from our Electrum server(s).
const unspentOutputs = await this.request<AddressListUnspent>(
'blockchain.address.listunspent',
address,
'include_tokens'
);
// Work out the locking byecode from the address.
const lockingBytecode =
Address.fromCashAddrOrLegacy(address).toLockscriptBytes();
// Transform the unspents into our UTXO format.
const utxos = unspentOutputs.map((unspent) => ({
outpointTransactionHash: hexToBin(unspent.tx_hash),
outpointIndex: unspent.tx_pos,
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,
}));
// Return the unspents.
return utxos;
}
async fetchTransactions(address: string): Promise<Transaction[]> {
// Get the history of the address from our Electrum server(s).
const history = await this.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.request<TransactionGet>(
'blockchain.transaction.get',
item.tx_hash,
false
)
)
);
// Decode the transactions.
const decodedTransactions = transactions.map((tx) =>
Transaction.fromHex(tx)
);
// Return the transactions.
return decodedTransactions;
}
// TODO: async broadcast() method
// This should accept options: { waitForSeen: boolean }
async broadcastTransaction(
tx: Uint8Array | Transaction | string,
opts: BroadcastOpts = { timeout: 10_000, waitUntilSeen: true }
) {
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.fromAny(tx);
}
// Attempt to broadcast the transaction.
await new Promise<void>(async (resolve, reject) => {
// Set a timeout.
const timeout = setTimeout(() => {
reject(new Error(`Failed to broadcast ${tx.toHashHex()}`));
}, opts.timeout);
// 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);
// Broadcast the transaction to our Electrum node.
await this.request('blockchain.transaction.broadcast', tx.toHex());
});
}
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}`);
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback
) {
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash
);
this.transactionSubscriptions[transactionHash] = callback;
}
async unsubscribeTransaction(
transactionHash: string,
_callback: TransactionCallback
) {
if (!this.transactionSubscriptions[transactionHash]) {
return;
}
delete this.transactionSubscriptions[transactionHash];
try {
await this.electrumClient.unsubscribe(
'blockchain.transaction.unsubscribe',
transactionHash
);
} catch (error) {
console.warn(`${error}`);
}
}
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;
}
protected onNotification(data: ElectrumNotification) {
// 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);
}
// Handle Transaction Notification.
if (data.method === 'blockchain.transaction.subscribe') {
const txHash = data.params[0];
const height = data.params[1];
const subscription = this.transactionSubscriptions[txHash];
if (!subscription) {
console.warn(
`Notification for tx ${txHash} subscribed to, but has no handler`
);
return;
}
// Trigger the subscription's callback.
subscription(height);
}
}
}
@@ -0,0 +1,355 @@
import type {
AddressCallback,
IBlockchain,
TransactionCallback,
UTXO,
} from './interface.js';
import { Address, Transaction } from '@xo/primitives';
import {
type Output,
binToHex,
createVirtualMachineBCH,
} from '@bitauth/libauth';
export type UTXOSet = { [outpointHashAndIndex: string]: UTXO };
// Local Blockchain Adapter.
// This simulates the BCH Blockchain.
export class BlockchainLocal implements IBlockchain {
private transactions: Map<string, Transaction>;
private addressSubscriptions: Map<string, Set<AddressCallback>>;
private transactionSubscriptions: Map<string, Set<TransactionCallback>>;
constructor() {
this.transactions = new Map();
this.addressSubscriptions = new Map();
this.transactionSubscriptions = new Map();
}
async start() {
console.log('BlockchainLocal: Started');
}
async stop() {
console.log('BlockchainLocal: Stopped');
}
async fetchUnspents(address: string): Promise<UTXO[]> {
// Derive the current UTXO set.
const utxoSet = this.deriveUtxoSet();
// Filter for only UTXOs matching the given address.
return Object.values(utxoSet).filter((utxo) => {
// Decode the address and locking bytecode.
const utxoAddress = Address.fromLockscriptBytes(utxo.lockingBytecode);
const needleAddress = Address.fromCashAddrOrLegacy(address);
// Compare the lockscript hex.
return utxoAddress.toLockscriptHex() === needleAddress.toLockscriptHex();
});
}
async fetchTransactions(address: string): Promise<Transaction[]> {
return this.getAllTransactions().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
.getInputs()
.some((input) =>
binToHex(input.unlockingBytecode).includes(address)
) ||
tx
.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.fromAny(tx);
}
// Derive the UTXO set so that we can find source outputs.
const utxoSet = this.deriveUtxoSet();
if (!opts.skipValidation) {
console.log('utxoSet', utxoSet);
// 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
}`;
console.log(utxoId);
return utxoSet[utxoId];
});
console.log('sourceOutputs', sourceOutputs);
// Validate the transaction.
BlockchainLocal.validateTransaction(tx, sourceOutputs);
}
// Add the transaction.
this.addTransaction(tx);
// 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.fromCashAddrOrLegacy(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.fromCashAddrOrLegacy(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[`${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.
// It is generally useful to skipValidation if you are emulating a coinbase tx.
if (!sourceOutput) {
return;
}
const address = Address.fromLockscriptBytes(
sourceOutput.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: Transaction) {
this.transactions.set(tx.toHashHex(), tx);
}
removeTransaction(txHash: string) {
this.transactions.delete(txHash);
}
removeDependentTransactions(txHash: string): string[] {
const removedTxids: string[] = [];
const toRemove: Set<string> = new Set([txHash]);
const findDependents = (id: string) => {
for (const [otherTxid, tx] of this.transactions) {
if (
tx
.getInputs()
.some((input) => binToHex(input.outpointTransactionHash) === id)
) {
toRemove.add(otherTxid);
findDependents(otherTxid);
}
}
};
findDependents(txHash);
for (const id of toRemove) {
if (this.transactions.has(id)) {
this.transactions.delete(id);
removedTxids.push(id);
}
}
return removedTxids;
}
getTransaction(txHash: string): Transaction | undefined {
return this.transactions.get(txHash);
}
getAllTransactions(): Transaction[] {
return Array.from(this.transactions.values());
}
deriveUtxoSet(): UTXOSet {
const utxoMap: UTXOSet = {};
// First, add all outputs as potential UTXOs
for (const tx of this.transactions.values()) {
const txHash = tx.toHash();
tx.getOutputs().forEach((output, outpointIndex) => {
const utxo: UTXO = {
outpointTransactionHash: txHash.toBytes(),
outpointIndex,
valueSatoshis: output.valueSatoshis,
lockingBytecode: output.lockingBytecode,
};
utxoMap[`${txHash.toHex()}:${outpointIndex}`] = utxo;
});
}
// Then, remove all spent outputs
for (const tx of this.transactions.values()) {
tx.getInputs().forEach((input) => {
const outpointTransactionHashHex = binToHex(
input.outpointTransactionHash
);
delete utxoMap[`${outpointTransactionHashHex}:${input.outpointIndex}`];
});
}
return utxoMap;
}
getDependentTransactions(txHash: string): Transaction[] {
return this.getAllTransactions().filter((tx) => {
tx.getInputs().some((input) => {
const outpointTransactionHashHex = binToHex(
input.outpointTransactionHash
);
return outpointTransactionHashHex === txHash;
});
});
}
getBroadcastOrder(): Transaction[] {
const visited = new Set<string>();
const order: Transaction[] = [];
const visit = (txid: string) => {
if (visited.has(txid)) return;
const tx = this.transactions.get(txid);
if (!tx) return;
visited.add(txid);
// First, visit all transactions that this transaction depends on
for (const input of tx.getInputs()) {
const outpointTransactionHashHex = binToHex(
input.outpointTransactionHash
);
visit(outpointTransactionHashHex);
}
order.push(tx);
};
// Start the topological sort from each transaction
for (const tx of this.transactions.values()) {
visit(tx.toHashHex());
}
return order;
}
/**
* @todo Consider moving this to Transaction Primitive. Trade-off is that it's probably big. Maybe standalone util?
*/
static validateTransaction(tx: Transaction, sourceOutputs: Array<Output>) {
console.log(tx);
console.log(sourceOutputs);
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,15 @@
/**
* 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 {}
+5
View File
@@ -0,0 +1,5 @@
export * from './blockchain.js';
export * from './blockchain-electrum.js';
export * from './blockchain-electrum-types.js';
export * from './blockchain-local.js';
export * from './interface.js';
@@ -0,0 +1,38 @@
import { Transaction } from '@xo/primitives';
import type { Output } from '@bitauth/libauth';
export type AddressCallback = (status: string | null) => void;
export type TransactionCallback = (height: number | null) => void;
export interface UTXO extends Output {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
}
export interface IBlockchain {
start: () => Promise<void>;
stop: () => Promise<void>;
fetchUnspents: (address: string) => Promise<UTXO[]>;
fetchTransactions: (address: string) => Promise<Array<Transaction>>;
broadcastTransaction: (transaction: Uint8Array) => Promise<void>;
subscribeAddress: (
address: string,
callback: AddressCallback
) => Promise<void>;
unsubscribeAddress: (
address: string,
callback: AddressCallback
) => Promise<void>;
subscribeTransaction: (
transactionHash: string,
callback: TransactionCallback
) => Promise<void>;
unsubscribeTransaction: (
transactionHash: string,
callback: TransactionCallback
) => Promise<void>;
}
+2
View File
@@ -1,2 +1,4 @@
export * from './blockchain/index.js';
export * from './storage/index.js';
export * from './utils/index.js';
export * from './wallet/index.js';
+4
View File
@@ -0,0 +1,4 @@
export * from './interface.js';
export * from './storage.js';
export * from './storage-idb.js';
export * from './storage-memory.js';
+9
View File
@@ -0,0 +1,9 @@
export interface IStorage {
start: () => Promise<void>;
stop: () => Promise<void>;
get: <T>(key: string) => Promise<T>;
set: <T>(key: string, value: T) => Promise<void>;
delete: (key: string) => Promise<void>;
clear: () => Promise<void>;
}
@@ -0,0 +1,30 @@
import { type IStorage } from './interface';
import {
get as idbGet,
set as idbSet,
del as idbDel,
clear as idbClear,
} from 'idb-keyval';
export class StorageIDB implements IStorage {
async start() {}
async stop() {}
async get(key: string) {
return await idbGet(key);
}
async set<T>(key: string, value: T) {
await idbSet(key, value);
}
async delete(key: string) {
idbDel(key);
}
async clear() {
idbClear();
}
}
@@ -0,0 +1,28 @@
import { type IStorage } from './interface';
/**
* This exists for testing. It is not intended for actual use and will likely be removed.
*/
export class StorageMemory implements IStorage {
storage: { [key: string]: any } = {};
async start() {}
async stop() {}
async get(key: string) {
return this.storage[key];
}
async set<T>(key: string, value: T) {
this.storage[key] = value;
}
async delete(key: string) {
delete this.storage[key];
}
async clear() {
this.storage = {};
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* The idea here is that this will implement the IStorage interface.
* But, it will allow the dev to specify multiple child IStorage interfaces for configuration.
* So, if the cloud storage is not available, the dev can specify behaviour (e.g. use quorum or just fail whatever operation was happening).
*
* @example
* ```ts
* // Actual interface will be more complex, but this is the rough idea.
* const storage = new Storage([
* new StorageIDB(...),
* new StorageSomeCloud(...)
* ])
* ```
*/
export class Storage {}
+17
View File
@@ -0,0 +1,17 @@
export class CashRPCError 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, CashRPCError);
} else {
this.stack = new Error().stack;
console.error(this.stack);
}
// 👇️ because we are extending a built-in class
Object.setPrototypeOf(this, CashRPCError.prototype);
}
}
-178
View File
@@ -1,178 +0,0 @@
import { Transaction, TransactionGraph } from '@xo/primitives';
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 {
pendingTransactions: TransactionGraph = new TransactionGraph();
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;
}
/**
* This won't actually broadcast, this will just commit the transaction to our graph.
* The reason we want this is because, for protos like BIP70, we want service to verify the tx and broadcast it.
* And we just want to be able to monitor whether service did the needful (broadcasted it).
* This should accept options: { waitForSeen: boolean }
*/
async commitTransaction(
transaction: Uint8Array | Transaction | string,
opts: { waitUntilSeen: boolean } = { waitUntilSeen: true }
) {
if (!(transaction instanceof Transaction)) {
transaction = Transaction.fromAny(transaction);
}
this.pendingTransactions.addTransaction(transaction);
// TODO: Wait for transaction to be seen on network.
console.log(opts);
}
// TODO: async broadcast() method
// This should accept options: { waitForSeen: boolean }
async broadcastTransaction(
transaction: Uint8Array | Transaction | string,
opts: { waitUntilSeen: boolean } = { waitUntilSeen: true }
) {
if (!(transaction instanceof Transaction)) {
transaction = Transaction.fromAny(transaction);
}
this.pendingTransactions.addTransaction(transaction);
// Broadcast any pending transaction.
// TODO: Use while loop instead.
for (let tx of this.pendingTransactions.getBroadcastOrder()) {
try {
console.log('broadcasting', tx);
this.pendingTransactions.removeTransaction(tx.toHashHex());
} catch (error) {
// TODO: Set these as a variable and then throw an ExtError.
this.pendingTransactions.removeDependentTransactions(tx.toHashHex());
throw new Error(
`Broadcast failed for ${transaction.toHashHex()}. Removing dependent transactions.`
);
}
}
// TODO: Wait for transaction to be seen on network.
console.log(opts);
}
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
@@ -1,9 +0,0 @@
import { Blockchain } from './blockchain.js';
export type ServiceConfig<BlockchainType, WalletType> = {
createBlockchain: () => BlockchainType;
createWallet: (
privateKeyBytes: Uint8Array,
electrum: Blockchain
) => WalletType;
};
-3
View File
@@ -1,6 +1,3 @@
export * from './blockchain.js';
export * from './blockchain-types.js';
export * from './storage.js';
export * from './wallet-hd.js';
export * from './wallet-p2pkh.js';
export * from './wallet-p2sh20.js';
+56
View File
@@ -0,0 +1,56 @@
import { Transaction } from '@xo/primitives';
import { type UTXO } from '../blockchain/index.js';
import {
type InputTemplate,
CompilerBCH,
CompilationData,
} from '@bitauth/libauth';
export type InputTemplateBCH = InputTemplate<
CompilerBCH,
false,
CompilationData
>;
export interface IWallet {
/**
* Start monitoring this wallet for any transaction activity.
*/
start: () => Promise<void>;
/**
* Stop monitoring this wallet for any transaction activity.
*/
stop: () => Promise<void>;
/**
* Remove any event listeners from this wallet.
*/
destroy: () => Promise<void>;
/**
* Refresh this wallet's state.
*/
refresh: () => Promise<void>;
/**
* Fetch all transactions from the blockchain involving this wallet.
*/
fetchTransactions: () => Promise<Array<Transaction>>;
/**
* Fetch all unspents from the blockchain belonging to this wallet.
*/
fetchUnspents: () => Promise<Array<UTXO>>;
/**
* Fetch all unspents from the blockchain belonging to this wallet.
*
* @todo Probably remove me and just store unspents as this?
*/
fetchUnspentDirectives: () => Promise<Array<InputTemplateBCH>>;
// Individual wallets should be able to send shekels too.
// send: () => Promise<void>
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 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).
*
* Additionally, consider how we'll store data for WalletSets.
* There will be child wallets. These will have their own data.
* And each child may then have its own wallet. And on and on.
* So, it's a tree hierarchy.
* We can probably emit a "storageUpdated" event that is registered by each parent wallet.
* The parent then "wraps" its own state into this and propagates it up to its parent.
* Then, when it reaches root, the storage adapter stashes all of the propagated data into actual storage.
*/
export class Storage {
constructor() {
console.log('TODO');
}
}
+7 -5
View File
@@ -1,5 +1,7 @@
import { type AddressListUnspent } from './blockchain-types.js';
import { Blockchain } from './blockchain.js';
import {
type IBlockchain,
type AddressListUnspent,
} from '../blockchain/index.js';
import { PrivateKey, Transaction } from '@xo/primitives';
@@ -17,11 +19,11 @@ export type InputTemplateBCH = InputTemplate<
export abstract class WalletBase extends PrivateKey {
// Dependencies/Services
public electrum: Blockchain;
public blockchain: IBlockchain;
constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) {
constructor(privateKeyBytes: Uint8Array, blockchain: IBlockchain) {
super(privateKeyBytes);
this.electrum = electrum;
this.blockchain = blockchain;
}
abstract startMonitoring(): Promise<void>;
+88 -35
View File
@@ -1,15 +1,15 @@
// Import the Stamp class to derive the private keys
import { type IWallet } from './interface.js';
import { HDPrivateNode, Transaction } from '@xo/primitives';
import { IBlockchain } from '../blockchain/index.js';
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,
@@ -24,9 +24,9 @@ export type WalletHDEvents<WalletType> = {
walletsUpdated: Array<WalletType>;
};
export class WalletHD<
WalletType extends WalletP2PKH = WalletP2PKH
> extends HDPrivateNode {
export class WalletHD<WalletType extends WalletP2PKH = WalletP2PKH>
implements IWallet
{
// Events.
public events: EventEmitter<WalletHDEvents<WalletType>> = new EventEmitter();
@@ -35,22 +35,32 @@ export class WalletHD<
public wallets: Array<WalletType> = [];
constructor(
public node: HdPrivateNodeValid,
public electrum: Blockchain,
public node: HDPrivateNode,
public blockchain: IBlockchain,
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 fromSeed<T extends WalletHD<any>>(
this: WalletHDConstructor<T>,
seed: Uint8Array,
blockchain: IBlockchain,
walletFactory?: WalletP2PKHFactory<any>
): T {
// Derive node from mnemonic.
const hdPrivateNode = HDPrivateNode.fromSeed(seed);
// Return a new instance of this class.
return new this(hdPrivateNode, blockchain, walletFactory);
}
static fromMnemonic<T extends WalletHD<any>>(
this: WalletHDConstructor<T>,
mnemonic: string,
electrum: Blockchain,
blockchain: IBlockchain,
walletFactory?: WalletP2PKHFactory<any>
): T {
// Derive the seed from the mnemonic.
@@ -60,46 +70,93 @@ export class WalletHD<
const hdPrivateNode = HDPrivateNode.fromSeed(seed);
// Return a new instance of this class.
return new this(hdPrivateNode.node, electrum, walletFactory);
return new this(hdPrivateNode, blockchain, walletFactory);
}
static fromXPriv<T extends WalletHD<any>>(
this: WalletHDConstructor<T>,
xpriv: string,
blockchain: IBlockchain,
walletFactory?: WalletP2PKHFactory<any>
): T {
// Derive node from mnemonic.
const hdPrivateNode = HDPrivateNode.fromXPriv(xpriv);
// Return a new instance of this class.
return new this(hdPrivateNode, blockchain, walletFactory);
}
async start() {
this.shouldMonitor = true;
await Promise.all(this.wallets.map((wallet) => wallet.start()));
}
async stop() {
this.shouldMonitor = false;
await Promise.all(this.wallets.map((wallet) => wallet.stop()));
}
async destroy() {
this.events.removeAllListeners();
}
async fetchTransactions() {
const transactions = await Promise.all(
this.wallets.map((wallet) => wallet.fetchTransactions())
);
// TODO: Remove duplicates.
return transactions.flat();
}
async fetchUnspents() {
const unspents = await Promise.all(
this.wallets.map((wallet) => wallet.fetchUnspents())
);
// TODO: Remove duplicates.
return unspents.flat();
}
async fetchUnspentDirectives() {
const unspentDirectives = await Promise.all(
this.wallets.map((wallet) => wallet.fetchUnspentDirectives())
);
// TODO: Remove duplicates.
return unspentDirectives.flat();
}
async refresh() {
await Promise.all(this.wallets.map((node) => node.refresh()));
}
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(
const childNode = this.node.derivePath(
`${DERIVATION_PATH}/0/${startIndex + i}`
);
const wallet = this.walletFactory(
childNode.privateKey().toBytes(),
this.electrum
childNode.privateKey(),
this.blockchain
);
if (this.shouldMonitor) {
wallet.startMonitoring();
wallet.start();
}
wallets.push(wallet);
@@ -128,7 +185,7 @@ export class WalletHD<
// Get the transaction history of each wallet in the batch
const results = await Promise.all(
batch.map((wallet) => wallet.fetchHistory())
batch.map((wallet) => wallet.fetchTransactions())
);
// Process the results
@@ -156,14 +213,10 @@ export class WalletHD<
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())
this.wallets.map((wallet) => wallet.fetchUnspentDirectives())
);
// Flatten the inputs.
+143 -96
View File
@@ -1,23 +1,20 @@
import { Transaction } from '@xo/primitives';
import { type IWallet } from './interface.js';
import { PrivateKey, Transaction } from '@xo/primitives';
import { type IBlockchain, type UTXO } from '../blockchain/index.js';
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,
encodeTransaction,
generateTransaction,
getMinimumFee,
walletTemplateP2pkhNonHd,
walletTemplateToCompilerBCH,
} from '@bitauth/libauth';
export type WalletP2PKHEvents = {
transactionsUpdated: Array<Transaction>;
unspentsUpdated: AddressListUnspent['response'];
unspentsUpdated: Array<UTXO>;
};
/**
@@ -30,124 +27,118 @@ export type WalletP2PKHEvents = {
* await walletP2PKH.startMonitoring();
* ```
*/
export class WalletP2PKH extends WalletBase {
export class WalletP2PKH implements IWallet {
// Events.
public events: EventEmitter<WalletP2PKHEvents> = new EventEmitter();
// State.
public transactions: Array<Transaction> = [];
public unspents: AddressListUnspent['response'] = [];
constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) {
super(privateKeyBytes, electrum);
public unspents: Array<UTXO> = [];
constructor(public privateKey: PrivateKey, public blockchain: IBlockchain) {
// Monitor the following properties and emit an event when they change.
this.events.monitorProperty(this, 'transactions', 'transactionsUpdated');
this.events.monitorProperty(this, 'unspents', 'unspentsUpdated');
}
static fromBytes(privateKeyBytes: Uint8Array, blockchain: IBlockchain) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromBytes(privateKeyBytes);
// Return a new instance of Wallet P2PKH.
return new WalletP2PKH(privateKey, blockchain);
}
static fromHex(privateKeyHex: string, blockchain: IBlockchain) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromHex(privateKeyHex);
// Return a new instance of Wallet P2PKH.
return new WalletP2PKH(privateKey, blockchain);
}
static fromWif(privateKeyWif: string, blockchain: IBlockchain) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.fromWIF(privateKeyWif);
// Return a new instance of Wallet P2PKH.
return new WalletP2PKH(privateKey, blockchain);
}
static generateRandom(blockchain: IBlockchain) {
// Create a Private Key entity from the provided bytes.
const privateKey = PrivateKey.generateRandom();
// Return a new instance of Wallet P2PKH.
return new WalletP2PKH(privateKey, blockchain);
}
async start() {
await this.blockchain.subscribeAddress(
this.getAddress().toCashAddr(),
this.onAddressNotification.bind(this)
);
}
async stop() {
await this.blockchain.unsubscribeAddress(
this.getAddress().toCashAddr(),
this.onAddressNotification.bind(this)
);
}
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() {
async fetchTransactions() {
// Get this node's address.
const address = this.getAddress();
const address = this.getAddress().toCashAddr();
// 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)
);
// Get transactions involving this address.
const transactions = await this.blockchain.fetchTransactions(address);
// Store the transactions.
this.transactions = decodedTransactions;
this.transactions = transactions;
return decodedTransactions;
// Return the fetched transactions.
return transactions;
}
async fetchUnspentOutputs() {
const address = this.getAddress();
async fetchUnspents() {
// Get this wallet's address.
const address = this.getAddress().toCashAddr();
const unspentOutputs = await this.electrum.request<AddressListUnspent>(
'blockchain.address.listunspent',
address,
'include_tokens'
);
// Fetch unspents belonging to this address.
const unspents = await this.blockchain.fetchUnspents(address);
// Emit event with unspent outputs.
this.unspents = unspentOutputs;
// Store the unspents.
this.unspents = unspents;
return unspentOutputs;
// Return the unspents.
return unspents;
}
async getUnspentDirectives() {
async fetchUnspentDirectives() {
// Get a list of unspent outputs.
const unspentOutputs = await this.fetchUnspentOutputs();
const unspentOutputs = await this.fetchUnspents();
// 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),
outpointIndex: unspent.outpointIndex,
outpointTransactionHash: unspent.outpointTransactionHash,
sequenceNumber: 0,
unlockingBytecode: {
compiler: compilerP2PKH,
data: {
keys: { privateKeys: { key: this.toBytes() } },
keys: { privateKeys: { key: this.privateKey.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),
token: unspent.token,
valueSatoshis: unspent.valueSatoshis,
},
}));
@@ -156,11 +147,67 @@ export class WalletP2PKH extends WalletBase {
}
async refresh(): Promise<void> {
await Promise.all([this.fetchUnspentOutputs(), this.fetchHistory()]);
await Promise.all([this.fetchUnspents(), this.fetchTransactions()]);
}
async onAddressNotification(status: string | null) {
getAddress() {
return this.privateKey.derivePublicKey().deriveAddress();
}
async sweep(payoutBytecode: Uint8Array) {
// Flatten the inputs.
const inputs = await this.fetchUnspentDirectives();
// Calculate the total sats available in our inputs.
const totalSats = inputs.reduce((total, input) => {
if (input.unlockingBytecode instanceof Uint8Array) {
return (total += 0n);
}
return (total += input.unlockingBytecode.valueSatoshis);
}, 0n);
// 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: inputs,
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;
}
private async onAddressNotification(status: string | null) {
// If status is null, it simply means that our subscribe call to Electrum was successful.
// TODO: I don't know if we need this anymore on later versions of Electurm.
if (!status) {
return;
}
@@ -171,13 +218,13 @@ export class WalletP2PKH extends WalletBase {
}
export type WalletP2PKHFactory<T extends WalletP2PKH = WalletP2PKH> = (
privateKeyBytes: Uint8Array,
electrum: Blockchain
privateKey: PrivateKey,
blockchain: IBlockchain
) => T;
export function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
privateKeyBytes: Uint8Array,
electrum: Blockchain
privateKey: PrivateKey,
blockchain: IBlockchain
): T {
return new WalletP2PKH(privateKeyBytes, electrum) as T;
return new WalletP2PKH(privateKey, blockchain) as T;
}