Initial commit
This commit is contained in:
309
src/services/wallet.ts
Normal file
309
src/services/wallet.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user