Files
IncomeTax/src/pages/AddressesPage.vue

794 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BaseWallet,
Bytes,
type Address,
} from '@xocash/stack';
import {
type WalletHDWatch,
type WalletP2PKHWatch,
} from '../xo-extensions/index.js';
import { binToHex } from '@bitauth/libauth';
import { useApp } from '../services/app.js';
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
type WalletType = 'p2pkh' | 'hd';
type AddressInfo = {
address: Address;
chainPath: number;
index: number;
cashAddr: string;
derivationPath: string;
lockscriptHex: string;
txCount: number;
utxoCount: number;
};
//-----------------------------------------------------------------------------
// App & Router
//-----------------------------------------------------------------------------
const app = useApp();
const route = useRoute();
const router = useRouter();
//-----------------------------------------------------------------------------
// State
//-----------------------------------------------------------------------------
/** The wallet key from the route param. */
const key = computed(() => route.params.key as string);
/** The account path from query params (for master xpubs). */
const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */
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);
/** Error message if wallet creation fails. */
const error = ref<string | null>(null);
/** 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);
}
//-----------------------------------------------------------------------------
// Wallet Initialization
//-----------------------------------------------------------------------------
/**
* Creates and initializes the wallet if it doesn't exist.
*/
async function initializeWallet() {
isLoading.value = true;
error.value = null;
try {
if (!walletType.value) {
throw new Error(
'Invalid key format. Please provide a valid public key or xpub.'
);
}
// 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);
} finally {
isLoading.value = false;
}
}
//-----------------------------------------------------------------------------
// Lifecycle
//-----------------------------------------------------------------------------
onMounted(() => {
initializeWallet();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/**
* Map of transaction counts per address lockscript.
* Key: lockscript hex, Value: number of transactions involving this address.
*/
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;
if (walletType.value === 'hd' && 'wallets' in baseWallet) {
// HD Wallet - iterate through child wallets.
const hdWallet = baseWallet as WalletHDWatch;
const basePath = hdWallet.genesisData.derivationPath;
for (const [chainPath, childWallets] of Object.entries(hdWallet.wallets)) {
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,
});
});
}
} else {
// 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,
});
}
return addresses;
});
/** Filtered addresses based on chain path and search query. */
const filteredAddresses = computed(() => {
let result = addressList.value;
// Filter by chain path.
if (chainPathFilter.value !== null) {
result = result.filter((a) => a.chainPath === chainPathFilter.value);
}
// Filter by search query.
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter((a) => a.cashAddr.toLowerCase().includes(query));
}
return result;
});
/** Count of external addresses. */
const externalCount = computed(() =>
addressList.value.filter((a) => a.chainPath === 0).length
);
/** Count of internal (change) addresses. */
const internalCount = computed(() =>
addressList.value.filter((a) => a.chainPath === 1).length
);
//-----------------------------------------------------------------------------
// Actions
//-----------------------------------------------------------------------------
function goBack() {
router.push({
name: 'wallet',
params: { key: key.value },
query: accountPath.value ? { accountPath: accountPath.value } : {},
});
}
function copyAddress(cashAddr: string) {
navigator.clipboard.writeText(cashAddr);
}
function getChainLabel(chainPath: number): string {
return chainPath === 0 ? 'External' : 'Change';
}
function setFilter(filter: number | null) {
chainPathFilter.value = filter;
}
</script>
<template>
<div class="addresses-page">
<!-- Header -->
<header class="header">
<button class="back-button" @click="goBack">
Back to Wallet
</button>
<div class="wallet-type-badge" v-if="walletType">
{{ walletType === 'hd' ? 'HD Wallet' : 'P2PKH Wallet' }}
</div>
</header>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>Loading addresses...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<h2>Error</h2>
<p>{{ error }}</p>
<button class="retry-button" @click="goBack">
Go Back
</button>
</div>
<!-- Addresses Content -->
<div v-else class="addresses-content">
<h1 class="page-title">Addresses</h1>
<!-- Stats -->
<div class="stats-row" v-if="walletType === 'hd'">
<div class="stat-card">
<div class="stat-value">{{ addressList.length }}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ externalCount }}</div>
<div class="stat-label">External</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ internalCount }}</div>
<div class="stat-label">Change</div>
</div>
</div>
<!-- Filters -->
<div class="filters" v-if="walletType === 'hd'">
<div class="filter-buttons">
<button
:class="['filter-btn', { active: chainPathFilter === null }]"
@click="setFilter(null)"
>
All
</button>
<button
:class="['filter-btn', { active: chainPathFilter === 0 }]"
@click="setFilter(0)"
>
External
</button>
<button
:class="['filter-btn', { active: chainPathFilter === 1 }]"
@click="setFilter(1)"
>
Change
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search addresses..."
class="search-input"
/>
</div>
<!-- Address List -->
<div class="address-list">
<div
v-for="addr in filteredAddresses"
:key="addr.cashAddr"
class="address-item"
>
<div class="address-info">
<div class="address-index">
<span class="chain-badge" :class="addr.chainPath === 0 ? 'external' : 'internal'">
{{ getChainLabel(addr.chainPath) }}
</span>
<span class="index-number">#{{ addr.index }}</span>
<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">
📋
</button>
</div>
<div v-if="filteredAddresses.length === 0" class="empty-state">
<p v-if="searchQuery">No addresses match your search.</p>
<p v-else>No addresses found.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.addresses-page {
min-height: 100vh;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
color: #fff;
padding: 1.5rem;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.back-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #a0aec0;
cursor: pointer;
transition: all 0.2s ease;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #4a5568;
}
.wallet-type-badge {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid #2d3748;
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: #a0aec0;
font-size: 1.125rem;
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.error-state p {
color: #fc8181;
margin-bottom: 1.5rem;
}
.retry-button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
border: none;
border-radius: 8px;
background: #2d3748;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-button:hover {
background: #4a5568;
}
/* Content */
.addresses-content {
max-width: 900px;
margin: 0 auto;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* Filters */
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
}
.filter-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: #a0aec0;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.filter-btn.active {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
border-color: rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.search-input::placeholder {
color: #4a5568;
}
.search-input:focus {
outline: none;
border-color: #00d4ff;
}
/* Address List */
.address-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.address-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1rem 1.25rem;
transition: all 0.2s ease;
}
.address-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #4a5568;
}
.address-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.address-index {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chain-badge {
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 4px;
}
.chain-badge.external {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
}
.chain-badge.internal {
background: rgba(237, 137, 54, 0.2);
color: #ed8936;
}
.index-number {
font-size: 0.75rem;
color: #718096;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.derivation-path {
font-size: 0.7rem;
color: #4a5568;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
margin-left: 0.5rem;
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.address-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: #a0aec0;
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;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.copy-button:hover {
background: rgba(255, 255, 255, 0.1);
transform: scale(1.05);
}
.empty-state {
text-align: center;
padding: 3rem;
color: #718096;
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
border: 1px dashed #2d3748;
}
</style>