Use cache for wallets. Move closer to offline mode. Clean up home page. Add tx count to addresses

This commit is contained in:
2026-01-20 23:21:09 +11:00
parent 463524b6f6
commit 658358ea32
16 changed files with 1465 additions and 1103 deletions

View File

@@ -1,18 +1,21 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, shallowRef } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BlockchainElectrum,
PublicKey,
BaseWallet,
Bytes,
type Address,
} from '@xocash/stack';
import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js';
import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js';
import { HDPublicNode } from '../xo-extensions/hd-public-node.js';
import {
type WalletHDWatch,
type WalletP2PKHWatch,
} from '../xo-extensions/index.js';
import { ReactiveWallet } from '../services/wallet.js';
import { binToHex } from '@bitauth/libauth';
import { useApp } from '../services/app.js';
//-----------------------------------------------------------------------------
// Types
@@ -26,12 +29,16 @@ type AddressInfo = {
index: number;
cashAddr: string;
derivationPath: string;
lockscriptHex: string;
txCount: number;
utxoCount: number;
};
//-----------------------------------------------------------------------------
// Route & Router
// App & Router
//-----------------------------------------------------------------------------
const app = useApp();
const route = useRoute();
const router = useRouter();
@@ -46,7 +53,11 @@ const key = computed(() => route.params.key as string);
const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */
const walletType = ref<WalletType | null>(null);
const walletType = computed<WalletType | null>(() => {
if (isXPub(key.value)) return 'hd';
if (isPublicKey(key.value)) return 'p2pkh';
return null;
});
/** Loading state. */
const isLoading = ref(true);
@@ -54,120 +65,82 @@ const isLoading = ref(true);
/** Error message if wallet creation fails. */
const error = ref<string | null>(null);
/** The reactive wallet instance. */
const wallet = shallowRef<ReactiveWallet | null>(null);
/** The blockchain adapter (shared). */
let blockchain: BlockchainElectrum | null = null;
/** Derived addresses with metadata. */
const addressList = ref<AddressInfo[]>([]);
/** Filter for chain path (0 = external, 1 = internal, null = all). */
const chainPathFilter = ref<number | null>(null);
/** Search query for filtering addresses. */
const searchQuery = ref('');
//-----------------------------------------------------------------------------
// Computed Wallet
//-----------------------------------------------------------------------------
/** The genesis data used to derive the wallet ID. */
const genesisData = computed(() => {
return walletType.value === 'hd'
? {
type: 'WalletHDWatch',
xpub: key.value,
derivationPath: accountPath.value ?? '',
} as const
: {
type: 'WalletP2PKHWatch',
publicKey: key.value,
} as const;
});
/** The wallet instance from the app's wallet manager. */
const wallet = computed(() => {
const walletId = BaseWallet.deriveId(genesisData.value);
return app.wallets.wallets[Bytes.from(walletId).toHex()];
});
//-----------------------------------------------------------------------------
// Wallet Type Detection
//-----------------------------------------------------------------------------
/**
* Determines if a key is an xpub (extended public key).
*
* @param key - The key to check.
* @returns True if the key is an xpub.
*/
function isXPub(key: string): boolean {
return key.startsWith('xpub') || key.startsWith('tpub');
}
/**
* Determines if a key is a valid compressed public key.
*
* @param key - The key to check.
* @returns True if the key is a valid public key.
*/
function isPublicKey(key: string): boolean {
if (key.length !== 66) return false;
if (!key.startsWith('02') && !key.startsWith('03')) return false;
return /^[0-9a-fA-F]+$/.test(key);
}
function detectWalletType(key: string): WalletType | null {
if (isXPub(key)) return 'hd';
if (isPublicKey(key)) return 'p2pkh';
return null;
}
//-----------------------------------------------------------------------------
// Wallet Creation
// Wallet Initialization
//-----------------------------------------------------------------------------
/**
* Creates and initializes the wallet if it doesn't exist.
*/
async function initializeWallet() {
isLoading.value = true;
error.value = null;
try {
const type = detectWalletType(key.value);
if (!type) {
if (!walletType.value) {
throw new Error(
'Invalid key format. Please provide a valid public key or xpub.'
);
}
walletType.value = type;
blockchain = new BlockchainElectrum({
servers: ['bch.imaginary.cash'],
});
await blockchain.start();
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
if (type === 'hd') {
let hdNode: HDPublicNode;
try {
hdNode = HDPublicNode.fromXPub(key.value);
} catch {
throw new Error('Invalid xpub format.');
}
// Determine the xpub to use and the derivation path for display.
let accountXpub: string;
let displayPath: string;
if (accountPath.value) {
// Master xpub provided - derive the account-level xpub.
try {
const accountNode = hdNode.derivePath(accountPath.value);
accountXpub = accountNode.toXPub();
displayPath = `m/${accountPath.value}`;
} catch (e) {
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else {
// Already an account-level xpub.
accountXpub = key.value;
displayPath = "m/44'/145'/0'";
}
baseWallet = await WalletHDWatch.from(
{ blockchain },
{
xpub: accountXpub,
derivationPath: displayPath,
}
);
} else {
try {
PublicKey.fromHex(key.value);
} catch {
throw new Error('Invalid public key format.');
}
baseWallet = await WalletP2PKHWatch.from(
{ blockchain },
{ publicKey: key.value }
);
}
wallet.value = new ReactiveWallet({ wallet: baseWallet });
await wallet.value.start();
// Build the address list.
buildAddressList();
// Create the wallet via the app's wallet manager.
await app.wallets.createWallet(genesisData.value);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err);
@@ -176,11 +149,76 @@ async function initializeWallet() {
}
}
//-----------------------------------------------------------------------------
// Lifecycle
//-----------------------------------------------------------------------------
onMounted(() => {
initializeWallet();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/**
* Builds the address list from the wallet.
* Map of transaction counts per address lockscript.
* Key: lockscript hex, Value: number of transactions involving this address.
*/
function buildAddressList() {
if (!wallet.value) return;
const txCountsByLockscript = computed<Map<string, number>>(() => {
if (!wallet.value) return new Map();
const counts = new Map<string, number>();
// Iterate through all transactions in the wallet.
wallet.value.transactions.value.forEach((tx) => {
const seenLockscripts = new Set<string>();
// Check outputs for addresses belonging to this wallet.
tx.transaction.getOutputs().forEach((output) => {
const lockscriptHex = binToHex(output.lockingBytecode);
// Only count once per transaction.
if (!seenLockscripts.has(lockscriptHex)) {
seenLockscripts.add(lockscriptHex);
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
}
});
// Check inputs (source outputs) for addresses belonging to this wallet.
tx.sourceOutputs.forEach((sourceOutput) => {
const lockscriptHex = binToHex(sourceOutput.lockingBytecode);
// Only count once per transaction.
if (!seenLockscripts.has(lockscriptHex)) {
seenLockscripts.add(lockscriptHex);
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
}
});
});
return counts;
});
/**
* Map of UTXO counts per address lockscript.
* Key: lockscript hex, Value: number of unspent outputs for this address.
*/
const utxoCountsByLockscript = computed<Map<string, number>>(() => {
if (!wallet.value) return new Map();
const counts = new Map<string, number>();
// Iterate through all UTXOs in the wallet.
wallet.value.unspents.value.forEach((blockchainUtxo) => {
const lockscriptHex = binToHex(blockchainUtxo.utxo.output.lockingBytecode);
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
});
return counts;
});
/** Derived addresses with metadata built from the wallet. */
const addressList = computed<AddressInfo[]>(() => {
if (!wallet.value) return [];
const addresses: AddressInfo[] = [];
const baseWallet = wallet.value.wallet;
@@ -194,12 +232,16 @@ function buildAddressList() {
childWallets.forEach((childWallet, index) => {
const address = childWallet.publicKey.deriveAddress();
const chainPathNum = Number(chainPath);
const lockscriptHex = address.toLockscriptHex();
addresses.push({
address,
chainPath: chainPathNum,
index,
cashAddr: address.toCashAddr(),
derivationPath: `${basePath}/${chainPathNum}/${index}`,
lockscriptHex,
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
});
});
}
@@ -207,47 +249,22 @@ function buildAddressList() {
// P2PKH Wallet - single address.
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
const address = p2pkhWallet.publicKey.deriveAddress();
const lockscriptHex = address.toLockscriptHex();
addresses.push({
address,
chainPath: 0,
index: 0,
cashAddr: address.toCashAddr(),
derivationPath: '—',
lockscriptHex,
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
});
}
addressList.value = addresses;
}
async function cleanup() {
if (wallet.value) {
await wallet.value.stop();
await wallet.value.destroy();
wallet.value = null;
}
if (blockchain) {
await blockchain.stop();
blockchain = null;
}
}
//-----------------------------------------------------------------------------
// Lifecycle
//-----------------------------------------------------------------------------
onMounted(() => {
initializeWallet();
return addresses;
});
onUnmounted(() => {
cleanup();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/** Filtered addresses based on chain path and search query. */
const filteredAddresses = computed(() => {
let result = addressList.value;
@@ -383,7 +400,7 @@ function setFilter(filter: number | null) {
<!-- Address List -->
<div class="address-list">
<div
v-for="(addr, idx) in filteredAddresses"
v-for="addr in filteredAddresses"
:key="addr.cashAddr"
class="address-item"
>
@@ -396,6 +413,14 @@ function setFilter(filter: number | null) {
<span class="derivation-path">{{ addr.derivationPath }}</span>
</div>
<code class="address-value">{{ addr.cashAddr }}</code>
<div class="address-stats">
<span class="stat-badge" :class="{ inactive: addr.txCount === 0 }">
{{ addr.txCount }} tx{{ addr.txCount !== 1 ? 's' : '' }}
</span>
<span class="stat-badge" :class="{ inactive: addr.utxoCount === 0 }">
{{ addr.utxoCount }} UTXO{{ addr.utxoCount !== 1 ? 's' : '' }}
</span>
</div>
</div>
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
📋
@@ -715,6 +740,31 @@ function setFilter(filter: number | null) {
word-break: break-all;
}
.address-stats {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.stat-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 500;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
border-radius: 4px;
background: rgba(0, 212, 255, 0.1);
color: #00d4ff;
border: 1px solid rgba(0, 212, 255, 0.2);
}
.stat-badge.inactive {
background: rgba(255, 255, 255, 0.03);
color: #4a5568;
border-color: #2d3748;
}
.copy-button {
padding: 0.5rem;
font-size: 1rem;