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

127
src/services/app.ts Normal file
View File

@@ -0,0 +1,127 @@
import { Rates } from './rates.js';
import { Settings } from './settings.js';
import { Wallets } from './wallets.js';
import { inject, ref } from 'vue';
import { type Router } from 'vue-router';
// XO Stack
import {
type BaseStorage,
BlockchainElectrum,
StorageLocalStorage,
} from '@xocash/stack';
import { ReactiveBlockchain } from './blockchain.js';
export type AppDependencies = {
router: Router;
settings: Settings;
blockchain: ReactiveBlockchain;
rates: Rates;
walletStorage: BaseStorage;
wallets: Wallets;
cache: BaseStorage;
};
export class App {
public isDebugMode = ref(false);
//---------------------------------------------------------------------------
// Initialization
//---------------------------------------------------------------------------
public router: Router;
public settings: Settings;
public blockchain: ReactiveBlockchain;
public rates: Rates;
public walletStorage: BaseStorage;
public wallets: Wallets;
public cache: BaseStorage;
constructor(
dependencies: AppDependencies
) {
this.router = dependencies.router;
this.settings = dependencies.settings;
this.blockchain = dependencies.blockchain;
this.rates = dependencies.rates;
this.walletStorage = dependencies.walletStorage;
this.wallets = dependencies.wallets;
this.cache = dependencies.cache;
}
static async create(router: Router) {
// Setup app storage adapter.
const appStorage = await StorageLocalStorage.createOrOpen('xoApp_V0.0.1');
const settingsStore = await appStorage.createOrGetStore('settings');
// Create settings class.
const settings = await Settings.from(settingsStore);
// Setup rates.
const rates = await Rates.from();
rates.start();
// Setup wallets storage adapter.
const walletStorage = await StorageLocalStorage.createOrOpen(
'xoWallets_V0.0.5'
);
// Setup cache storage adapter.
const cacheStorage = await StorageLocalStorage.createOrOpen(
'xoWalletCache_V0.0.5'
);
const blockchainCache = await cacheStorage.createOrGetStore(
'electrumBlockchain',
{
syncInMemory: true,
}
);
// Setup blockchain (and use our cache).
const blockchain = await ReactiveBlockchain.from(
await BlockchainElectrum.from({
store: blockchainCache,
})
);
// Setup wallet manager.
const walletManager = new Wallets(
blockchain.blockchain,
walletStorage,
cacheStorage,
);
await walletManager.start();
// Create new instance of app.
return new this(
{
router,
blockchain,
cache: cacheStorage,
rates,
settings,
walletStorage,
wallets: walletManager,
}
);
}
async initializeBrowser(): Promise<void> {
// Make sure that this browser supports Mutex Locks (navigator.lock).
// TODO: Will we even need this for CashStamps?
if (typeof navigator.locks === 'undefined') {
throw new Error(
'Your browser does not support Mutex Locks. Please update your browser or switch to a browser that supports this feature.'
);
}
}
}
export function useApp() {
const app = inject<App>('app');
if (!app) throw new Error('App not properly initialized');
return app;
}

View File

@@ -0,0 +1,60 @@
import { Mixin } from '@/utils/mixin.js';
import { ref } from 'vue';
import {
type AddressStatusPayload,
type BlockHeightPayload,
BaseBlockchain,
} from '@xocash/stack';
export class ReactiveBlockchain extends Mixin([
BaseBlockchain<AddressStatusPayload, BlockHeightPayload>,
]) {
static async from(
blockchain: BaseBlockchain<AddressStatusPayload, BlockHeightPayload>
) {
return new ReactiveBlockchain(blockchain);
}
// Dependencies.
public readonly blockchain: BaseBlockchain<
AddressStatusPayload,
BlockHeightPayload
>;
// Reactives.
isConnected = ref(false);
blockHeight = ref<number | undefined>(undefined);
constructor(
blockchain: BaseBlockchain<AddressStatusPayload, BlockHeightPayload>
) {
super(blockchain);
// Bind our events.
blockchain.on(
'isConnectedUpdated',
(isConnected) => {
this.isConnected.value = isConnected;
},
500
);
blockchain.on(
'blockHeightUpdated',
(blockHeight) => {
this.blockHeight.value = blockHeight;
},
500
);
// Assign our class members.
this.blockchain = blockchain;
// Set the initial block height.
this.blockchain.fetchChainTip().then((chainTip) => {
this.blockHeight.value = chainTip.height;
});
}
}

109
src/services/rates.ts Normal file
View File

@@ -0,0 +1,109 @@
import { ref, triggerRef } from 'vue';
import {
BaseRates,
RatesComposite,
RatesOracle,
ratesMedianPolicy,
} from '@xocash/stack';
import { Mixin } from '@/utils/mixin';
/**
*/
export class Rates extends Mixin([BaseRates]){
// Our rates adapter.
private readonly adapter: BaseRates;
// Our individual rates as a reactives.
// NOTE: Because our adapter sends us updates one by one, we must use triggerRef to update this.
private rates = ref<{ [key: string]: number | undefined }>({});
static async from(refreshMilliseconds = 60_000) {
const oracle = await RatesOracle.from();
const compositeAdapter = new RatesComposite(
[oracle],
ratesMedianPolicy,
refreshMilliseconds
);
return new Rates(compositeAdapter);
}
constructor(ratesAdapter: BaseRates) {
super(ratesAdapter);
this.adapter = ratesAdapter;
this.adapter.on(
'rateUpdated',
({ numeratorUnitCode, denominatorUnitCode, price }) => {
if (denominatorUnitCode !== 'BCH') {
return;
}
this.rates.value[numeratorUnitCode] = price;
triggerRef(this.rates);
}
);
}
toBCH(amount: number, fromCurrency: string): number {
const rate = this.rates.value[fromCurrency];
if (rate === undefined) {
return 0;
// throw new Error(`Currency ${fromCurrency} not supported.`);
}
return Rates.roundToDigits(amount / rate, 8);
}
toSats(amount: number, fromCurrency: string): number {
return this.toBCH(amount * 100_000_000, fromCurrency);
}
fromSats(satoshis: number | bigint, targetCurrency: string): number {
if (typeof satoshis === 'bigint') {
satoshis = Number(satoshis);
}
return this.fromBCH(satoshis / 100_000_000, targetCurrency);
}
fromBCH(amount: number, targetCurrency: string): number {
const rate = this.rates.value[targetCurrency];
if (rate === undefined) {
return 0;
// throw new Error(`Currency ${targetCurrency} not supported.`);
}
return Rates.roundToDigits(amount * rate, 2);
}
formatSats(sats: number | bigint, targetCurrency: string) {
const amount = this.fromSats(sats, targetCurrency);
return this.formatCurrency(amount, targetCurrency);
}
static roundToDigits(numberToRound: number, digits: number): number {
// Set the options of the Number Format object.
const options: Intl.NumberFormatOptions = {
style: 'decimal',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
};
// Create an instance of number format using above options.
// NOTE: We force the locale to en-GB so that the number is formatted correctly (e.g. with a decimal, not a comma).
const numberFormat = new Intl.NumberFormat('en-GB', options);
// Format the number.
const formattedAmount = numberFormat.format(numberToRound);
// Return the formatted number.
return Number(formattedAmount);
}
}

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 }
);
}
}

223
src/services/wallets.ts Normal file
View File

@@ -0,0 +1,223 @@
import { shallowReactive, toRaw } from 'vue';
import {
type BaseStorage,
type WalletHDEntropy,
type WalletHDDerivationData,
type WalletHDGenesisData,
type WalletBlockchain,
type WalletP2PKHGenesisData,
type WalletName,
StoreInMemory,
BaseStore,
WalletHD,
WalletP2PKH,
BaseWallet,
Mnemonic,
type MnemonicRaw,
} from '@xocash/stack';
import { ReactiveWallet } from 'src/services/wallet.js';
// Remove our definition for using Mnemonic in the WalletHDGenesisData type, we store them as MnemonicRaw so we can instantiate them.
export type AppWalletHDGenesisData = // Remove mnemonic option
((
| Exclude<WalletHDEntropy, { mnemonic: Mnemonic }>
// Add mnemonic option with MnemonicRaw type
| { mnemonic: MnemonicRaw }
) &
WalletHDDerivationData) & {
type: 'WalletHD';
name: string;
};
export type AppWalletP2PKHGenesisData = WalletP2PKHGenesisData & {
type: 'WalletP2PKH';
name: string;
};
export type AppWalletData = AppWalletP2PKHGenesisData | AppWalletHDGenesisData;
export type AppWalletStore = { [uid: string]: AppWalletData };
export type WalletsStore = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export type WalletsSupported = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export class Wallets {
public walletsStore?: BaseStore;
public wallets = shallowReactive<{
[walletId: string]: ReactiveWallet;
}>({});
constructor(
public blockchain: WalletBlockchain,
public storage: BaseStorage,
public cache: BaseStorage,
) {}
async start(): Promise<void> {
// Get or create a store for WalletGenesis data.
this.walletsStore = await this.storage.createOrGetStore('wallets', {
syncInMemory: true,
});
// Get all child wallets.
const wallets = await this.walletsStore.all<WalletsSupported>();
// Initialize each wallet.
const walletInitPromises = Object.entries(wallets).map(
async ([walletId, genesisData]) => {
// Get the cache for this wallet.
const cacheStore = await this.cache.createOrGetStore(walletId, {
syncInMemory: true,
});
// If this is a WalletHD type...
if (genesisData.type === 'WalletHD') {
// Convert the mnemonic from Raw to Instance if we are using a mnemonic.
const walletData =
'mnemonic' in genesisData
? {
...genesisData,
mnemonic: Mnemonic.fromRaw(genesisData.mnemonic),
}
: genesisData;
// Instantiate the wallet.
const wallet = await WalletHD.from(
{
blockchain: this.blockchain,
cache: cacheStore,
},
walletData
);
// Add a persistent store for our activities.
await wallet.activities.store.addStore(
await this.storage.createStore(
`activities_${await wallet.getId()}`,
{}
)
);
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
} else if (genesisData.type === 'WalletP2PKH') {
// Instantiate the wallet.
const wallet = await WalletP2PKH.from(
{
blockchain: this.blockchain,
},
genesisData
);
// Add a persistent store for our activities.
await wallet.activities.store.addStore(
await this.storage.createStore(
`activities_${await wallet.getId()}`,
{}
)
);
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
}
// Otherwise, this is an unsupported wallet type.
else {
console.warn(
`${walletId} has an unsupported wallet type`,
genesisData
);
}
}
);
console.time('walletInit');
// Wait for all wallets to initialize.
await Promise.all(walletInitPromises);
console.timeEnd('walletInit');
}
async createWallet(
walletData: WalletHDGenesisData | WalletP2PKHGenesisData
): Promise<string> {
// Extract the raw Wallet Data.
// NOTE: This is a Vue Quirk. We cannot store reactives as structuredClones cannot be performed on them.
// Thus, we must extract the raw value.
const walletDataRaw = toRaw(walletData);
const walletCache = StoreInMemory.from();
let wallet: BaseWallet;
const dependencies = {
blockchain: this.blockchain,
cache: walletCache,
};
if (walletDataRaw.type === 'WalletHD') {
wallet = await WalletHD.from(dependencies, walletDataRaw);
} else if (walletData.type === 'WalletP2PKH') {
wallet = await WalletP2PKH.from(dependencies, walletDataRaw);
} else {
throw new Error(`Unsupported wallet data: ${walletData}`);
}
// Get the Wallet ID.
const walletId = await wallet.getId();
// Add the wallet to our store.
await this.walletsStore?.set(walletId, {
...walletData,
mnemonic:
'mnemonic' in walletData ? walletData.mnemonic.toRaw() : undefined,
});
// Add a persistent store for this wallet's activities.
await wallet.activities.store.addStore(
await this.storage.createStore(`activities_${await wallet.getId()}`, {})
);
// Determine the new Wallet's Default name.
const walletCount = Object.keys(this.wallets).length;
const walletName =
walletCount === 0 ? 'Default Wallet' : `Wallet ${walletCount + 1}`;
// Set the default metadata for this wallet.
// NOTE: Give it a timestamp of 0. This is to support wallet syncing. Without it, this new activity would overwrite the current wallet name activity from the server/other wallets
await wallet.activities.add<WalletName>({
key: 'WalletName',
value: walletName,
timestamp: BigInt(0),
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
return walletId;
}
async deleteWallet(walletId: string): Promise<void> {
await this.walletsStore?.delete(walletId);
await this.storage.deleteStore(`activities_${walletId}`);
delete this.wallets[walletId];
}
}