Use cache for wallets. Move closer to offline mode. Clean up home page. Add tx count to addresses
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user