import { computed, ref, shallowRef, triggerRef } from 'vue'; import { type BlockchainUTXO, type BlockchainUTXOs, type MapDiff, type WalletAddresses, type WalletTransactions, type WalletTransaction, BaseWallet, BlockHeader, Bytes, ExtMap, calculateBalanceSats, calculateBalanceTokens, } from '@xocash/stack'; import { Mixin } from '../utils/mixin.js'; import { binToHex } from '@bitauth/libauth'; type ReactiveWalletDependencies = { wallet: T; }; //----------------------------------------------------------------------------- // ReactiveWallet - Generic Reactive Wallet Wrapper //----------------------------------------------------------------------------- /** * A reactive Vue wrapper for any wallet that extends BaseWallet. * * @remarks * * This class wraps any wallet from the stack package and adds reactive Vue * bindings for use in the UI. It uses the Mixin pattern to delegate all * wallet functionality to the underlying wallet instance. * * @example * ```ts * // With a watch-only HD wallet * const hdWallet = await WalletHDWatch.from(deps, { xpub: '...' }); * const reactiveHD = new ReactiveWallet(hdWallet); * await reactiveHD.start(); * * // With a watch-only P2PKH wallet * const p2pkhWallet = await WalletP2PKHWatch.from(deps, { publicKey: '...' }); * const reactiveP2PKH = new ReactiveWallet(p2pkhWallet); * await reactiveP2PKH.start(); * * // With a regular wallet (signing capable) * const wallet = await WalletHD.from(deps, { mnemonic: '...' }); * const reactiveWallet = new ReactiveWallet(wallet); * await reactiveWallet.start(); * ``` */ export class ReactiveWallet extends Mixin([ BaseWallet, ]) { public static async from(deps: ReactiveWalletDependencies): Promise> { const reactiveWallet = new ReactiveWallet(deps); await reactiveWallet.start(); return reactiveWallet; } //----------------------------------- // Reactive Wallet State //----------------------------------- /** All addresses belonging to this wallet. */ addresses = shallowRef(new ExtMap()); /** All transactions involving this wallet. */ transactions = shallowRef(new ExtMap()); /** All unspent transaction outputs belonging to this wallet. */ unspents = shallowRef(new ExtMap()); /** Block headers indexed by height. */ blockHeaders = ref<{ [height: number]: BlockHeader }>({}); /** Whether the wallet has completed its initial load. */ isReady = ref(false); //----------------------------------- // Derived State //----------------------------------- /** The current balance in satoshis. */ balanceSats = computed(() => { return calculateBalanceSats( this.unspents.value.map((blockchainUTXO) => blockchainUTXO.utxo).toArray() ); }); /** The current token balances by category. */ balanceTokens = computed(() => { return calculateBalanceTokens( this.unspents.value.map((blockchainUTXO) => blockchainUTXO.utxo).toArray() ); }); /** The underlying wallet instance. */ readonly wallet: T; /** * Creates a new ReactiveWallet. * * @param deps - The dependencies of the reactive wallet. */ constructor(deps: ReactiveWalletDependencies) { super(deps.wallet); this.wallet = deps.wallet; // Listen for state updates from the underlying wallet. deps.wallet.on( 'stateUpdated', async () => { await Promise.all([ this.updateAddresses(), this.updateUnspents(), this.updateTransactions(), ]); // Mark the wallet as ready after the first update. this.isReady.value = true; }, 500 ); } /** * Updates the reactive addresses from the underlying wallet. */ async updateAddresses(): Promise { const addresses = await this.wallet.getAddresses(); this.addresses.value = addresses; triggerRef(this.addresses); } /** * Updates the reactive transactions from the underlying wallet. * * @returns The diff of added, removed, and updated transactions. */ async updateTransactions(): Promise> { const transactions = await this.wallet.getTransactions(); // For each transaction, fetch the block header in the background. transactions.forEach((tx) => { if (tx.height <= 0) return; this.wallet.blockchain.fetchBlockHeader(tx.height).then((blockHeader) => { this.blockHeaders.value[tx.height] = blockHeader; }); }); // Create a diff of added, removed and updated transactions. const { added, removed, updated } = this.transactions.value.diff( transactions, (a, b) => a.height === b.height ); // Update existing transactions. updated.forEach((tx) => { const existingTx = this.transactions.value.get(tx.hash.toHex()); if (existingTx) { Object.assign(existingTx, tx); } }); // Add new transactions. added.forEach((tx) => this.transactions.value.set(tx.hash.toHex(), tx)); // Remove old transactions. removed.forEach((tx) => this.transactions.value.delete(tx.hash.toHex())); // Trigger reactivity if there were changes. if (added.size || updated.size || removed.size) { triggerRef(this.transactions); } return { added, removed, updated }; } /** * Updates the reactive unspents from the underlying wallet. * * @returns The diff of added, removed, and updated unspents. */ async updateUnspents(): Promise> { const unspents = await this.wallet.getUnspents(); // Create a diff of added, removed and updated unspents. const { added, removed, updated } = this.unspents.value.diff( unspents, (a, b) => a.utxo.outpoint.toString() === b.utxo.outpoint.toString() ); // Add new unspents. added.forEach((unspent) => this.unspents.value.set(unspent.utxo.outpoint.toString(), unspent) ); // Update existing unspents. updated.forEach((unspent) => { const existingUnspent = this.unspents.value.get( unspent.utxo.outpoint.toString() ); if (existingUnspent) { Object.assign(existingUnspent, unspent); } }); // Remove old unspents. removed.forEach((unspent) => this.unspents.value.delete(unspent.utxo.outpoint.toString()) ); // Trigger reactivity if there were changes. if (added.size || updated.size || removed.size) { triggerRef(this.unspents); } return { added, removed, updated }; } /** * Calculates the balance change for a given transaction. * * @param tx - The transaction to calculate the balance change for. * @returns The net balance change in satoshis (positive = incoming, negative = outgoing). */ calculateBalanceChange(tx: WalletTransaction): bigint { let incoming = 0n; let outgoing = 0n; // Calculate incoming funds from outputs. tx.transaction.getOutputs().forEach((output) => { const outputAddress = binToHex(output.lockingBytecode); if (this.addresses.value.get(outputAddress)) { incoming += BigInt(output.valueSatoshis); } }); // Calculate outgoing funds from inputs. tx.sourceOutputs.forEach((sourceOutput) => { const lockscriptHex = binToHex(sourceOutput.lockingBytecode); if (this.addresses.value.get(lockscriptHex)) { outgoing += sourceOutput.valueSatoshis; } }); return incoming - outgoing; } /** * Calculates the token balance change for a given transaction. * * @param tx - The transaction to calculate the token balance change for. * @returns An object with category IDs as keys and balance changes as values. */ calculateTokenBalanceChange(tx: WalletTransaction): { [categoryId: string]: bigint; } { const categories: { [categoryId: string]: { incoming: bigint; outgoing: bigint }; } = {}; // Calculate incoming tokens from outputs. tx.transaction.getOutputs().forEach((output) => { if (output.token) { const categoryId = Bytes.from(output.token.category).toHex(); if (!categories[categoryId]) { categories[categoryId] = { incoming: 0n, outgoing: 0n }; } const outputAddress = binToHex(output.lockingBytecode); if (this.addresses.value.get(outputAddress)) { categories[categoryId].incoming += BigInt(output.token.amount); } } }); // Calculate outgoing tokens from inputs. tx.sourceOutputs.forEach((sourceOutput) => { if (sourceOutput.token) { const categoryId = Bytes.from(sourceOutput.token.category).toHex(); if (!categories[categoryId]) { categories[categoryId] = { incoming: 0n, outgoing: 0n }; } const lockscriptHex = binToHex(sourceOutput.lockingBytecode); if (this.addresses.value.get(lockscriptHex)) { categories[categoryId].outgoing += BigInt(sourceOutput.token.amount); } } }); return Object.entries(categories).reduce( (acc, [categoryId, amounts]) => { acc[categoryId] = amounts.incoming - amounts.outgoing; return acc; }, {} as { [categoryId: string]: bigint } ); } }