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

View File

@@ -9,7 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@generalprotocols/oracle-client": "^0.0.1", "@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@xocash/stack": "file:../stack/packages/stack", "@xocash/stack": "file:../stack/packages/stack",
"class-variance-authority": "^0.7.1", "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 App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
import bootApp from './boot/app.js';
import './style.css'; import './style.css';
@@ -9,4 +10,9 @@ const app = createApp(App);
app.use(router); app.use(router);
await bootApp({
app,
router,
});
app.mount('#app'); app.mount('#app');

View File

@@ -1,18 +1,21 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router';
import { import {
BlockchainElectrum, BaseWallet,
PublicKey, Bytes,
type Address, type Address,
} from '@xocash/stack'; } from '@xocash/stack';
import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js'; import {
import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js'; type WalletHDWatch,
import { HDPublicNode } from '../xo-extensions/hd-public-node.js'; 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 // Types
@@ -26,12 +29,16 @@ type AddressInfo = {
index: number; index: number;
cashAddr: string; cashAddr: string;
derivationPath: string; derivationPath: string;
lockscriptHex: string;
txCount: number;
utxoCount: number;
}; };
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Route & Router // App & Router
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
const app = useApp();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -46,7 +53,11 @@ const key = computed(() => route.params.key as string);
const accountPath = computed(() => route.query.accountPath as string | undefined); const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */ /** 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. */ /** Loading state. */
const isLoading = ref(true); const isLoading = ref(true);
@@ -54,120 +65,82 @@ const isLoading = ref(true);
/** Error message if wallet creation fails. */ /** Error message if wallet creation fails. */
const error = ref<string | null>(null); 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). */ /** Filter for chain path (0 = external, 1 = internal, null = all). */
const chainPathFilter = ref<number | null>(null); const chainPathFilter = ref<number | null>(null);
/** Search query for filtering addresses. */ /** Search query for filtering addresses. */
const searchQuery = ref(''); 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 // 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 { function isXPub(key: string): boolean {
return key.startsWith('xpub') || key.startsWith('tpub'); 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 { function isPublicKey(key: string): boolean {
if (key.length !== 66) return false; if (key.length !== 66) return false;
if (!key.startsWith('02') && !key.startsWith('03')) return false; if (!key.startsWith('02') && !key.startsWith('03')) return false;
return /^[0-9a-fA-F]+$/.test(key); 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() { async function initializeWallet() {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
try { try {
const type = detectWalletType(key.value); if (!walletType.value) {
if (!type) {
throw new Error( throw new Error(
'Invalid key format. Please provide a valid public key or xpub.' 'Invalid key format. Please provide a valid public key or xpub.'
); );
} }
walletType.value = type; // Create the wallet via the app's wallet manager.
await app.wallets.createWallet(genesisData.value);
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();
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet'; error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err); 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() { const txCountsByLockscript = computed<Map<string, number>>(() => {
if (!wallet.value) return; 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 addresses: AddressInfo[] = [];
const baseWallet = wallet.value.wallet; const baseWallet = wallet.value.wallet;
@@ -194,12 +232,16 @@ function buildAddressList() {
childWallets.forEach((childWallet, index) => { childWallets.forEach((childWallet, index) => {
const address = childWallet.publicKey.deriveAddress(); const address = childWallet.publicKey.deriveAddress();
const chainPathNum = Number(chainPath); const chainPathNum = Number(chainPath);
const lockscriptHex = address.toLockscriptHex();
addresses.push({ addresses.push({
address, address,
chainPath: chainPathNum, chainPath: chainPathNum,
index, index,
cashAddr: address.toCashAddr(), cashAddr: address.toCashAddr(),
derivationPath: `${basePath}/${chainPathNum}/${index}`, 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. // P2PKH Wallet - single address.
const p2pkhWallet = baseWallet as WalletP2PKHWatch; const p2pkhWallet = baseWallet as WalletP2PKHWatch;
const address = p2pkhWallet.publicKey.deriveAddress(); const address = p2pkhWallet.publicKey.deriveAddress();
const lockscriptHex = address.toLockscriptHex();
addresses.push({ addresses.push({
address, address,
chainPath: 0, chainPath: 0,
index: 0, index: 0,
cashAddr: address.toCashAddr(), cashAddr: address.toCashAddr(),
derivationPath: '—', derivationPath: '—',
lockscriptHex,
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
}); });
} }
addressList.value = addresses; return 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. */ /** Filtered addresses based on chain path and search query. */
const filteredAddresses = computed(() => { const filteredAddresses = computed(() => {
let result = addressList.value; let result = addressList.value;
@@ -383,7 +400,7 @@ function setFilter(filter: number | null) {
<!-- Address List --> <!-- Address List -->
<div class="address-list"> <div class="address-list">
<div <div
v-for="(addr, idx) in filteredAddresses" v-for="addr in filteredAddresses"
:key="addr.cashAddr" :key="addr.cashAddr"
class="address-item" class="address-item"
> >
@@ -396,6 +413,14 @@ function setFilter(filter: number | null) {
<span class="derivation-path">{{ addr.derivationPath }}</span> <span class="derivation-path">{{ addr.derivationPath }}</span>
</div> </div>
<code class="address-value">{{ addr.cashAddr }}</code> <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> </div>
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address"> <button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
📋 📋
@@ -715,6 +740,31 @@ function setFilter(filter: number | null) {
word-break: break-all; 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 { .copy-button {
padding: 0.5rem; padding: 0.5rem;
font-size: 1rem; font-size: 1rem;

View File

@@ -1,133 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Mnemonic, HDPrivateNode } from '@xocash/stack';
const router = useRouter(); import XpubInput from '../components/XpubInput.vue';
import MnemonicConverter from '../components/MnemonicConverter.vue';
/** The wallet key input (public key or xpub). */ import WalletHistory from '../components/WalletHistory.vue';
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
//-----------------------------------------------------------------------------
/** Whether to show the mnemonic converter. */ /** Whether to show the mnemonic converter. */
const showConverter = ref(false); 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> </script>
<template> <template>
@@ -140,76 +19,8 @@ function viewWallet() {
</div> </div>
<div class="input-section"> <div class="input-section">
<div class="input-wrapper"> <!-- Raw xpub/public key input -->
<input <XpubInput />
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>
<!-- Mnemonic Converter Toggle --> <!-- Mnemonic Converter Toggle -->
<div class="converter-toggle"> <div class="converter-toggle">
@@ -221,97 +32,14 @@ function viewWallet() {
</button> </button>
</div> </div>
<!-- Mnemonic Converter --> <!-- Mnemonic Converter Panel -->
<div v-if="showConverter" class="converter-panel"> <div v-if="showConverter" class="converter-panel">
<div class="security-warning"> <MnemonicConverter :show-instructions="true" />
<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>
<div class="converter-form"> <!-- Wallet History -->
<label class="input-label">Mnemonic Phrase</label> <div class="wallet-section">
<textarea <WalletHistory />
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>
</div> </div>
</div> </div>
</div> </div>
@@ -355,65 +83,11 @@ function viewWallet() {
max-width: 600px; max-width: 600px;
} }
.input-wrapper { /* Converter Toggle */
display: flex; .converter-toggle {
gap: 0.75rem; margin-top: 1.5rem;
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; margin-bottom: 1rem;
text-align: center;
} }
.toggle-button { .toggle-button {
@@ -430,82 +104,6 @@ function viewWallet() {
color: #00d4ff; 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 */
.converter-panel { .converter-panel {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
@@ -515,301 +113,8 @@ function viewWallet() {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.security-warning { /* Wallet Section */
display: flex; .wallet-section {
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 {
margin-top: 2rem; 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> </style>

View File

@@ -1,18 +1,15 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router';
import { import {
BlockchainElectrum, BaseWallet,
PublicKey, Bytes,
BlockHeader,
} from '@xocash/stack'; } from '@xocash/stack';
import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js'; import { useApp } from '../services/app.js';
import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js';
import { HDPublicNode } from '../xo-extensions/hd-public-node.js';
import { ReactiveWallet } from '../services/wallet.js'; const app = useApp();
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Types // Types
@@ -37,8 +34,37 @@ const key = computed(() => route.params.key as string);
/** The account path from query params (for master xpubs). */ /** The account path from query params (for master xpubs). */
const accountPath = computed(() => route.query.accountPath as string | undefined); const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */ /** The 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;
});
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. */ /** Loading state. */
const isLoading = ref(true); const isLoading = ref(true);
@@ -46,15 +72,6 @@ const isLoading = ref(true);
/** Error message if wallet creation fails. */ /** Error message if wallet creation fails. */
const error = ref<string | null>(null); 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 // Wallet Type Detection
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@@ -85,18 +102,6 @@ function isPublicKey(key: string): boolean {
return /^[0-9a-fA-F]+$/.test(key); 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 // Wallet Creation
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@@ -109,99 +114,26 @@ async function initializeWallet() {
error.value = null; error.value = null;
try { try {
// Detect the wallet type. if (wallet.value) {
const type = detectWalletType(key.value); return;
if (!type) {
throw new Error(
'Invalid key format. Please provide a valid public key (02... or 03...) or xpub.'
);
} }
walletType.value = type; console.log('WALLET WAS NOT FOUND, CREATING...')
// Create the blockchain adapter. const genesisData = walletType.value === 'hd'
blockchain = new BlockchainElectrum({ ? {
// TODO: Make this configurable type: 'WalletHDWatch',
servers: ['bch.imaginary.cash'], xpub: key.value,
}); derivationPath: accountPath.value ?? '',
} as const
: {
type: 'WalletP2PKHWatch',
publicKey: key.value,
} as const
// Wait for the blockchain to connect. console.log('genesisData', genesisData);
await blockchain.start();
// Create the appropriate wallet type. await app.wallets.createWallet(genesisData);
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();
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet'; error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err); 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 // Lifecycle
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@@ -234,10 +150,6 @@ onMounted(() => {
initializeWallet(); initializeWallet();
}); });
onUnmounted(() => {
cleanup();
});
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Computed Values // Computed Values
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@@ -257,6 +169,8 @@ const transactionCount = computed(() => {
/** Sorted transactions (newest first). */ /** Sorted transactions (newest first). */
const sortedTransactions = computed(() => { const sortedTransactions = computed(() => {
console.log('123')
console.log('sortedTransactions', wallet.value?.transactions.value.toArray());
if (!wallet.value) return []; if (!wallet.value) return [];
return wallet.value.transactions.value return wallet.value.transactions.value
@@ -413,7 +327,7 @@ function viewAddresses() {
</span> </span>
<!-- block timestamp --> <!-- block timestamp -->
<span class="tx-time"> <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> </span>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,40 +1,60 @@
import { ref, triggerRef } from 'vue'; import { triggerRef } from 'vue';
import { import {
BaseRates, type BaseRates,
RatesComposite, RatesComposite,
RatesBitPay,
RatesCoinbase,
// RatesCryptoCompare,
RatesOracle, RatesOracle,
ratesMedianPolicy, ratesMedianPolicy,
BaseStore,
} from '@xocash/stack'; } 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. // Our rates adapter.
private readonly adapter: BaseRates; 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. // 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) { static async from(store: BaseStore, refreshMilliseconds = 60_000) {
const oracle = await RatesOracle.from(); // 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( const compositeAdapter = new RatesComposite(
[oracle], [oracle, coinbase, bitpay],
ratesMedianPolicy, ratesMedianPolicy,
refreshMilliseconds refreshMilliseconds
); );
return new Rates(compositeAdapter); return new Rates(compositeAdapter, ratesStore);
} }
constructor(ratesAdapter: BaseRates) { constructor(ratesAdapter: BaseRates, ratesStore: ReactiveStore<RatesStore>) {
super(ratesAdapter);
this.adapter = ratesAdapter; this.adapter = ratesAdapter;
this.ratesStore = ratesStore;
this.adapter.on( this.adapter.on(
'rateUpdated', 'rateUpdated',
@@ -43,20 +63,30 @@ export class Rates extends Mixin([BaseRates]){
return; return;
} }
this.rates.value[numeratorUnitCode] = price; this.ratesStore.set(numeratorUnitCode, {
triggerRef(this.rates); 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 { toBCH(amount: number, fromCurrency: string): number {
const rate = this.rates.value[fromCurrency]; const rate = this.ratesStore.prop(fromCurrency).value;
if (rate === undefined) { if (!rate) {
return 0; return 0;
// throw new Error(`Currency ${fromCurrency} not supported.`); // 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 { toSats(amount: number, fromCurrency: string): number {
@@ -72,13 +102,14 @@ export class Rates extends Mixin([BaseRates]){
} }
fromBCH(amount: number, targetCurrency: string): number { fromBCH(amount: number, targetCurrency: string): number {
const rate = this.rates.value[targetCurrency]; const rate = this.ratesStore.prop(targetCurrency).value;
if (rate === undefined) {
if (!rate) {
return 0; return 0;
// throw new Error(`Currency ${targetCurrency} not supported.`); // 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) { formatSats(sats: number | bigint, targetCurrency: string) {
@@ -87,6 +118,28 @@ export class Rates extends Mixin([BaseRates]){
return this.formatCurrency(amount, targetCurrency); 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 { static roundToDigits(numberToRound: number, digits: number): number {
// Set the options of the Number Format object. // Set the options of the Number Format object.
const options: Intl.NumberFormatOptions = { 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>> { public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
const reactiveWallet = new ReactiveWallet(deps); const reactiveWallet = new ReactiveWallet(deps);
await reactiveWallet.start(); // await reactiveWallet.start();
return reactiveWallet; return reactiveWallet;
} }

View File

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