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

16
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "www",
"version": "0.0.0",
"dependencies": {
"@generalprotocols/oracle-client": "^0.0.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@tailwindcss/vite": "^4.1.17",
"@xocash/stack": "file:../stack/packages/stack",
"class-variance-authority": "^0.7.1",
@@ -524,13 +524,13 @@
}
},
"node_modules/@generalprotocols/oracle-client": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1.tgz",
"integrity": "sha512-TmnPCUm1VYeWK7SkWV8w3jAjUOW9SFLPgF8ni06ouaGYyer/35oZ5OW+6R3kpFtRWhO4rlnT9HYL8SHE3Yj0+A==",
"version": "0.0.1-development.11945476152",
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1-development.11945476152.tgz",
"integrity": "sha512-1Q43NfacrVfSbatCREzIX7U3DgACBUegNjV977y+pql+Fve03bOyTiUQClevymCi7M3T6mCyMzSEGT8zA6EZtQ==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"zod": "^3.24.3"
"zod": "^4.1.12"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -2173,9 +2173,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@generalprotocols/oracle-client": "^0.0.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@tailwindcss/vite": "^4.1.17",
"@xocash/stack": "file:../stack/packages/stack",
"class-variance-authority": "^0.7.1",

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Mnemonic, HDPrivateNode } from '@xocash/stack';
/**
* Props for the MnemonicConverter component.
*/
interface Props {
/** Whether to show the security instructions/help box. */
showInstructions?: boolean;
}
withDefaults(defineProps<Props>(), {
showInstructions: true,
});
const router = useRouter();
/** The mnemonic input. */
const mnemonicInput = ref('');
/** The derivation path for xpub generation. */
const xpubDerivationPath = ref("m/44'/145'/0'");
/** The generated xpub. */
const generatedXpub = ref('');
/** Error from mnemonic conversion. */
const converterError = ref('');
/** Whether conversion is in progress. */
const isConverting = ref(false);
/**
* Convert mnemonic to xpub.
*/
async function convertMnemonicToXpub() {
const phrase = mnemonicInput.value.trim();
if (!phrase) {
converterError.value = 'Please enter a mnemonic phrase';
return;
}
isConverting.value = true;
converterError.value = '';
generatedXpub.value = '';
try {
// Parse and validate the mnemonic.
const mnemonic = Mnemonic.fromPhrase(phrase);
// Create the HD private node from the mnemonic.
const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic);
// Derive to the account level (hardened path).
const accountNode = hdPrivateNode.derivePath(xpubDerivationPath.value);
// Get the public node and export as xpub.
const hdPublicNode = accountNode.deriveHDPublicNode();
generatedXpub.value = hdPublicNode.toXPub();
} catch (e) {
converterError.value = e instanceof Error ? e.message : 'Failed to convert mnemonic';
} finally {
isConverting.value = false;
}
}
/**
* Use the generated xpub and navigate directly to the wallet page.
*/
function useGeneratedXpub() {
if (generatedXpub.value) {
const xpub = generatedXpub.value;
// Clear sensitive data immediately.
mnemonicInput.value = '';
generatedXpub.value = '';
// Navigate directly to the wallet page.
router.push({ name: 'wallet', params: { key: xpub } });
}
}
/**
* Copy xpub to clipboard.
*/
function copyXpub() {
if (generatedXpub.value) {
navigator.clipboard.writeText(generatedXpub.value);
}
}
</script>
<template>
<div class="mnemonic-converter">
<!-- Security Warning -->
<div v-if="showInstructions" class="security-warning">
<span class="warning-icon">🔐</span>
<div>
<strong>Security Notice</strong>
<p>Your mnemonic is processed locally and never sent anywhere. Clear it after use.</p>
</div>
</div>
<div class="converter-form">
<label class="input-label">Mnemonic Phrase</label>
<textarea
v-model="mnemonicInput"
placeholder="Enter your 12 or 24 word recovery phrase..."
class="mnemonic-input"
rows="3"
></textarea>
<label class="input-label">Derivation Path</label>
<div class="path-row">
<input
v-model="xpubDerivationPath"
type="text"
class="path-input"
/>
<div class="path-presets">
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/145'/0'` }"
@click="xpubDerivationPath = `m/44'/145'/0'`"
>
BCH
</button>
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/0'/0'` }"
@click="xpubDerivationPath = `m/44'/0'/0'`"
>
BTC
</button>
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/245'/0'` }"
@click="xpubDerivationPath = `m/44'/245'/0'`"
>
BCH Alt
</button>
</div>
</div>
<button
class="convert-button"
@click="convertMnemonicToXpub"
:disabled="isConverting"
>
{{ isConverting ? 'Converting...' : 'Generate xpub' }}
</button>
<p v-if="converterError" class="error-message">{{ converterError }}</p>
<!-- Generated xpub -->
<div v-if="generatedXpub" class="generated-xpub">
<label class="input-label">Generated xpub</label>
<div class="xpub-display">
<code>{{ generatedXpub }}</code>
</div>
<div class="xpub-actions">
<button class="action-btn" @click="copyXpub">
📋 Copy
</button>
<button class="action-btn primary" @click="useGeneratedXpub">
Use this xpub
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.mnemonic-converter {
width: 100%;
}
.security-warning {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(72, 187, 120, 0.1);
border: 1px solid rgba(72, 187, 120, 0.3);
border-radius: 8px;
margin-bottom: 1.25rem;
}
.security-warning .warning-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.security-warning strong {
display: block;
font-size: 0.875rem;
color: #48bb78;
margin-bottom: 0.25rem;
}
.security-warning p {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
}
.converter-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.mnemonic-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
resize: vertical;
}
.mnemonic-input::placeholder {
color: #4a5568;
}
.mnemonic-input:focus {
outline: none;
border-color: #00d4ff;
}
.path-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.path-input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.path-input::placeholder {
color: #4a5568;
}
.path-input:focus {
outline: none;
border-color: #00d4ff;
}
.path-presets {
display: flex;
gap: 0.25rem;
}
.preset-btn {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid #2d3748;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: #a0aec0;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: rgba(0, 212, 255, 0.1);
border-color: #00d4ff;
color: #00d4ff;
}
.preset-btn.active {
background: rgba(0, 212, 255, 0.2);
border-color: #00d4ff;
color: #00d4ff;
}
.convert-button {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.convert-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
.convert-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #fc8181;
font-size: 0.875rem;
}
.generated-xpub {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.xpub-display {
padding: 0.75rem 1rem;
background: rgba(0, 212, 255, 0.05);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
overflow-x: auto;
}
.xpub-display code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.7rem;
color: #00d4ff;
word-break: break-all;
}
.xpub-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.action-btn {
flex: 1;
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;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #4a5568;
}
.action-btn.primary {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border: none;
color: #fff;
}
.action-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useApp } from '../services/app.js';
const app = useApp();
const router = useRouter();
/**
* Get the wallet identifier (xpub or public key) from a wallet.
* @param wallet - The wallet object.
* @returns The wallet identifier string.
*/
function getWalletId(wallet: any): string {
return wallet.wallet.genesisData.type === 'WalletHDWatch'
? wallet.wallet.genesisData.xpub
: wallet.wallet.genesisData.publicKey;
}
/**
* Navigate to the wallet view page.
* @param walletId - The wallet identifier to view.
*/
function viewWallet(walletId: string) {
router.push({ name: 'wallet', params: { key: walletId } });
}
</script>
<template>
<div class="wallet-history">
<div
class="wallet-item"
v-for="wallet in Object.values(app.wallets.wallets)"
:key="getWalletId(wallet)"
>
<div class="wallet-name">{{ getWalletId(wallet) }}</div>
<!-- Balance display (currently commented out)
<div class="wallet-balance">{{ wallet.wallet.balanceBCH.value.toLocaleString() }} BCH</div>
<div class="wallet-balance-sats">{{ wallet.balanceSats.value.toLocaleString() }} sats</div>
-->
<button class="view-button" @click="viewWallet(getWalletId(wallet))">
View Wallet
</button>
</div>
<!-- Empty state when no wallets are available -->
<div v-if="Object.keys(app.wallets.wallets).length === 0" class="empty-state">
<p>No wallet history yet</p>
<p class="empty-hint">Wallets you view will appear here</p>
</div>
</div>
</template>
<style scoped>
.wallet-history {
width: 100%;
}
.wallet-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.wallet-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #4a5568;
}
.wallet-item:last-child {
margin-bottom: 0;
}
.wallet-name {
flex: 1;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
color: #a0aec0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallet-balance {
font-size: 0.875rem;
font-weight: 600;
color: #00d4ff;
}
.wallet-balance-sats {
font-size: 0.75rem;
color: #718096;
}
.view-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.view-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.3);
}
.view-button:active {
transform: translateY(0);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 2rem;
color: #718096;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
.empty-hint {
margin-top: 0.5rem !important;
font-size: 0.75rem !important;
color: #4a5568 !important;
}
</style>

View File

@@ -0,0 +1,416 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
/** The wallet key input (public key or xpub). */
const walletKey = ref('');
/** Whether to show advanced options. */
const showAdvanced = ref(false);
/** The account path for master xpubs. */
const accountPath = ref('');
/** Whether this is a master xpub (requires account path derivation). */
const isMasterXpub = ref(false);
/** Error message for invalid input. */
const error = ref('');
/** Detect if the input looks like an xpub. */
const isXpub = computed(() => {
const key = walletKey.value.trim();
return key.startsWith('xpub') || key.startsWith('tpub');
});
/**
* Navigate to the wallet view page.
*/
async function viewWallet() {
const key = walletKey.value.trim();
if (!key) {
error.value = 'Please enter a public key or xpub';
return;
}
error.value = '';
// Build query params for advanced options.
const query: Record<string, string> = {};
if (isMasterXpub.value && accountPath.value.trim()) {
query.accountPath = accountPath.value.trim();
}
router.push({ name: 'wallet', params: { key }, query });
}
</script>
<template>
<div class="xpub-input-component">
<div class="input-wrapper">
<input
v-model="walletKey"
type="text"
placeholder="Enter public key or xpub..."
class="key-input"
@keyup.enter="viewWallet"
/>
<button class="view-button" @click="viewWallet">
View Wallet
</button>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
<!-- Advanced Options Toggle -->
<div v-if="isXpub" class="advanced-toggle">
<button
class="toggle-button"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? '▼' : '▶' }} Advanced Options
</button>
</div>
<!-- Advanced Options Panel -->
<div v-if="showAdvanced && isXpub" class="advanced-panel">
<div class="option-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="isMasterXpub"
class="checkbox"
/>
<span>Derive from a non-hardened path (e.g., 0/0)</span>
</label>
</div>
<div v-if="isMasterXpub" class="path-input-section">
<label class="input-label">Derivation Path (non-hardened only)</label>
<input
v-model="accountPath"
type="text"
placeholder="e.g., 0 or 0/0"
class="path-input"
/>
<p class="input-hint">
Only non-hardened paths work with xpub (no apostrophes)
</p>
<div class="warning-box">
<span class="warning-icon"></span>
<div class="warning-content">
<strong>Hardened paths require private keys</strong>
<p>
Paths like <code>44'/145'/0'</code> cannot be derived from an xpub.
Export the account-level xpub directly from your wallet instead.
</p>
</div>
</div>
<div class="help-section">
<p class="help-title">How to get the correct xpub:</p>
<ul class="help-list">
<li><strong>Electron Cash:</strong> Wallet → Information → copy the "Master Public Key" (this is already at account level)</li>
<li><strong>Other wallets:</strong> Export the xpub from the account you want to watch</li>
</ul>
</div>
</div>
</div>
<div class="examples">
<p class="examples-title">Supported Inputs:</p>
<ul class="examples-list">
<li>
<strong>Public Key (P2PKH):</strong>
<code>02...</code> or <code>03...</code> (33 bytes hex, 66 characters)
</li>
<li>
<strong>Account-level xpub:</strong>
<code>xpub...</code> — Export from Electron Cash via Wallet → Information
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.xpub-input-component {
width: 100%;
}
.input-wrapper {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.key-input {
flex: 1;
padding: 1rem 1.25rem;
font-size: 1rem;
border: 2px solid #2d3748;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
transition: all 0.2s ease;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.key-input::placeholder {
color: #4a5568;
}
.key-input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.view-button {
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.view-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.3);
}
.view-button:active {
transform: translateY(0);
}
.error-message {
color: #fc8181;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Advanced Options */
.advanced-toggle {
margin-bottom: 1rem;
}
.toggle-button {
background: none;
border: none;
color: #a0aec0;
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem 0;
transition: color 0.2s ease;
}
.toggle-button:hover {
color: #00d4ff;
}
.advanced-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.option-row {
margin-bottom: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
color: #a0aec0;
font-size: 0.875rem;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
accent-color: #00d4ff;
cursor: pointer;
}
.path-input-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.input-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.path-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.path-input::placeholder {
color: #4a5568;
}
.path-input:focus {
outline: none;
border-color: #00d4ff;
}
.input-hint {
font-size: 0.75rem;
color: #718096;
margin-top: 0.5rem;
}
/* Warning Box */
.warning-box {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(237, 137, 54, 0.1);
border: 1px solid rgba(237, 137, 54, 0.3);
border-radius: 8px;
margin-top: 1rem;
}
.warning-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.warning-content {
font-size: 0.8rem;
color: #ed8936;
}
.warning-content strong {
display: block;
margin-bottom: 0.25rem;
}
.warning-content p {
margin: 0;
color: #a0aec0;
}
.warning-content code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: rgba(237, 137, 54, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #ed8936;
font-size: 0.75rem;
}
/* Help Section */
.help-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.help-title {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
margin-bottom: 0.5rem;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.75rem;
color: #718096;
}
.help-list li {
margin-bottom: 0.5rem;
padding-left: 1rem;
position: relative;
}
.help-list li::before {
content: "•";
position: absolute;
left: 0;
color: #00d4ff;
}
.help-list strong {
color: #a0aec0;
}
/* Examples */
.examples {
margin-top: 2rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
border: 1px solid #2d3748;
}
.examples-title {
font-size: 0.875rem;
font-weight: 600;
color: #a0aec0;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.examples-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.875rem;
color: #718096;
}
.examples-list li {
margin-bottom: 0.5rem;
}
.examples-list li:last-child {
margin-bottom: 0;
}
.examples-list code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: rgba(0, 212, 255, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #00d4ff;
font-size: 0.8rem;
}
.examples-list strong {
color: #a0aec0;
}
</style>

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
import bootApp from './boot/app.js';
import './style.css';
@@ -9,4 +10,9 @@ const app = createApp(App);
app.use(router);
await bootApp({
app,
router,
});
app.mount('#app');

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;

View File

@@ -1,133 +1,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { Mnemonic, HDPrivateNode } from '@xocash/stack';
import { ref } from 'vue';
const router = useRouter();
/** The wallet key input (public key or xpub). */
const walletKey = ref('');
/** Whether to show advanced options. */
const showAdvanced = ref(false);
/** The account path for master xpubs. */
const accountPath = ref('');
/** Whether this is a master xpub (requires account path derivation). */
const isMasterXpub = ref(false);
/** Error message for invalid input. */
const error = ref('');
/** Detect if the input looks like an xpub. */
const isXpub = computed(() => {
const key = walletKey.value.trim();
return key.startsWith('xpub') || key.startsWith('tpub');
});
//-----------------------------------------------------------------------------
// Mnemonic Converter
//-----------------------------------------------------------------------------
import XpubInput from '../components/XpubInput.vue';
import MnemonicConverter from '../components/MnemonicConverter.vue';
import WalletHistory from '../components/WalletHistory.vue';
/** Whether to show the mnemonic converter. */
const showConverter = ref(false);
/** The mnemonic input. */
const mnemonicInput = ref('');
/** The derivation path for xpub generation. */
const xpubDerivationPath = ref("m/44'/145'/0'");
/** The generated xpub. */
const generatedXpub = ref('');
/** Error from mnemonic conversion. */
const converterError = ref('');
/** Whether conversion is in progress. */
const isConverting = ref(false);
/**
* Convert mnemonic to xpub.
*/
async function convertMnemonicToXpub() {
const phrase = mnemonicInput.value.trim();
if (!phrase) {
converterError.value = 'Please enter a mnemonic phrase';
return;
}
isConverting.value = true;
converterError.value = '';
generatedXpub.value = '';
try {
// Parse and validate the mnemonic.
const mnemonic = Mnemonic.fromPhrase(phrase);
// Create the HD private node from the mnemonic.
const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic);
// Derive to the account level (hardened path).
const accountNode = hdPrivateNode.derivePath(xpubDerivationPath.value);
// Get the public node and export as xpub.
const hdPublicNode = accountNode.deriveHDPublicNode();
generatedXpub.value = hdPublicNode.toXPub();
} catch (e) {
converterError.value = e instanceof Error ? e.message : 'Failed to convert mnemonic';
} finally {
isConverting.value = false;
}
}
/**
* Use the generated xpub.
*/
function useGeneratedXpub() {
if (generatedXpub.value) {
walletKey.value = generatedXpub.value;
// Clear sensitive data.
mnemonicInput.value = '';
generatedXpub.value = '';
showConverter.value = false;
}
}
/**
* Copy xpub to clipboard.
*/
function copyXpub() {
if (generatedXpub.value) {
navigator.clipboard.writeText(generatedXpub.value);
}
}
/**
* Navigate to the wallet view page.
*/
function viewWallet() {
const key = walletKey.value.trim();
if (!key) {
error.value = 'Please enter a public key or xpub';
return;
}
error.value = '';
// Build query params for advanced options.
const query: Record<string, string> = {};
if (isMasterXpub.value && accountPath.value.trim()) {
query.accountPath = accountPath.value.trim();
}
router.push({ name: 'wallet', params: { key }, query });
}
</script>
<template>
@@ -140,76 +19,8 @@ function viewWallet() {
</div>
<div class="input-section">
<div class="input-wrapper">
<input
v-model="walletKey"
type="text"
placeholder="Enter public key or xpub..."
class="key-input"
@keyup.enter="viewWallet"
/>
<button class="view-button" @click="viewWallet">
View Wallet
</button>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
<!-- Advanced Options Toggle -->
<div v-if="isXpub" class="advanced-toggle">
<button
class="toggle-button"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? '▼' : '▶' }} Advanced Options
</button>
</div>
<!-- Advanced Options Panel -->
<div v-if="showAdvanced && isXpub" class="advanced-panel">
<div class="option-row">
<label class="checkbox-label">
<input
type="checkbox"
v-model="isMasterXpub"
class="checkbox"
/>
<span>Derive from a non-hardened path (e.g., 0/0)</span>
</label>
</div>
<div v-if="isMasterXpub" class="path-input-section">
<label class="input-label">Derivation Path (non-hardened only)</label>
<input
v-model="accountPath"
type="text"
placeholder="e.g., 0 or 0/0"
class="path-input"
/>
<p class="input-hint">
Only non-hardened paths work with xpub (no apostrophes)
</p>
<div class="warning-box">
<span class="warning-icon"></span>
<div class="warning-content">
<strong>Hardened paths require private keys</strong>
<p>
Paths like <code>44'/145'/0'</code> cannot be derived from an xpub.
Export the account-level xpub directly from your wallet instead.
</p>
</div>
</div>
<div class="help-section">
<p class="help-title">How to get the correct xpub:</p>
<ul class="help-list">
<li><strong>Electron Cash:</strong> Wallet → Information → copy the "Master Public Key" (this is already at account level)</li>
<li><strong>Other wallets:</strong> Export the xpub from the account you want to watch</li>
</ul>
</div>
</div>
</div>
<!-- Raw xpub/public key input -->
<XpubInput />
<!-- Mnemonic Converter Toggle -->
<div class="converter-toggle">
@@ -221,97 +32,14 @@ function viewWallet() {
</button>
</div>
<!-- Mnemonic Converter -->
<!-- Mnemonic Converter Panel -->
<div v-if="showConverter" class="converter-panel">
<div class="security-warning">
<span class="warning-icon">🔐</span>
<div>
<strong>Security Notice</strong>
<p>Your mnemonic is processed locally and never sent anywhere. Clear it after use.</p>
</div>
<MnemonicConverter :show-instructions="true" />
</div>
<div class="converter-form">
<label class="input-label">Mnemonic Phrase</label>
<textarea
v-model="mnemonicInput"
placeholder="Enter your 12 or 24 word recovery phrase..."
class="mnemonic-input"
rows="3"
></textarea>
<label class="input-label">Derivation Path</label>
<div class="path-row">
<input
v-model="xpubDerivationPath"
type="text"
class="path-input"
/>
<div class="path-presets">
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/145'/0'` }"
@click="xpubDerivationPath = `m/44'/145'/0'`"
>
BCH
</button>
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/0'/0'` }"
@click="xpubDerivationPath = `m/44'/0'/0'`"
>
BTC
</button>
<button
class="preset-btn"
:class="{ active: xpubDerivationPath === `m/44'/245'/0'` }"
@click="xpubDerivationPath = `m/44'/245'/0'`"
>
BCH Alt
</button>
</div>
</div>
<button
class="convert-button"
@click="convertMnemonicToXpub"
:disabled="isConverting"
>
{{ isConverting ? 'Converting...' : 'Generate xpub' }}
</button>
<p v-if="converterError" class="error-message">{{ converterError }}</p>
<!-- Generated xpub -->
<div v-if="generatedXpub" class="generated-xpub">
<label class="input-label">Generated xpub</label>
<div class="xpub-display">
<code>{{ generatedXpub }}</code>
</div>
<div class="xpub-actions">
<button class="action-btn" @click="copyXpub">
📋 Copy
</button>
<button class="action-btn primary" @click="useGeneratedXpub">
Use this xpub
</button>
</div>
</div>
</div>
</div>
<div class="examples">
<p class="examples-title">Supported Inputs:</p>
<ul class="examples-list">
<li>
<strong>Public Key (P2PKH):</strong>
<code>02...</code> or <code>03...</code> (33 bytes hex, 66 characters)
</li>
<li>
<strong>Account-level xpub:</strong>
<code>xpub...</code> Export from Electron Cash via Wallet Information
</li>
</ul>
<!-- Wallet History -->
<div class="wallet-section">
<WalletHistory />
</div>
</div>
</div>
@@ -355,65 +83,11 @@ function viewWallet() {
max-width: 600px;
}
.input-wrapper {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.key-input {
flex: 1;
padding: 1rem 1.25rem;
font-size: 1rem;
border: 2px solid #2d3748;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
transition: all 0.2s ease;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.key-input::placeholder {
color: #4a5568;
}
.key-input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
}
.view-button {
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.view-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.3);
}
.view-button:active {
transform: translateY(0);
}
.error-message {
color: #fc8181;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Advanced Options */
.advanced-toggle {
/* Converter Toggle */
.converter-toggle {
margin-top: 1.5rem;
margin-bottom: 1rem;
text-align: center;
}
.toggle-button {
@@ -430,82 +104,6 @@ function viewWallet() {
color: #00d4ff;
}
.advanced-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.option-row {
margin-bottom: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
color: #a0aec0;
font-size: 0.875rem;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
accent-color: #00d4ff;
cursor: pointer;
}
.path-input-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.input-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.path-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.path-input::placeholder {
color: #4a5568;
}
.path-input:focus {
outline: none;
border-color: #00d4ff;
}
.input-hint {
font-size: 0.75rem;
color: #718096;
margin-top: 0.5rem;
}
/* Converter Toggle */
.converter-toggle {
margin-bottom: 1rem;
text-align: center;
}
/* Converter Panel */
.converter-panel {
background: rgba(255, 255, 255, 0.03);
@@ -515,301 +113,8 @@ function viewWallet() {
margin-bottom: 1.5rem;
}
.security-warning {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(72, 187, 120, 0.1);
border: 1px solid rgba(72, 187, 120, 0.3);
border-radius: 8px;
margin-bottom: 1.25rem;
}
.security-warning .warning-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.security-warning strong {
display: block;
font-size: 0.875rem;
color: #48bb78;
margin-bottom: 0.25rem;
}
.security-warning p {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
}
.converter-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mnemonic-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
resize: vertical;
}
.mnemonic-input::placeholder {
color: #4a5568;
}
.mnemonic-input:focus {
outline: none;
border-color: #00d4ff;
}
.path-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.path-row .path-input {
flex: 1;
}
.path-presets {
display: flex;
gap: 0.25rem;
}
.preset-btn {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid #2d3748;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: #a0aec0;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: rgba(0, 212, 255, 0.1);
border-color: #00d4ff;
color: #00d4ff;
}
.preset-btn.active {
background: rgba(0, 212, 255, 0.2);
border-color: #00d4ff;
color: #00d4ff;
}
.convert-button {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.convert-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
.convert-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.generated-xpub {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.xpub-display {
padding: 0.75rem 1rem;
background: rgba(0, 212, 255, 0.05);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
overflow-x: auto;
}
.xpub-display code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.7rem;
color: #00d4ff;
word-break: break-all;
}
.xpub-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.action-btn {
flex: 1;
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;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #4a5568;
}
.action-btn.primary {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border: none;
color: #fff;
}
.action-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
}
/* Warning Box */
.warning-box {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(237, 137, 54, 0.1);
border: 1px solid rgba(237, 137, 54, 0.3);
border-radius: 8px;
margin-top: 1rem;
}
.warning-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.warning-content {
font-size: 0.8rem;
color: #ed8936;
}
.warning-content strong {
display: block;
margin-bottom: 0.25rem;
}
.warning-content p {
margin: 0;
color: #a0aec0;
}
.warning-content code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: rgba(237, 137, 54, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #ed8936;
font-size: 0.75rem;
}
/* Help Section */
.help-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2d3748;
}
.help-title {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
margin-bottom: 0.5rem;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.75rem;
color: #718096;
}
.help-list li {
margin-bottom: 0.5rem;
padding-left: 1rem;
position: relative;
}
.help-list li::before {
content: "•";
position: absolute;
left: 0;
color: #00d4ff;
}
.help-list strong {
color: #a0aec0;
}
/* Examples */
.examples {
/* Wallet Section */
.wallet-section {
margin-top: 2rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
border: 1px solid #2d3748;
}
.examples-title {
font-size: 0.875rem;
font-weight: 600;
color: #a0aec0;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.examples-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.875rem;
color: #718096;
}
.examples-list li {
margin-bottom: 0.5rem;
}
.examples-list li:last-child {
margin-bottom: 0;
}
.examples-list code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: rgba(0, 212, 255, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #00d4ff;
font-size: 0.8rem;
}
.examples-list strong {
color: #a0aec0;
}
</style>

View File

@@ -1,18 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, shallowRef, watch } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BlockchainElectrum,
PublicKey,
BlockHeader,
BaseWallet,
Bytes,
} 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 { useApp } from '../services/app.js';
import { ReactiveWallet } from '../services/wallet.js';
const app = useApp();
//-----------------------------------------------------------------------------
// Types
@@ -37,8 +34,37 @@ 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);
/** The wallet type. */
const walletType = computed<WalletType | null>(() => {
if (isXPub(key.value)) return 'hd';
if (isPublicKey(key.value)) return 'p2pkh';
return null;
});
const wallet = computed(() => {
console.log('wallet', app.wallets.wallets);
const genesisData = walletType.value === 'hd'
? {
type: 'WalletHDWatch',
xpub: key.value,
derivationPath: accountPath.value ?? '',
} as const
: {
type: 'WalletP2PKHWatch',
publicKey: key.value,
} as const
console.log('genesisData', genesisData);
// Use the genesis data to derive the wallet ID
const walletId = BaseWallet.deriveId(genesisData);
console.log('walletId', Bytes.from(walletId).toHex());
const wallet = app.wallets.wallets[Bytes.from(walletId).toHex()];
console.log('wallet', wallet);
return wallet;
});
/** Loading state. */
const isLoading = ref(true);
@@ -46,15 +72,6 @@ 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;
/** The block headers. */
const blockHeaders = ref<{ [height: number]: BlockHeader }>({});
//-----------------------------------------------------------------------------
// Wallet Type Detection
//-----------------------------------------------------------------------------
@@ -85,18 +102,6 @@ function isPublicKey(key: string): boolean {
return /^[0-9a-fA-F]+$/.test(key);
}
/**
* Detects the wallet type from the key.
*
* @param key - The key to analyze.
* @returns The wallet type or null if invalid.
*/
function detectWalletType(key: string): WalletType | null {
if (isXPub(key)) return 'hd';
if (isPublicKey(key)) return 'p2pkh';
return null;
}
//-----------------------------------------------------------------------------
// Wallet Creation
//-----------------------------------------------------------------------------
@@ -109,99 +114,26 @@ async function initializeWallet() {
error.value = null;
try {
// Detect the wallet type.
const type = detectWalletType(key.value);
if (!type) {
throw new Error(
'Invalid key format. Please provide a valid public key (02... or 03...) or xpub.'
);
if (wallet.value) {
return;
}
walletType.value = type;
console.log('WALLET WAS NOT FOUND, CREATING...')
// Create the blockchain adapter.
blockchain = new BlockchainElectrum({
// TODO: Make this configurable
servers: ['bch.imaginary.cash'],
});
const genesisData = walletType.value === 'hd'
? {
type: 'WalletHDWatch',
xpub: key.value,
derivationPath: accountPath.value ?? '',
} as const
: {
type: 'WalletP2PKHWatch',
publicKey: key.value,
} as const
// Wait for the blockchain to connect.
await blockchain.start();
console.log('genesisData', genesisData);
// Create the appropriate wallet type.
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
if (type === 'hd') {
// Validate the xpub.
let hdNode: HDPublicNode;
try {
hdNode = HDPublicNode.fromXPub(key.value);
} catch {
throw new Error('Invalid xpub format. Please check and try again.');
}
// 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'";
}
// Create the HD watch wallet with the account-level xpub.
baseWallet = await WalletHDWatch.from(
{ blockchain },
{
xpub: accountXpub,
derivationPath: displayPath,
}
);
} else {
// Validate the public key.
try {
PublicKey.fromHex(key.value);
} catch {
throw new Error('Invalid public key format. Please check and try again.');
}
// Create the P2PKH watch wallet.
baseWallet = await WalletP2PKHWatch.from(
{ blockchain },
{ publicKey: key.value }
);
}
// Wrap in reactive wallet.
wallet.value = new ReactiveWallet({ wallet: baseWallet });
watch(wallet.value.transactions, (transactions) => {
transactions.forEach((tx) => {
blockchain?.fetchBlockHeader(tx.height).then((blockHeader) => {
blockHeaders.value[tx.height] = blockHeader;
});
});
});
wallet.value.transactions.value.forEach((tx) => {
blockchain?.fetchBlockHeader(tx.height).then((blockHeader) => {
blockHeaders.value[tx.height] = blockHeader;
});
});
// Start the wallet (this triggers the initial scan/fetch).
await wallet.value.start();
await app.wallets.createWallet(genesisData);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err);
@@ -210,22 +142,6 @@ async function initializeWallet() {
}
}
/**
* Cleans up the wallet and blockchain connection.
*/
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
//-----------------------------------------------------------------------------
@@ -234,10 +150,6 @@ onMounted(() => {
initializeWallet();
});
onUnmounted(() => {
cleanup();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
@@ -257,6 +169,8 @@ const transactionCount = computed(() => {
/** Sorted transactions (newest first). */
const sortedTransactions = computed(() => {
console.log('123')
console.log('sortedTransactions', wallet.value?.transactions.value.toArray());
if (!wallet.value) return [];
return wallet.value.transactions.value
@@ -413,7 +327,7 @@ function viewAddresses() {
</span>
<!-- block timestamp -->
<span class="tx-time">
{{ blockHeaders[tx.height]?.getTimestampDate().toLocaleString('en-AU', { dateStyle: 'short', timeStyle: 'short' }) }}
{{ wallet.blockHeaders.value[tx.height]?.getTimestampDate().toLocaleString('en-AU', { dateStyle: 'short', timeStyle: 'short' }) }}
</span>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Rates } from './rates.js';
import { Settings } from './settings.js';
// import { Settings } from './settings.js';
import { Wallets } from './wallets.js';
import { inject, ref } from 'vue';
@@ -16,7 +16,7 @@ import { ReactiveBlockchain } from './blockchain.js';
export type AppDependencies = {
router: Router;
settings: Settings;
// settings: Settings;
blockchain: ReactiveBlockchain;
rates: Rates;
walletStorage: BaseStorage;
@@ -32,7 +32,7 @@ export class App {
//---------------------------------------------------------------------------
public router: Router;
public settings: Settings;
// public settings: Settings;
public blockchain: ReactiveBlockchain;
public rates: Rates;
public walletStorage: BaseStorage;
@@ -43,7 +43,7 @@ export class App {
dependencies: AppDependencies
) {
this.router = dependencies.router;
this.settings = dependencies.settings;
// this.settings = dependencies.settings;
this.blockchain = dependencies.blockchain;
this.rates = dependencies.rates;
this.walletStorage = dependencies.walletStorage;
@@ -53,24 +53,30 @@ export class App {
static async create(router: Router) {
// Setup app storage adapter.
const appStorage = await StorageLocalStorage.createOrOpen('xoApp_V0.0.1');
const settingsStore = await appStorage.createOrGetStore('settings');
// TODO: Add settings so we can select things like currency and exchange rate provider.
const appStorage = await StorageLocalStorage.createOrOpen('IncomeTaxApp_V0.0.1');
// const settingsStore = await appStorage.createOrGetStore('settings');
// Create settings class.
const settings = await Settings.from(settingsStore);
// const settings = await Settings.from(settingsStore);
console.time('createApp');
// Setup rates.
const rates = await Rates.from();
// Setup rates.
const ratesStore = await appStorage.createOrGetStore('rates');
const rates = await Rates.from(ratesStore);
rates.start();
console.log('started rates')
// Setup wallets storage adapter.
const walletStorage = await StorageLocalStorage.createOrOpen(
'xoWallets_V0.0.5'
'IncomeTaxWallets_V0.0.1'
);
// Setup cache storage adapter.
const cacheStorage = await StorageLocalStorage.createOrOpen(
'xoWalletCache_V0.0.5'
'IncomeTaxWalletCache_V0.0.1'
);
const blockchainCache = await cacheStorage.createOrGetStore(
'electrumBlockchain',
@@ -79,13 +85,18 @@ export class App {
}
);
const blockchainElectrum = new BlockchainElectrum({
store: blockchainCache,
});
blockchainElectrum.start();
// Setup blockchain (and use our cache).
const blockchain = await ReactiveBlockchain.from(
await BlockchainElectrum.from({
store: blockchainCache,
})
blockchainElectrum
);
console.log('started blockchain')
// Setup wallet manager.
const walletManager = new Wallets(
blockchain.blockchain,
@@ -94,6 +105,10 @@ export class App {
);
await walletManager.start();
console.log('started wallet manager')
console.timeEnd('createApp');
// Create new instance of app.
return new this(
{
@@ -101,7 +116,7 @@ export class App {
blockchain,
cache: cacheStorage,
rates,
settings,
// settings,
walletStorage,
wallets: walletManager,
}

View File

@@ -1,40 +1,60 @@
import { ref, triggerRef } from 'vue';
import { triggerRef } from 'vue';
import {
BaseRates,
type BaseRates,
RatesComposite,
RatesBitPay,
RatesCoinbase,
// RatesCryptoCompare,
RatesOracle,
ratesMedianPolicy,
BaseStore,
} from '@xocash/stack';
import { Mixin } from '@/utils/mixin';
import { OracleClient } from '@generalprotocols/oracle-client';
import { ReactiveStore } from 'src/utils/reactive-store.js';
export type RatesStore = { [key: string]: Rate | undefined };
export type Rate = {
price: number;
lastUpdated: number;
};
/**
*/
export class Rates extends Mixin([BaseRates]){
export class Rates {
// Our rates adapter.
private readonly adapter: BaseRates;
// Our individual rates as a reactives.
// Our individual rates as a reactive store.
// NOTE: Because our adapter sends us updates one by one, we must use triggerRef to update this.
private rates = ref<{ [key: string]: number | undefined }>({});
private readonly ratesStore: ReactiveStore<RatesStore>;
static async from(refreshMilliseconds = 60_000) {
const oracle = await RatesOracle.from();
static async from(store: BaseStore, refreshMilliseconds = 60_000) {
// Create a reactive store to persist our rates if the user opens the wallet without internet
const ratesStore = await ReactiveStore.from<RatesStore>(store, {});
const oracleClient = new OracleClient();
const oracle = await RatesOracle.from(oracleClient);
const coinbase = RatesCoinbase.from();
const bitpay = RatesBitPay.from();
// NOTE: This API is way too slow and was used so that Saqib could get conversions in Cambodia.
// So we are disabling it for now.
// const cryptocompare = RatesCryptoCompare.from();
const compositeAdapter = new RatesComposite(
[oracle],
[oracle, coinbase, bitpay],
ratesMedianPolicy,
refreshMilliseconds
);
return new Rates(compositeAdapter);
return new Rates(compositeAdapter, ratesStore);
}
constructor(ratesAdapter: BaseRates) {
super(ratesAdapter);
constructor(ratesAdapter: BaseRates, ratesStore: ReactiveStore<RatesStore>) {
this.adapter = ratesAdapter;
this.ratesStore = ratesStore;
this.adapter.on(
'rateUpdated',
@@ -43,20 +63,30 @@ export class Rates extends Mixin([BaseRates]){
return;
}
this.rates.value[numeratorUnitCode] = price;
triggerRef(this.rates);
this.ratesStore.set(numeratorUnitCode, {
price,
lastUpdated: Date.now() / 1000,
});
triggerRef(this.ratesStore.prop(numeratorUnitCode));
}
);
}
async start(): Promise<void> {
console.log('rates started');
// Start our rates adapter.
await this.adapter.start();
}
toBCH(amount: number, fromCurrency: string): number {
const rate = this.rates.value[fromCurrency];
if (rate === undefined) {
const rate = this.ratesStore.prop(fromCurrency).value;
if (!rate) {
return 0;
// throw new Error(`Currency ${fromCurrency} not supported.`);
}
return Rates.roundToDigits(amount / rate, 8);
return Rates.roundToDigits(amount / rate.price, 8);
}
toSats(amount: number, fromCurrency: string): number {
@@ -72,13 +102,14 @@ export class Rates extends Mixin([BaseRates]){
}
fromBCH(amount: number, targetCurrency: string): number {
const rate = this.rates.value[targetCurrency];
if (rate === undefined) {
const rate = this.ratesStore.prop(targetCurrency).value;
if (!rate) {
return 0;
// throw new Error(`Currency ${targetCurrency} not supported.`);
}
return Rates.roundToDigits(amount * rate, 2);
return Rates.roundToDigits(amount * rate.price, 2);
}
formatSats(sats: number | bigint, targetCurrency: string) {
@@ -87,6 +118,28 @@ export class Rates extends Mixin([BaseRates]){
return this.formatCurrency(amount, targetCurrency);
}
formatCurrency(
amount: number,
currency: string,
opts: Partial<Intl.NumberFormatOptions> = {}
) {
const minimumFractionDigitsMap: { [currency: string]: number } = {
AUD: 2,
BCH: 8,
USD: 2,
};
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
currencyDisplay: 'narrowSymbol', // Uses a shorter currency symbol when available
minimumFractionDigits: minimumFractionDigitsMap[currency] || 0, // Controls decimal places
...opts,
});
return formatter.format(amount);
}
static roundToDigits(numberToRound: number, digits: number): number {
// Set the options of the Number Format object.
const options: Intl.NumberFormatOptions = {

View File

@@ -58,7 +58,7 @@ export class ReactiveWallet<T extends BaseWallet = BaseWallet> extends Mixin([
]) {
public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
const reactiveWallet = new ReactiveWallet(deps);
await reactiveWallet.start();
// await reactiveWallet.start();
return reactiveWallet;
}

View File

@@ -2,44 +2,43 @@ 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 {
type WalletHDWatchEntropy,
type WalletHDWatchDerivationData,
type WalletHDWatchGenesisData,
type WalletP2PKHWatchGenesisData,
WalletHDWatch,
WalletP2PKHWatch,
} from '../xo-extensions/index.js';
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
export type AppWalletHDWatchGenesisData = // Remove mnemonic option
((
| Exclude<WalletHDEntropy, { mnemonic: Mnemonic }>
// Add mnemonic option with MnemonicRaw type
| { mnemonic: MnemonicRaw }
WalletHDWatchEntropy
) &
WalletHDDerivationData) & {
type: 'WalletHD';
WalletHDWatchDerivationData) & {
type: 'WalletHDWatch';
name: string;
};
export type AppWalletP2PKHGenesisData = WalletP2PKHGenesisData & {
type: 'WalletP2PKH';
export type AppWalletP2PKHWatchGenesisData = WalletP2PKHWatchGenesisData & {
type: 'WalletP2PKHWatch';
name: string;
};
export type AppWalletData = AppWalletP2PKHGenesisData | AppWalletHDGenesisData;
export type AppWalletData = AppWalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
export type AppWalletStore = { [uid: string]: AppWalletData };
export type WalletsStore = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export type WalletsSupported = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export type WalletsStore = WalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
export type WalletsSupported = WalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
export class Wallets {
public walletsStore?: BaseStore;
@@ -72,24 +71,17 @@ export class Wallets {
});
// 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;
if (genesisData.type === 'WalletHDWatch') {
// Instantiate the wallet.
const wallet = await WalletHD.from(
console.time('WalletHDWatch.from');
const wallet = await WalletHDWatch.from(
{
blockchain: this.blockchain,
cache: cacheStore,
},
walletData
genesisData
);
console.timeEnd('WalletHDWatch.from');
// Add a persistent store for our activities.
await wallet.activities.store.addStore(
@@ -100,16 +92,18 @@ export class Wallets {
);
// Push it to our list of wallets.
console.time('ReactiveWallet.from');
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
console.timeEnd('ReactiveWallet.from');
// 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') {
} else if (genesisData.type === 'WalletP2PKHWatch') {
// Instantiate the wallet.
const wallet = await WalletP2PKH.from(
const wallet = await WalletP2PKHWatch.from(
{
blockchain: this.blockchain,
},
@@ -125,9 +119,11 @@ export class Wallets {
);
// Push it to our list of wallets.
console.time('ReactiveWallet.from');
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
console.timeEnd('ReactiveWallet.from');
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
@@ -150,7 +146,7 @@ export class Wallets {
}
async createWallet(
walletData: WalletHDGenesisData | WalletP2PKHGenesisData
walletData: WalletHDWatchGenesisData | WalletP2PKHWatchGenesisData
): Promise<string> {
// Extract the raw Wallet Data.
// NOTE: This is a Vue Quirk. We cannot store reactives as structuredClones cannot be performed on them.
@@ -166,10 +162,10 @@ export class Wallets {
cache: walletCache,
};
if (walletDataRaw.type === 'WalletHD') {
wallet = await WalletHD.from(dependencies, walletDataRaw);
} else if (walletData.type === 'WalletP2PKH') {
wallet = await WalletP2PKH.from(dependencies, walletDataRaw);
if (walletDataRaw.type === 'WalletHDWatch') {
wallet = await WalletHDWatch.from(dependencies, walletDataRaw);
} else if (walletData.type === 'WalletP2PKHWatch') {
wallet = await WalletP2PKHWatch.from(dependencies, walletDataRaw);
} else {
throw new Error(`Unsupported wallet data: ${walletData}`);
}
@@ -177,11 +173,13 @@ export class Wallets {
// Get the Wallet ID.
const walletId = await wallet.getId();
if (this.wallets[walletId]) {
return walletId;
}
// 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.
@@ -220,4 +218,5 @@ export class Wallets {
delete this.wallets[walletId];
}
}

118
src/utils/reactive-store.ts Normal file
View File

@@ -0,0 +1,118 @@
import { Mixin } from './mixin.js';
import { BaseStore } from '@xocash/stack';
import { type ComputedRef, computed, ref } from 'vue';
export class ReactiveStore<
T extends Record<string, unknown> = Record<string, unknown>
> extends Mixin([BaseStore]) {
static async from<T extends Record<string, unknown>>(
store: BaseStore,
defaultValues?: T
) {
// If provided, Load the store with any default values that are missing.
if (defaultValues) {
for (const [key, value] of Object.entries(defaultValues)) {
await store.getOrSet(key, () => value);
}
}
// Retrieve all of the store's data.
// TODO: We need to fix our stores to allow more explicit typing.
const initialData = (await store.all()) as T;
return new this(store, initialData);
}
// Dependencies.
public readonly adapter: BaseStore;
// The reactive wrapper to access our store's data.
public storeData: ComputedRef<T>;
// List of callbacks to trigger when this object is destroyed.
// NOTE: Typically, these will be callbacks to unsubscribe from events.
protected destroyCallbacks: Array<() => void> = [];
constructor(store: BaseStore, initialData: T) {
super(store);
// Declare a reactive ref to hold our store's data.
// NOTE: This is deliberately not a class member as we do not want it scoped outside of this.
const storeDataRef = ref<T>(initialData);
// Make sure the store data updates on set operations.
this.destroyCallbacks.push(
store.on('set', ({ key, value }) => {
storeDataRef.value[key] = value;
})
);
// Make sure the store data updates on delete operations.
this.destroyCallbacks.push(
store.on('delete', ({ key }) => {
delete storeDataRef.value[key];
})
);
// Make sure the store data updates on clear operations.
this.destroyCallbacks.push(
store.on('clear', () => {
storeDataRef.value = {};
})
);
// Setup a computed property that allows read-only access to our reactive store data.
this.storeData = computed(() => storeDataRef.value);
// Assign the adapter.
this.adapter = store;
// return this as unknown as ReactiveStore<T> & BaseStore;
}
destroy() {
// Release any dangling references (e.g. event subscriptions).
this.destroyCallbacks.forEach((callback) => {
callback();
});
}
prop<K extends keyof T & string>(key: K) {
// Return a computed property.
return computed<T[K]>({
get: () => this.storeData.value[key],
set: (value: T[K]) => {
// NOTE: Setters will only appear once they have propagated in the store.
// For awaitable setters, use the class' `set` method directly.
this.adapter.set(key, value).catch((error) => {
console.error(
`Failed to set "${key}" with value "${value}": ${error}`
);
});
},
});
}
// TODO: I want to be able to expose the BaseStore's methods
// But there are lots of them, so I don't want to have to manually redefine these.
// How can I do it?
/*
async get<K extends keyof T>(key: K) {
return await this.adapter.get(key as string);
}
// Async method to set property in both the ref and the store
async set<K extends keyof T>(key: K, value: T[K]) {
await this.adapter.set(key as string, value);
}
async delete<K extends keyof T>(key: K) {
return await this.adapter.delete(key as string);
}
async clear() {
await this.adapter.clear();
}
*/
}

View File

@@ -0,0 +1,4 @@
export * from './wallet-hd-watch.js';
export * from './wallet-p2pkh-watch.js';
export * from './hd-public-node.js';
export * from './hd-private-node.js';