diff --git a/examples/node/genesisTest.js b/examples/node/genesisTest.js new file mode 100644 index 0000000..86c8e26 --- /dev/null +++ b/examples/node/genesisTest.js @@ -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()); diff --git a/examples/node/index.js b/examples/node/index.js index 4a8fc7a..e90a7d4 100644 --- a/examples/node/index.js +++ b/examples/node/index.js @@ -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()); diff --git a/package-lock.json b/package-lock.json index d374f18..f4a8fc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/packages/primitives/src/address.ts b/packages/primitives/src/address.ts index c21a262..30115a0 100644 --- a/packages/primitives/src/address.ts +++ b/packages/primitives/src/address.ts @@ -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. * diff --git a/packages/primitives/src/bytes.ts b/packages/primitives/src/bytes.ts index 99d0c30..d2208b7 100644 --- a/packages/primitives/src/bytes.ts +++ b/packages/primitives/src/bytes.ts @@ -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. * diff --git a/packages/primitives/src/hd-private-node.ts b/packages/primitives/src/hd-private-node.ts index 2ceb581..19fb634 100644 --- a/packages/primitives/src/hd-private-node.ts +++ b/packages/primitives/src/hd-private-node.ts @@ -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. * diff --git a/packages/primitives/src/private-key.ts b/packages/primitives/src/private-key.ts index 4a9cf5c..e839a27 100644 --- a/packages/primitives/src/private-key.ts +++ b/packages/primitives/src/private-key.ts @@ -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. * diff --git a/packages/primitives/src/transaction.ts b/packages/primitives/src/transaction.ts index c2449e8..a29c1c4 100644 --- a/packages/primitives/src/transaction.ts +++ b/packages/primitives/src/transaction.ts @@ -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; + } } diff --git a/packages/primitives/src/utxo.ts b/packages/primitives/src/utxo.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/vue/src/wallet-hd-vue.ts b/packages/vue/src/wallet-hd-vue.ts index 2565921..a56afff 100644 --- a/packages/vue/src/wallet-hd-vue.ts +++ b/packages/vue/src/wallet-hd-vue.ts @@ -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 { // Reactive State. public rShouldMonitor = ref(false); @@ -14,13 +13,13 @@ export class WalletHDVue extends WalletHD { 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); diff --git a/packages/vue/src/wallet-p2pkh-vue.ts b/packages/vue/src/wallet-p2pkh-vue.ts index 7b4c40f..d7c040a 100644 --- a/packages/vue/src/wallet-p2pkh-vue.ts +++ b/packages/vue/src/wallet-p2pkh-vue.ts @@ -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([]); + public rUnspents = shallowRef>([]); public rTransactions = shallowRef>([]); // 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); } diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 7142dad..33ab1c7 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -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" } } diff --git a/packages/wallet/src/wallet/blockchain-types.ts b/packages/wallet/src/blockchain/blockchain-electrum-types.ts similarity index 92% rename from packages/wallet/src/wallet/blockchain-types.ts rename to packages/wallet/src/blockchain/blockchain-electrum-types.ts index e7339fe..9b5be1a 100644 --- a/packages/wallet/src/wallet/blockchain-types.ts +++ b/packages/wallet/src/blockchain/blockchain-electrum-types.ts @@ -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; diff --git a/packages/wallet/src/blockchain/blockchain-electrum.ts b/packages/wallet/src/blockchain/blockchain-electrum.ts new file mode 100644 index 0000000..343c9f6 --- /dev/null +++ b/packages/wallet/src/blockchain/blockchain-electrum.ts @@ -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 { + // 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 { + // Disconnect from Electrum. + this.electrumClient.disconnect(); + } + + async fetchUnspents(address: string): Promise { + // Fetch unspents for the given address from our Electrum server(s). + const unspentOutputs = await this.request( + '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 { + // Get the history of the address from our Electrum server(s). + const history = await this.request( + 'blockchain.address.get_history', + address, + 0, + -1 + ); + + // Get the transactions for each history item. + const transactions = await Promise.all( + history.map((item) => + this.request( + '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(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( + endpoint: T['method'], + ...params: T['params'] + ): Promise { + 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); + } + } +} diff --git a/packages/wallet/src/blockchain/blockchain-local.ts b/packages/wallet/src/blockchain/blockchain-local.ts new file mode 100644 index 0000000..65d71aa --- /dev/null +++ b/packages/wallet/src/blockchain/blockchain-local.ts @@ -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; + private addressSubscriptions: Map>; + private transactionSubscriptions: Map>; + + 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 { + // 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 { + 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 = 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(); + 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) { + 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); + } + } +} diff --git a/packages/wallet/src/blockchain/blockchain.ts b/packages/wallet/src/blockchain/blockchain.ts new file mode 100644 index 0000000..7a5b9be --- /dev/null +++ b/packages/wallet/src/blockchain/blockchain.ts @@ -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 {} diff --git a/packages/wallet/src/blockchain/index.ts b/packages/wallet/src/blockchain/index.ts new file mode 100644 index 0000000..e7fbef0 --- /dev/null +++ b/packages/wallet/src/blockchain/index.ts @@ -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'; diff --git a/packages/wallet/src/blockchain/interface.ts b/packages/wallet/src/blockchain/interface.ts new file mode 100644 index 0000000..f84a3a5 --- /dev/null +++ b/packages/wallet/src/blockchain/interface.ts @@ -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; + stop: () => Promise; + + fetchUnspents: (address: string) => Promise; + fetchTransactions: (address: string) => Promise>; + broadcastTransaction: (transaction: Uint8Array) => Promise; + + subscribeAddress: ( + address: string, + callback: AddressCallback + ) => Promise; + unsubscribeAddress: ( + address: string, + callback: AddressCallback + ) => Promise; + + subscribeTransaction: ( + transactionHash: string, + callback: TransactionCallback + ) => Promise; + unsubscribeTransaction: ( + transactionHash: string, + callback: TransactionCallback + ) => Promise; +} diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 59ecbab..be205ec 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,2 +1,4 @@ +export * from './blockchain/index.js'; +export * from './storage/index.js'; export * from './utils/index.js'; export * from './wallet/index.js'; diff --git a/packages/wallet/src/storage/index.ts b/packages/wallet/src/storage/index.ts new file mode 100644 index 0000000..8eae43b --- /dev/null +++ b/packages/wallet/src/storage/index.ts @@ -0,0 +1,4 @@ +export * from './interface.js'; +export * from './storage.js'; +export * from './storage-idb.js'; +export * from './storage-memory.js'; diff --git a/packages/wallet/src/storage/interface.ts b/packages/wallet/src/storage/interface.ts new file mode 100644 index 0000000..0b376f9 --- /dev/null +++ b/packages/wallet/src/storage/interface.ts @@ -0,0 +1,9 @@ +export interface IStorage { + start: () => Promise; + stop: () => Promise; + + get: (key: string) => Promise; + set: (key: string, value: T) => Promise; + delete: (key: string) => Promise; + clear: () => Promise; +} diff --git a/packages/wallet/src/storage/storage-idb.ts b/packages/wallet/src/storage/storage-idb.ts new file mode 100644 index 0000000..5ae7094 --- /dev/null +++ b/packages/wallet/src/storage/storage-idb.ts @@ -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(key: string, value: T) { + await idbSet(key, value); + } + + async delete(key: string) { + idbDel(key); + } + + async clear() { + idbClear(); + } +} diff --git a/packages/wallet/src/storage/storage-memory.ts b/packages/wallet/src/storage/storage-memory.ts new file mode 100644 index 0000000..65c4910 --- /dev/null +++ b/packages/wallet/src/storage/storage-memory.ts @@ -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(key: string, value: T) { + this.storage[key] = value; + } + + async delete(key: string) { + delete this.storage[key]; + } + + async clear() { + this.storage = {}; + } +} diff --git a/packages/wallet/src/storage/storage.ts b/packages/wallet/src/storage/storage.ts new file mode 100644 index 0000000..3e4581d --- /dev/null +++ b/packages/wallet/src/storage/storage.ts @@ -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 {} diff --git a/packages/wallet/src/utils/ext-error.ts b/packages/wallet/src/utils/ext-error.ts new file mode 100644 index 0000000..a56e1e5 --- /dev/null +++ b/packages/wallet/src/utils/ext-error.ts @@ -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); + } +} \ No newline at end of file diff --git a/packages/wallet/src/wallet/blockchain.ts b/packages/wallet/src/wallet/blockchain.ts deleted file mode 100644 index 9720718..0000000 --- a/packages/wallet/src/wallet/blockchain.ts +++ /dev/null @@ -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 { - // 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( - endpoint: T['method'], - ...params: T['params'] - ): Promise { - 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); - } - } -} diff --git a/packages/wallet/src/wallet/config.ts b/packages/wallet/src/wallet/config.ts deleted file mode 100644 index 9f17ba6..0000000 --- a/packages/wallet/src/wallet/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Blockchain } from './blockchain.js'; - -export type ServiceConfig = { - createBlockchain: () => BlockchainType; - createWallet: ( - privateKeyBytes: Uint8Array, - electrum: Blockchain - ) => WalletType; -}; diff --git a/packages/wallet/src/wallet/index.ts b/packages/wallet/src/wallet/index.ts index d83459a..fdb1a3f 100644 --- a/packages/wallet/src/wallet/index.ts +++ b/packages/wallet/src/wallet/index.ts @@ -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'; diff --git a/packages/wallet/src/wallet/interface.ts b/packages/wallet/src/wallet/interface.ts new file mode 100644 index 0000000..210ef8c --- /dev/null +++ b/packages/wallet/src/wallet/interface.ts @@ -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; + + /** + * Stop monitoring this wallet for any transaction activity. + */ + stop: () => Promise; + + /** + * Remove any event listeners from this wallet. + */ + destroy: () => Promise; + + /** + * Refresh this wallet's state. + */ + refresh: () => Promise; + + /** + * Fetch all transactions from the blockchain involving this wallet. + */ + fetchTransactions: () => Promise>; + + /** + * Fetch all unspents from the blockchain belonging to this wallet. + */ + fetchUnspents: () => Promise>; + + /** + * Fetch all unspents from the blockchain belonging to this wallet. + * + * @todo Probably remove me and just store unspents as this? + */ + fetchUnspentDirectives: () => Promise>; + + // Individual wallets should be able to send shekels too. + // send: () => Promise +} diff --git a/packages/wallet/src/wallet/storage.ts b/packages/wallet/src/wallet/storage.ts deleted file mode 100644 index ed3e5a3..0000000 --- a/packages/wallet/src/wallet/storage.ts +++ /dev/null @@ -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'); - } -} diff --git a/packages/wallet/src/wallet/wallet-base.ts b/packages/wallet/src/wallet/wallet-base.ts index ecdc9d0..3888856 100644 --- a/packages/wallet/src/wallet/wallet-base.ts +++ b/packages/wallet/src/wallet/wallet-base.ts @@ -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; diff --git a/packages/wallet/src/wallet/wallet-hd.ts b/packages/wallet/src/wallet/wallet-hd.ts index 0867295..b360ca1 100644 --- a/packages/wallet/src/wallet/wallet-hd.ts +++ b/packages/wallet/src/wallet/wallet-hd.ts @@ -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 = { walletsUpdated: Array; }; -export class WalletHD< - WalletType extends WalletP2PKH = WalletP2PKH -> extends HDPrivateNode { +export class WalletHD + implements IWallet +{ // Events. public events: EventEmitter> = new EventEmitter(); @@ -35,22 +35,32 @@ export class WalletHD< public wallets: Array = []; constructor( - public node: HdPrivateNodeValid, - public electrum: Blockchain, + public node: HDPrivateNode, + public blockchain: IBlockchain, private walletFactory: WalletP2PKHFactory = 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>( + this: WalletHDConstructor, + seed: Uint8Array, + blockchain: IBlockchain, + walletFactory?: WalletP2PKHFactory + ): 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>( this: WalletHDConstructor, mnemonic: string, - electrum: Blockchain, + blockchain: IBlockchain, walletFactory?: WalletP2PKHFactory ): 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>( + this: WalletHDConstructor, + xpriv: string, + blockchain: IBlockchain, + walletFactory?: WalletP2PKHFactory + ): 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 = []; // 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. diff --git a/packages/wallet/src/wallet/wallet-p2pkh.ts b/packages/wallet/src/wallet/wallet-p2pkh.ts index 2ed94b9..96eb429 100644 --- a/packages/wallet/src/wallet/wallet-p2pkh.ts +++ b/packages/wallet/src/wallet/wallet-p2pkh.ts @@ -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; - unspentsUpdated: AddressListUnspent['response']; + unspentsUpdated: Array; }; /** @@ -30,124 +27,118 @@ export type WalletP2PKHEvents = { * await walletP2PKH.startMonitoring(); * ``` */ -export class WalletP2PKH extends WalletBase { +export class WalletP2PKH implements IWallet { // Events. public events: EventEmitter = new EventEmitter(); // State. public transactions: Array = []; - public unspents: AddressListUnspent['response'] = []; - - constructor(privateKeyBytes: Uint8Array, electrum: Blockchain) { - super(privateKeyBytes, electrum); + public unspents: Array = []; + 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( - '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( - '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( - '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 { - 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 = ( - privateKeyBytes: Uint8Array, - electrum: Blockchain + privateKey: PrivateKey, + blockchain: IBlockchain ) => T; export function useWalletP2PKH( - privateKeyBytes: Uint8Array, - electrum: Blockchain + privateKey: PrivateKey, + blockchain: IBlockchain ): T { - return new WalletP2PKH(privateKeyBytes, electrum) as T; + return new WalletP2PKH(privateKey, blockchain) as T; }