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

742
src/pages/AddressesPage.vue Normal file
View File

@@ -0,0 +1,742 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, shallowRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BlockchainElectrum,
WalletP2PKHWatch,
WalletHDWatch,
HDPublicNode,
PublicKey,
type Address,
} from '@xocash/stack';
import { ReactiveWallet } from '../services/wallet.js';
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
type WalletType = 'p2pkh' | 'hd';
type AddressInfo = {
address: Address;
chainPath: number;
index: number;
cashAddr: string;
derivationPath: string;
};
//-----------------------------------------------------------------------------
// Route & Router
//-----------------------------------------------------------------------------
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 = ref<WalletType | null>(null);
/** Loading state. */
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('');
//-----------------------------------------------------------------------------
// Wallet Type Detection
//-----------------------------------------------------------------------------
function isXPub(key: string): boolean {
return key.startsWith('xpub') || key.startsWith('tpub');
}
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
//-----------------------------------------------------------------------------
async function initializeWallet() {
isLoading.value = true;
error.value = null;
try {
const type = detectWalletType(key.value);
if (!type) {
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(baseWallet);
await wallet.value.start();
// Build the address list.
buildAddressList();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err);
} finally {
isLoading.value = false;
}
}
/**
* Builds the address list from the wallet.
*/
function buildAddressList() {
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);
addresses.push({
address,
chainPath: chainPathNum,
index,
cashAddr: address.toCashAddr(),
derivationPath: `${basePath}/${chainPathNum}/${index}`,
});
});
}
} else {
// P2PKH Wallet - single address.
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
const address = p2pkhWallet.publicKey.deriveAddress();
addresses.push({
address,
chainPath: 0,
index: 0,
cashAddr: address.toCashAddr(),
derivationPath: '—',
});
}
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();
});
onUnmounted(() => {
cleanup();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/** 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, idx) 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>
<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;
}
.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>