Initial commit

This commit is contained in:
2026-01-15 13:39:10 +11:00
commit e99af43f06
29 changed files with 5867 additions and 0 deletions

309
src/services/wallet.ts Normal file
View 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 }
);
}
}