Use cache for wallets. Move closer to offline mode. Clean up home page. Add tx count to addresses
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
384
src/components/MnemonicConverter.vue
Normal file
384
src/components/MnemonicConverter.vue
Normal 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>
|
||||
139
src/components/WalletHistory.vue
Normal file
139
src/components/WalletHistory.vue
Normal 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>
|
||||
416
src/components/XpubInput.vue
Normal file
416
src/components/XpubInput.vue
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<MnemonicConverter :show-instructions="true" />
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
118
src/utils/reactive-store.ts
Normal 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();
|
||||
}
|
||||
*/
|
||||
}
|
||||
4
src/xo-extensions/index.ts
Normal file
4
src/xo-extensions/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user