310 lines
9.2 KiB
TypeScript
310 lines
9.2 KiB
TypeScript
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<T extends BaseWallet> = {
|
|
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<T extends BaseWallet = BaseWallet> extends Mixin([
|
|
BaseWallet,
|
|
]) {
|
|
public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
|
|
const reactiveWallet = new ReactiveWallet(deps);
|
|
await reactiveWallet.start();
|
|
return reactiveWallet;
|
|
}
|
|
|
|
//-----------------------------------
|
|
// Reactive Wallet State
|
|
//-----------------------------------
|
|
|
|
/** All addresses belonging to this wallet. */
|
|
addresses = shallowRef<WalletAddresses>(new ExtMap());
|
|
|
|
/** All transactions involving this wallet. */
|
|
transactions = shallowRef<WalletTransactions>(new ExtMap());
|
|
|
|
/** All unspent transaction outputs belonging to this wallet. */
|
|
unspents = shallowRef<BlockchainUTXOs>(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<T>) {
|
|
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<void> {
|
|
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<MapDiff<WalletTransaction>> {
|
|
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<MapDiff<BlockchainUTXO>> {
|
|
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 }
|
|
);
|
|
}
|
|
}
|