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",
|
"name": "www",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@generalprotocols/oracle-client": "^0.0.1",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@xocash/stack": "file:../stack/packages/stack",
|
"@xocash/stack": "file:../stack/packages/stack",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -524,13 +524,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@generalprotocols/oracle-client": {
|
"node_modules/@generalprotocols/oracle-client": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1-development.11945476152",
|
||||||
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1-development.11945476152.tgz",
|
||||||
"integrity": "sha512-TmnPCUm1VYeWK7SkWV8w3jAjUOW9SFLPgF8ni06ouaGYyer/35oZ5OW+6R3kpFtRWhO4rlnT9HYL8SHE3Yj0+A==",
|
"integrity": "sha512-1Q43NfacrVfSbatCREzIX7U3DgACBUegNjV977y+pql+Fve03bOyTiUQClevymCi7M3T6mCyMzSEGT8zA6EZtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"zod": "^3.24.3"
|
"zod": "^4.1.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
@@ -2173,9 +2173,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@generalprotocols/oracle-client": "^0.0.1",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@xocash/stack": "file:../stack/packages/stack",
|
"@xocash/stack": "file:../stack/packages/stack",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -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 App from './App.vue';
|
||||||
import router from './router/index.js';
|
import router from './router/index.js';
|
||||||
|
import bootApp from './boot/app.js';
|
||||||
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
@@ -9,4 +10,9 @@ const app = createApp(App);
|
|||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
await bootApp({
|
||||||
|
app,
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, shallowRef } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BlockchainElectrum,
|
BaseWallet,
|
||||||
PublicKey,
|
Bytes,
|
||||||
type Address,
|
type Address,
|
||||||
} from '@xocash/stack';
|
} from '@xocash/stack';
|
||||||
|
|
||||||
import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js';
|
import {
|
||||||
import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js';
|
type WalletHDWatch,
|
||||||
import { HDPublicNode } from '../xo-extensions/hd-public-node.js';
|
type WalletP2PKHWatch,
|
||||||
|
} from '../xo-extensions/index.js';
|
||||||
|
|
||||||
import { ReactiveWallet } from '../services/wallet.js';
|
import { binToHex } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
import { useApp } from '../services/app.js';
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -26,12 +29,16 @@ type AddressInfo = {
|
|||||||
index: number;
|
index: number;
|
||||||
cashAddr: string;
|
cashAddr: string;
|
||||||
derivationPath: string;
|
derivationPath: string;
|
||||||
|
lockscriptHex: string;
|
||||||
|
txCount: number;
|
||||||
|
utxoCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Route & Router
|
// App & Router
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const app = useApp();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -46,7 +53,11 @@ const key = computed(() => route.params.key as string);
|
|||||||
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
||||||
|
|
||||||
/** The detected wallet type. */
|
/** The detected wallet type. */
|
||||||
const walletType = ref<WalletType | null>(null);
|
const walletType = computed<WalletType | null>(() => {
|
||||||
|
if (isXPub(key.value)) return 'hd';
|
||||||
|
if (isPublicKey(key.value)) return 'p2pkh';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
/** Loading state. */
|
/** Loading state. */
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -54,120 +65,82 @@ const isLoading = ref(true);
|
|||||||
/** Error message if wallet creation fails. */
|
/** Error message if wallet creation fails. */
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
/** The reactive wallet instance. */
|
|
||||||
const wallet = shallowRef<ReactiveWallet | null>(null);
|
|
||||||
|
|
||||||
/** The blockchain adapter (shared). */
|
|
||||||
let blockchain: BlockchainElectrum | null = null;
|
|
||||||
|
|
||||||
/** Derived addresses with metadata. */
|
|
||||||
const addressList = ref<AddressInfo[]>([]);
|
|
||||||
|
|
||||||
/** Filter for chain path (0 = external, 1 = internal, null = all). */
|
/** Filter for chain path (0 = external, 1 = internal, null = all). */
|
||||||
const chainPathFilter = ref<number | null>(null);
|
const chainPathFilter = ref<number | null>(null);
|
||||||
|
|
||||||
/** Search query for filtering addresses. */
|
/** Search query for filtering addresses. */
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
// Computed Wallet
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The genesis data used to derive the wallet ID. */
|
||||||
|
const genesisData = computed(() => {
|
||||||
|
return walletType.value === 'hd'
|
||||||
|
? {
|
||||||
|
type: 'WalletHDWatch',
|
||||||
|
xpub: key.value,
|
||||||
|
derivationPath: accountPath.value ?? '',
|
||||||
|
} as const
|
||||||
|
: {
|
||||||
|
type: 'WalletP2PKHWatch',
|
||||||
|
publicKey: key.value,
|
||||||
|
} as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The wallet instance from the app's wallet manager. */
|
||||||
|
const wallet = computed(() => {
|
||||||
|
const walletId = BaseWallet.deriveId(genesisData.value);
|
||||||
|
return app.wallets.wallets[Bytes.from(walletId).toHex()];
|
||||||
|
});
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Wallet Type Detection
|
// Wallet Type Detection
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a key is an xpub (extended public key).
|
||||||
|
*
|
||||||
|
* @param key - The key to check.
|
||||||
|
* @returns True if the key is an xpub.
|
||||||
|
*/
|
||||||
function isXPub(key: string): boolean {
|
function isXPub(key: string): boolean {
|
||||||
return key.startsWith('xpub') || key.startsWith('tpub');
|
return key.startsWith('xpub') || key.startsWith('tpub');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a key is a valid compressed public key.
|
||||||
|
*
|
||||||
|
* @param key - The key to check.
|
||||||
|
* @returns True if the key is a valid public key.
|
||||||
|
*/
|
||||||
function isPublicKey(key: string): boolean {
|
function isPublicKey(key: string): boolean {
|
||||||
if (key.length !== 66) return false;
|
if (key.length !== 66) return false;
|
||||||
if (!key.startsWith('02') && !key.startsWith('03')) return false;
|
if (!key.startsWith('02') && !key.startsWith('03')) return false;
|
||||||
return /^[0-9a-fA-F]+$/.test(key);
|
return /^[0-9a-fA-F]+$/.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectWalletType(key: string): WalletType | null {
|
|
||||||
if (isXPub(key)) return 'hd';
|
|
||||||
if (isPublicKey(key)) return 'p2pkh';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Wallet Creation
|
// Wallet Initialization
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and initializes the wallet if it doesn't exist.
|
||||||
|
*/
|
||||||
async function initializeWallet() {
|
async function initializeWallet() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const type = detectWalletType(key.value);
|
if (!walletType.value) {
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid key format. Please provide a valid public key or xpub.'
|
'Invalid key format. Please provide a valid public key or xpub.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
walletType.value = type;
|
// Create the wallet via the app's wallet manager.
|
||||||
|
await app.wallets.createWallet(genesisData.value);
|
||||||
blockchain = new BlockchainElectrum({
|
|
||||||
servers: ['bch.imaginary.cash'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await blockchain.start();
|
|
||||||
|
|
||||||
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
|
|
||||||
|
|
||||||
if (type === 'hd') {
|
|
||||||
let hdNode: HDPublicNode;
|
|
||||||
try {
|
|
||||||
hdNode = HDPublicNode.fromXPub(key.value);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid xpub format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the xpub to use and the derivation path for display.
|
|
||||||
let accountXpub: string;
|
|
||||||
let displayPath: string;
|
|
||||||
|
|
||||||
if (accountPath.value) {
|
|
||||||
// Master xpub provided - derive the account-level xpub.
|
|
||||||
try {
|
|
||||||
const accountNode = hdNode.derivePath(accountPath.value);
|
|
||||||
accountXpub = accountNode.toXPub();
|
|
||||||
displayPath = `m/${accountPath.value}`;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Already an account-level xpub.
|
|
||||||
accountXpub = key.value;
|
|
||||||
displayPath = "m/44'/145'/0'";
|
|
||||||
}
|
|
||||||
|
|
||||||
baseWallet = await WalletHDWatch.from(
|
|
||||||
{ blockchain },
|
|
||||||
{
|
|
||||||
xpub: accountXpub,
|
|
||||||
derivationPath: displayPath,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
PublicKey.fromHex(key.value);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid public key format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
baseWallet = await WalletP2PKHWatch.from(
|
|
||||||
{ blockchain },
|
|
||||||
{ publicKey: key.value }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
wallet.value = new ReactiveWallet({ wallet: baseWallet });
|
|
||||||
await wallet.value.start();
|
|
||||||
|
|
||||||
// Build the address list.
|
|
||||||
buildAddressList();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
||||||
console.error('Wallet initialization error:', err);
|
console.error('Wallet initialization error:', err);
|
||||||
@@ -176,11 +149,76 @@ async function initializeWallet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initializeWallet();
|
||||||
|
});
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
// Computed Values
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the address list from the wallet.
|
* Map of transaction counts per address lockscript.
|
||||||
|
* Key: lockscript hex, Value: number of transactions involving this address.
|
||||||
*/
|
*/
|
||||||
function buildAddressList() {
|
const txCountsByLockscript = computed<Map<string, number>>(() => {
|
||||||
if (!wallet.value) return;
|
if (!wallet.value) return new Map();
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Iterate through all transactions in the wallet.
|
||||||
|
wallet.value.transactions.value.forEach((tx) => {
|
||||||
|
const seenLockscripts = new Set<string>();
|
||||||
|
|
||||||
|
// Check outputs for addresses belonging to this wallet.
|
||||||
|
tx.transaction.getOutputs().forEach((output) => {
|
||||||
|
const lockscriptHex = binToHex(output.lockingBytecode);
|
||||||
|
// Only count once per transaction.
|
||||||
|
if (!seenLockscripts.has(lockscriptHex)) {
|
||||||
|
seenLockscripts.add(lockscriptHex);
|
||||||
|
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check inputs (source outputs) for addresses belonging to this wallet.
|
||||||
|
tx.sourceOutputs.forEach((sourceOutput) => {
|
||||||
|
const lockscriptHex = binToHex(sourceOutput.lockingBytecode);
|
||||||
|
// Only count once per transaction.
|
||||||
|
if (!seenLockscripts.has(lockscriptHex)) {
|
||||||
|
seenLockscripts.add(lockscriptHex);
|
||||||
|
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of UTXO counts per address lockscript.
|
||||||
|
* Key: lockscript hex, Value: number of unspent outputs for this address.
|
||||||
|
*/
|
||||||
|
const utxoCountsByLockscript = computed<Map<string, number>>(() => {
|
||||||
|
if (!wallet.value) return new Map();
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Iterate through all UTXOs in the wallet.
|
||||||
|
wallet.value.unspents.value.forEach((blockchainUtxo) => {
|
||||||
|
const lockscriptHex = binToHex(blockchainUtxo.utxo.output.lockingBytecode);
|
||||||
|
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Derived addresses with metadata built from the wallet. */
|
||||||
|
const addressList = computed<AddressInfo[]>(() => {
|
||||||
|
if (!wallet.value) return [];
|
||||||
|
|
||||||
const addresses: AddressInfo[] = [];
|
const addresses: AddressInfo[] = [];
|
||||||
const baseWallet = wallet.value.wallet;
|
const baseWallet = wallet.value.wallet;
|
||||||
@@ -194,12 +232,16 @@ function buildAddressList() {
|
|||||||
childWallets.forEach((childWallet, index) => {
|
childWallets.forEach((childWallet, index) => {
|
||||||
const address = childWallet.publicKey.deriveAddress();
|
const address = childWallet.publicKey.deriveAddress();
|
||||||
const chainPathNum = Number(chainPath);
|
const chainPathNum = Number(chainPath);
|
||||||
|
const lockscriptHex = address.toLockscriptHex();
|
||||||
addresses.push({
|
addresses.push({
|
||||||
address,
|
address,
|
||||||
chainPath: chainPathNum,
|
chainPath: chainPathNum,
|
||||||
index,
|
index,
|
||||||
cashAddr: address.toCashAddr(),
|
cashAddr: address.toCashAddr(),
|
||||||
derivationPath: `${basePath}/${chainPathNum}/${index}`,
|
derivationPath: `${basePath}/${chainPathNum}/${index}`,
|
||||||
|
lockscriptHex,
|
||||||
|
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||||||
|
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -207,47 +249,22 @@ function buildAddressList() {
|
|||||||
// P2PKH Wallet - single address.
|
// P2PKH Wallet - single address.
|
||||||
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
|
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
|
||||||
const address = p2pkhWallet.publicKey.deriveAddress();
|
const address = p2pkhWallet.publicKey.deriveAddress();
|
||||||
|
const lockscriptHex = address.toLockscriptHex();
|
||||||
addresses.push({
|
addresses.push({
|
||||||
address,
|
address,
|
||||||
chainPath: 0,
|
chainPath: 0,
|
||||||
index: 0,
|
index: 0,
|
||||||
cashAddr: address.toCashAddr(),
|
cashAddr: address.toCashAddr(),
|
||||||
derivationPath: '—',
|
derivationPath: '—',
|
||||||
|
lockscriptHex,
|
||||||
|
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||||||
|
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addressList.value = addresses;
|
return addresses;
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanup() {
|
|
||||||
if (wallet.value) {
|
|
||||||
await wallet.value.stop();
|
|
||||||
await wallet.value.destroy();
|
|
||||||
wallet.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockchain) {
|
|
||||||
await blockchain.stop();
|
|
||||||
blockchain = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
// Lifecycle
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initializeWallet();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
// Computed Values
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Filtered addresses based on chain path and search query. */
|
/** Filtered addresses based on chain path and search query. */
|
||||||
const filteredAddresses = computed(() => {
|
const filteredAddresses = computed(() => {
|
||||||
let result = addressList.value;
|
let result = addressList.value;
|
||||||
@@ -383,7 +400,7 @@ function setFilter(filter: number | null) {
|
|||||||
<!-- Address List -->
|
<!-- Address List -->
|
||||||
<div class="address-list">
|
<div class="address-list">
|
||||||
<div
|
<div
|
||||||
v-for="(addr, idx) in filteredAddresses"
|
v-for="addr in filteredAddresses"
|
||||||
:key="addr.cashAddr"
|
:key="addr.cashAddr"
|
||||||
class="address-item"
|
class="address-item"
|
||||||
>
|
>
|
||||||
@@ -396,6 +413,14 @@ function setFilter(filter: number | null) {
|
|||||||
<span class="derivation-path">{{ addr.derivationPath }}</span>
|
<span class="derivation-path">{{ addr.derivationPath }}</span>
|
||||||
</div>
|
</div>
|
||||||
<code class="address-value">{{ addr.cashAddr }}</code>
|
<code class="address-value">{{ addr.cashAddr }}</code>
|
||||||
|
<div class="address-stats">
|
||||||
|
<span class="stat-badge" :class="{ inactive: addr.txCount === 0 }">
|
||||||
|
{{ addr.txCount }} tx{{ addr.txCount !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="stat-badge" :class="{ inactive: addr.utxoCount === 0 }">
|
||||||
|
{{ addr.utxoCount }} UTXO{{ addr.utxoCount !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
|
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
|
||||||
📋
|
📋
|
||||||
@@ -715,6 +740,31 @@ function setFilter(filter: number | null) {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: #00d4ff;
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge.inactive {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #4a5568;
|
||||||
|
border-color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-button {
|
.copy-button {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|||||||
@@ -1,133 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { Mnemonic, HDPrivateNode } from '@xocash/stack';
|
|
||||||
|
|
||||||
const router = useRouter();
|
import XpubInput from '../components/XpubInput.vue';
|
||||||
|
import MnemonicConverter from '../components/MnemonicConverter.vue';
|
||||||
/** The wallet key input (public key or xpub). */
|
import WalletHistory from '../components/WalletHistory.vue';
|
||||||
const walletKey = ref('');
|
|
||||||
|
|
||||||
/** Whether to show advanced options. */
|
|
||||||
const showAdvanced = ref(false);
|
|
||||||
|
|
||||||
/** The account path for master xpubs. */
|
|
||||||
const accountPath = ref('');
|
|
||||||
|
|
||||||
/** Whether this is a master xpub (requires account path derivation). */
|
|
||||||
const isMasterXpub = ref(false);
|
|
||||||
|
|
||||||
/** Error message for invalid input. */
|
|
||||||
const error = ref('');
|
|
||||||
|
|
||||||
/** Detect if the input looks like an xpub. */
|
|
||||||
const isXpub = computed(() => {
|
|
||||||
const key = walletKey.value.trim();
|
|
||||||
return key.startsWith('xpub') || key.startsWith('tpub');
|
|
||||||
});
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
// Mnemonic Converter
|
|
||||||
//-----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Whether to show the mnemonic converter. */
|
/** Whether to show the mnemonic converter. */
|
||||||
const showConverter = ref(false);
|
const showConverter = ref(false);
|
||||||
|
|
||||||
/** The mnemonic input. */
|
|
||||||
const mnemonicInput = ref('');
|
|
||||||
|
|
||||||
/** The derivation path for xpub generation. */
|
|
||||||
const xpubDerivationPath = ref("m/44'/145'/0'");
|
|
||||||
|
|
||||||
/** The generated xpub. */
|
|
||||||
const generatedXpub = ref('');
|
|
||||||
|
|
||||||
/** Error from mnemonic conversion. */
|
|
||||||
const converterError = ref('');
|
|
||||||
|
|
||||||
/** Whether conversion is in progress. */
|
|
||||||
const isConverting = ref(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert mnemonic to xpub.
|
|
||||||
*/
|
|
||||||
async function convertMnemonicToXpub() {
|
|
||||||
const phrase = mnemonicInput.value.trim();
|
|
||||||
|
|
||||||
if (!phrase) {
|
|
||||||
converterError.value = 'Please enter a mnemonic phrase';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isConverting.value = true;
|
|
||||||
converterError.value = '';
|
|
||||||
generatedXpub.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse and validate the mnemonic.
|
|
||||||
const mnemonic = Mnemonic.fromPhrase(phrase);
|
|
||||||
|
|
||||||
// Create the HD private node from the mnemonic.
|
|
||||||
const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic);
|
|
||||||
|
|
||||||
// Derive to the account level (hardened path).
|
|
||||||
const accountNode = hdPrivateNode.derivePath(xpubDerivationPath.value);
|
|
||||||
|
|
||||||
// Get the public node and export as xpub.
|
|
||||||
const hdPublicNode = accountNode.deriveHDPublicNode();
|
|
||||||
generatedXpub.value = hdPublicNode.toXPub();
|
|
||||||
} catch (e) {
|
|
||||||
converterError.value = e instanceof Error ? e.message : 'Failed to convert mnemonic';
|
|
||||||
} finally {
|
|
||||||
isConverting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use the generated xpub.
|
|
||||||
*/
|
|
||||||
function useGeneratedXpub() {
|
|
||||||
if (generatedXpub.value) {
|
|
||||||
walletKey.value = generatedXpub.value;
|
|
||||||
// Clear sensitive data.
|
|
||||||
mnemonicInput.value = '';
|
|
||||||
generatedXpub.value = '';
|
|
||||||
showConverter.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy xpub to clipboard.
|
|
||||||
*/
|
|
||||||
function copyXpub() {
|
|
||||||
if (generatedXpub.value) {
|
|
||||||
navigator.clipboard.writeText(generatedXpub.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to the wallet view page.
|
|
||||||
*/
|
|
||||||
function viewWallet() {
|
|
||||||
const key = walletKey.value.trim();
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
error.value = 'Please enter a public key or xpub';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = '';
|
|
||||||
|
|
||||||
// Build query params for advanced options.
|
|
||||||
const query: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (isMasterXpub.value && accountPath.value.trim()) {
|
|
||||||
query.accountPath = accountPath.value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push({ name: 'wallet', params: { key }, query });
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -140,76 +19,8 @@ function viewWallet() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-section">
|
<div class="input-section">
|
||||||
<div class="input-wrapper">
|
<!-- Raw xpub/public key input -->
|
||||||
<input
|
<XpubInput />
|
||||||
v-model="walletKey"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter public key or xpub..."
|
|
||||||
class="key-input"
|
|
||||||
@keyup.enter="viewWallet"
|
|
||||||
/>
|
|
||||||
<button class="view-button" @click="viewWallet">
|
|
||||||
View Wallet
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
|
||||||
|
|
||||||
<!-- Advanced Options Toggle -->
|
|
||||||
<div v-if="isXpub" class="advanced-toggle">
|
|
||||||
<button
|
|
||||||
class="toggle-button"
|
|
||||||
@click="showAdvanced = !showAdvanced"
|
|
||||||
>
|
|
||||||
{{ showAdvanced ? '▼' : '▶' }} Advanced Options
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Options Panel -->
|
|
||||||
<div v-if="showAdvanced && isXpub" class="advanced-panel">
|
|
||||||
<div class="option-row">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="isMasterXpub"
|
|
||||||
class="checkbox"
|
|
||||||
/>
|
|
||||||
<span>Derive from a non-hardened path (e.g., 0/0)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isMasterXpub" class="path-input-section">
|
|
||||||
<label class="input-label">Derivation Path (non-hardened only)</label>
|
|
||||||
<input
|
|
||||||
v-model="accountPath"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., 0 or 0/0"
|
|
||||||
class="path-input"
|
|
||||||
/>
|
|
||||||
<p class="input-hint">
|
|
||||||
Only non-hardened paths work with xpub (no apostrophes)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="warning-box">
|
|
||||||
<span class="warning-icon">⚠️</span>
|
|
||||||
<div class="warning-content">
|
|
||||||
<strong>Hardened paths require private keys</strong>
|
|
||||||
<p>
|
|
||||||
Paths like <code>44'/145'/0'</code> cannot be derived from an xpub.
|
|
||||||
Export the account-level xpub directly from your wallet instead.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<p class="help-title">How to get the correct xpub:</p>
|
|
||||||
<ul class="help-list">
|
|
||||||
<li><strong>Electron Cash:</strong> Wallet → Information → copy the "Master Public Key" (this is already at account level)</li>
|
|
||||||
<li><strong>Other wallets:</strong> Export the xpub from the account you want to watch</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mnemonic Converter Toggle -->
|
<!-- Mnemonic Converter Toggle -->
|
||||||
<div class="converter-toggle">
|
<div class="converter-toggle">
|
||||||
@@ -221,97 +32,14 @@ function viewWallet() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mnemonic Converter -->
|
<!-- Mnemonic Converter Panel -->
|
||||||
<div v-if="showConverter" class="converter-panel">
|
<div v-if="showConverter" class="converter-panel">
|
||||||
<div class="security-warning">
|
<MnemonicConverter :show-instructions="true" />
|
||||||
<span class="warning-icon">🔐</span>
|
|
||||||
<div>
|
|
||||||
<strong>Security Notice</strong>
|
|
||||||
<p>Your mnemonic is processed locally and never sent anywhere. Clear it after use.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="converter-form">
|
<!-- Wallet History -->
|
||||||
<label class="input-label">Mnemonic Phrase</label>
|
<div class="wallet-section">
|
||||||
<textarea
|
<WalletHistory />
|
||||||
v-model="mnemonicInput"
|
|
||||||
placeholder="Enter your 12 or 24 word recovery phrase..."
|
|
||||||
class="mnemonic-input"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<label class="input-label">Derivation Path</label>
|
|
||||||
<div class="path-row">
|
|
||||||
<input
|
|
||||||
v-model="xpubDerivationPath"
|
|
||||||
type="text"
|
|
||||||
class="path-input"
|
|
||||||
/>
|
|
||||||
<div class="path-presets">
|
|
||||||
<button
|
|
||||||
class="preset-btn"
|
|
||||||
:class="{ active: xpubDerivationPath === `m/44'/145'/0'` }"
|
|
||||||
@click="xpubDerivationPath = `m/44'/145'/0'`"
|
|
||||||
>
|
|
||||||
BCH
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="preset-btn"
|
|
||||||
:class="{ active: xpubDerivationPath === `m/44'/0'/0'` }"
|
|
||||||
@click="xpubDerivationPath = `m/44'/0'/0'`"
|
|
||||||
>
|
|
||||||
BTC
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="preset-btn"
|
|
||||||
:class="{ active: xpubDerivationPath === `m/44'/245'/0'` }"
|
|
||||||
@click="xpubDerivationPath = `m/44'/245'/0'`"
|
|
||||||
>
|
|
||||||
BCH Alt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="convert-button"
|
|
||||||
@click="convertMnemonicToXpub"
|
|
||||||
:disabled="isConverting"
|
|
||||||
>
|
|
||||||
{{ isConverting ? 'Converting...' : 'Generate xpub' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p v-if="converterError" class="error-message">{{ converterError }}</p>
|
|
||||||
|
|
||||||
<!-- Generated xpub -->
|
|
||||||
<div v-if="generatedXpub" class="generated-xpub">
|
|
||||||
<label class="input-label">Generated xpub</label>
|
|
||||||
<div class="xpub-display">
|
|
||||||
<code>{{ generatedXpub }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="xpub-actions">
|
|
||||||
<button class="action-btn" @click="copyXpub">
|
|
||||||
📋 Copy
|
|
||||||
</button>
|
|
||||||
<button class="action-btn primary" @click="useGeneratedXpub">
|
|
||||||
✓ Use this xpub
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="examples">
|
|
||||||
<p class="examples-title">Supported Inputs:</p>
|
|
||||||
<ul class="examples-list">
|
|
||||||
<li>
|
|
||||||
<strong>Public Key (P2PKH):</strong>
|
|
||||||
<code>02...</code> or <code>03...</code> (33 bytes hex, 66 characters)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Account-level xpub:</strong>
|
|
||||||
<code>xpub...</code> — Export from Electron Cash via Wallet → Information
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,65 +83,11 @@ function viewWallet() {
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
/* Converter Toggle */
|
||||||
display: flex;
|
.converter-toggle {
|
||||||
gap: 0.75rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 2px solid #2d3748;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #fff;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input::placeholder {
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #00d4ff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-button {
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #fc8181;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced Options */
|
|
||||||
.advanced-toggle {
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
@@ -430,82 +104,6 @@ function viewWallet() {
|
|||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced-panel {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-row {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: #a0aec0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: #00d4ff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-input-section {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #a0aec0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #fff;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-input::placeholder {
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #718096;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Converter Toggle */
|
|
||||||
.converter-toggle {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Converter Panel */
|
/* Converter Panel */
|
||||||
.converter-panel {
|
.converter-panel {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
@@ -515,301 +113,8 @@ function viewWallet() {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-warning {
|
/* Wallet Section */
|
||||||
display: flex;
|
.wallet-section {
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(72, 187, 120, 0.1);
|
|
||||||
border: 1px solid rgba(72, 187, 120, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-warning .warning-icon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-warning strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #48bb78;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-warning p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mnemonic-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #fff;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mnemonic-input::placeholder {
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mnemonic-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-row .path-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-presets {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
color: #a0aec0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn:hover {
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
|
||||||
border-color: #00d4ff;
|
|
||||||
color: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn.active {
|
|
||||||
background: rgba(0, 212, 255, 0.2);
|
|
||||||
border-color: #00d4ff;
|
|
||||||
color: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.convert-button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.convert-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.convert-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.generated-xpub {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xpub-display {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: rgba(0, 212, 255, 0.05);
|
|
||||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xpub-display code {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #00d4ff;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xpub-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #a0aec0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.primary {
|
|
||||||
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.primary:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Warning Box */
|
|
||||||
.warning-box {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(237, 137, 54, 0.1);
|
|
||||||
border: 1px solid rgba(237, 137, 54, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-content {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #ed8936;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-content strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-content p {
|
|
||||||
margin: 0;
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-content code {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
background: rgba(237, 137, 54, 0.2);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #ed8936;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Section */
|
|
||||||
.help-section {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #a0aec0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-list li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-list li::before {
|
|
||||||
content: "•";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
color: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-list strong {
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Examples */
|
|
||||||
.examples {
|
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding: 1.5rem;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #a0aec0;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-list li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-list li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-list code {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #00d4ff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.examples-list strong {
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, shallowRef, watch } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BlockchainElectrum,
|
BaseWallet,
|
||||||
PublicKey,
|
Bytes,
|
||||||
BlockHeader,
|
|
||||||
} from '@xocash/stack';
|
} from '@xocash/stack';
|
||||||
|
|
||||||
import { WalletP2PKHWatch } from '../xo-extensions/wallet-p2pkh-watch.js';
|
import { useApp } from '../services/app.js';
|
||||||
import { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js';
|
|
||||||
import { HDPublicNode } from '../xo-extensions/hd-public-node.js';
|
|
||||||
|
|
||||||
import { ReactiveWallet } from '../services/wallet.js';
|
const app = useApp();
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -37,8 +34,37 @@ const key = computed(() => route.params.key as string);
|
|||||||
/** The account path from query params (for master xpubs). */
|
/** The account path from query params (for master xpubs). */
|
||||||
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
||||||
|
|
||||||
/** The detected wallet type. */
|
/** The wallet type. */
|
||||||
const walletType = ref<WalletType | null>(null);
|
const walletType = computed<WalletType | null>(() => {
|
||||||
|
if (isXPub(key.value)) return 'hd';
|
||||||
|
if (isPublicKey(key.value)) return 'p2pkh';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wallet = computed(() => {
|
||||||
|
console.log('wallet', app.wallets.wallets);
|
||||||
|
|
||||||
|
const genesisData = walletType.value === 'hd'
|
||||||
|
? {
|
||||||
|
type: 'WalletHDWatch',
|
||||||
|
xpub: key.value,
|
||||||
|
derivationPath: accountPath.value ?? '',
|
||||||
|
} as const
|
||||||
|
: {
|
||||||
|
type: 'WalletP2PKHWatch',
|
||||||
|
publicKey: key.value,
|
||||||
|
} as const
|
||||||
|
console.log('genesisData', genesisData);
|
||||||
|
|
||||||
|
// Use the genesis data to derive the wallet ID
|
||||||
|
const walletId = BaseWallet.deriveId(genesisData);
|
||||||
|
|
||||||
|
console.log('walletId', Bytes.from(walletId).toHex());
|
||||||
|
|
||||||
|
const wallet = app.wallets.wallets[Bytes.from(walletId).toHex()];
|
||||||
|
console.log('wallet', wallet);
|
||||||
|
return wallet;
|
||||||
|
});
|
||||||
|
|
||||||
/** Loading state. */
|
/** Loading state. */
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -46,15 +72,6 @@ const isLoading = ref(true);
|
|||||||
/** Error message if wallet creation fails. */
|
/** Error message if wallet creation fails. */
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
/** The reactive wallet instance. */
|
|
||||||
const wallet = shallowRef<ReactiveWallet | null>(null);
|
|
||||||
|
|
||||||
/** The blockchain adapter (shared). */
|
|
||||||
let blockchain: BlockchainElectrum | null = null;
|
|
||||||
|
|
||||||
/** The block headers. */
|
|
||||||
const blockHeaders = ref<{ [height: number]: BlockHeader }>({});
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Wallet Type Detection
|
// Wallet Type Detection
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
@@ -85,18 +102,6 @@ function isPublicKey(key: string): boolean {
|
|||||||
return /^[0-9a-fA-F]+$/.test(key);
|
return /^[0-9a-fA-F]+$/.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the wallet type from the key.
|
|
||||||
*
|
|
||||||
* @param key - The key to analyze.
|
|
||||||
* @returns The wallet type or null if invalid.
|
|
||||||
*/
|
|
||||||
function detectWalletType(key: string): WalletType | null {
|
|
||||||
if (isXPub(key)) return 'hd';
|
|
||||||
if (isPublicKey(key)) return 'p2pkh';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Wallet Creation
|
// Wallet Creation
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
@@ -109,99 +114,26 @@ async function initializeWallet() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Detect the wallet type.
|
if (wallet.value) {
|
||||||
const type = detectWalletType(key.value);
|
return;
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
throw new Error(
|
|
||||||
'Invalid key format. Please provide a valid public key (02... or 03...) or xpub.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
walletType.value = type;
|
console.log('WALLET WAS NOT FOUND, CREATING...')
|
||||||
|
|
||||||
// Create the blockchain adapter.
|
const genesisData = walletType.value === 'hd'
|
||||||
blockchain = new BlockchainElectrum({
|
? {
|
||||||
// TODO: Make this configurable
|
type: 'WalletHDWatch',
|
||||||
servers: ['bch.imaginary.cash'],
|
xpub: key.value,
|
||||||
});
|
derivationPath: accountPath.value ?? '',
|
||||||
|
} as const
|
||||||
|
: {
|
||||||
|
type: 'WalletP2PKHWatch',
|
||||||
|
publicKey: key.value,
|
||||||
|
} as const
|
||||||
|
|
||||||
// Wait for the blockchain to connect.
|
console.log('genesisData', genesisData);
|
||||||
await blockchain.start();
|
|
||||||
|
|
||||||
// Create the appropriate wallet type.
|
await app.wallets.createWallet(genesisData);
|
||||||
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
|
|
||||||
|
|
||||||
if (type === 'hd') {
|
|
||||||
// Validate the xpub.
|
|
||||||
let hdNode: HDPublicNode;
|
|
||||||
try {
|
|
||||||
hdNode = HDPublicNode.fromXPub(key.value);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid xpub format. Please check and try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the xpub to use and the derivation path for display.
|
|
||||||
let accountXpub: string;
|
|
||||||
let displayPath: string;
|
|
||||||
|
|
||||||
if (accountPath.value) {
|
|
||||||
// Master xpub provided - derive the account-level xpub.
|
|
||||||
try {
|
|
||||||
const accountNode = hdNode.derivePath(accountPath.value);
|
|
||||||
accountXpub = accountNode.toXPub();
|
|
||||||
displayPath = `m/${accountPath.value}`;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Already an account-level xpub.
|
|
||||||
accountXpub = key.value;
|
|
||||||
displayPath = "m/44'/145'/0'";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the HD watch wallet with the account-level xpub.
|
|
||||||
baseWallet = await WalletHDWatch.from(
|
|
||||||
{ blockchain },
|
|
||||||
{
|
|
||||||
xpub: accountXpub,
|
|
||||||
derivationPath: displayPath,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Validate the public key.
|
|
||||||
try {
|
|
||||||
PublicKey.fromHex(key.value);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid public key format. Please check and try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the P2PKH watch wallet.
|
|
||||||
baseWallet = await WalletP2PKHWatch.from(
|
|
||||||
{ blockchain },
|
|
||||||
{ publicKey: key.value }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap in reactive wallet.
|
|
||||||
wallet.value = new ReactiveWallet({ wallet: baseWallet });
|
|
||||||
|
|
||||||
watch(wallet.value.transactions, (transactions) => {
|
|
||||||
transactions.forEach((tx) => {
|
|
||||||
blockchain?.fetchBlockHeader(tx.height).then((blockHeader) => {
|
|
||||||
blockHeaders.value[tx.height] = blockHeader;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wallet.value.transactions.value.forEach((tx) => {
|
|
||||||
blockchain?.fetchBlockHeader(tx.height).then((blockHeader) => {
|
|
||||||
blockHeaders.value[tx.height] = blockHeader;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the wallet (this triggers the initial scan/fetch).
|
|
||||||
await wallet.value.start();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
||||||
console.error('Wallet initialization error:', err);
|
console.error('Wallet initialization error:', err);
|
||||||
@@ -210,22 +142,6 @@ async function initializeWallet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up the wallet and blockchain connection.
|
|
||||||
*/
|
|
||||||
async function cleanup() {
|
|
||||||
if (wallet.value) {
|
|
||||||
await wallet.value.stop();
|
|
||||||
await wallet.value.destroy();
|
|
||||||
wallet.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockchain) {
|
|
||||||
await blockchain.stop();
|
|
||||||
blockchain = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
@@ -234,10 +150,6 @@ onMounted(() => {
|
|||||||
initializeWallet();
|
initializeWallet();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
// Computed Values
|
// Computed Values
|
||||||
//-----------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------
|
||||||
@@ -257,6 +169,8 @@ const transactionCount = computed(() => {
|
|||||||
|
|
||||||
/** Sorted transactions (newest first). */
|
/** Sorted transactions (newest first). */
|
||||||
const sortedTransactions = computed(() => {
|
const sortedTransactions = computed(() => {
|
||||||
|
console.log('123')
|
||||||
|
console.log('sortedTransactions', wallet.value?.transactions.value.toArray());
|
||||||
if (!wallet.value) return [];
|
if (!wallet.value) return [];
|
||||||
|
|
||||||
return wallet.value.transactions.value
|
return wallet.value.transactions.value
|
||||||
@@ -413,7 +327,7 @@ function viewAddresses() {
|
|||||||
</span>
|
</span>
|
||||||
<!-- block timestamp -->
|
<!-- block timestamp -->
|
||||||
<span class="tx-time">
|
<span class="tx-time">
|
||||||
{{ blockHeaders[tx.height]?.getTimestampDate().toLocaleString('en-AU', { dateStyle: 'short', timeStyle: 'short' }) }}
|
{{ wallet.blockHeaders.value[tx.height]?.getTimestampDate().toLocaleString('en-AU', { dateStyle: 'short', timeStyle: 'short' }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Rates } from './rates.js';
|
import { Rates } from './rates.js';
|
||||||
import { Settings } from './settings.js';
|
// import { Settings } from './settings.js';
|
||||||
import { Wallets } from './wallets.js';
|
import { Wallets } from './wallets.js';
|
||||||
|
|
||||||
import { inject, ref } from 'vue';
|
import { inject, ref } from 'vue';
|
||||||
@@ -16,7 +16,7 @@ import { ReactiveBlockchain } from './blockchain.js';
|
|||||||
|
|
||||||
export type AppDependencies = {
|
export type AppDependencies = {
|
||||||
router: Router;
|
router: Router;
|
||||||
settings: Settings;
|
// settings: Settings;
|
||||||
blockchain: ReactiveBlockchain;
|
blockchain: ReactiveBlockchain;
|
||||||
rates: Rates;
|
rates: Rates;
|
||||||
walletStorage: BaseStorage;
|
walletStorage: BaseStorage;
|
||||||
@@ -32,7 +32,7 @@ export class App {
|
|||||||
//---------------------------------------------------------------------------
|
//---------------------------------------------------------------------------
|
||||||
|
|
||||||
public router: Router;
|
public router: Router;
|
||||||
public settings: Settings;
|
// public settings: Settings;
|
||||||
public blockchain: ReactiveBlockchain;
|
public blockchain: ReactiveBlockchain;
|
||||||
public rates: Rates;
|
public rates: Rates;
|
||||||
public walletStorage: BaseStorage;
|
public walletStorage: BaseStorage;
|
||||||
@@ -43,7 +43,7 @@ export class App {
|
|||||||
dependencies: AppDependencies
|
dependencies: AppDependencies
|
||||||
) {
|
) {
|
||||||
this.router = dependencies.router;
|
this.router = dependencies.router;
|
||||||
this.settings = dependencies.settings;
|
// this.settings = dependencies.settings;
|
||||||
this.blockchain = dependencies.blockchain;
|
this.blockchain = dependencies.blockchain;
|
||||||
this.rates = dependencies.rates;
|
this.rates = dependencies.rates;
|
||||||
this.walletStorage = dependencies.walletStorage;
|
this.walletStorage = dependencies.walletStorage;
|
||||||
@@ -53,24 +53,30 @@ export class App {
|
|||||||
|
|
||||||
static async create(router: Router) {
|
static async create(router: Router) {
|
||||||
// Setup app storage adapter.
|
// Setup app storage adapter.
|
||||||
const appStorage = await StorageLocalStorage.createOrOpen('xoApp_V0.0.1');
|
// TODO: Add settings so we can select things like currency and exchange rate provider.
|
||||||
const settingsStore = await appStorage.createOrGetStore('settings');
|
const appStorage = await StorageLocalStorage.createOrOpen('IncomeTaxApp_V0.0.1');
|
||||||
|
// const settingsStore = await appStorage.createOrGetStore('settings');
|
||||||
|
|
||||||
// Create settings class.
|
// Create settings class.
|
||||||
const settings = await Settings.from(settingsStore);
|
// const settings = await Settings.from(settingsStore);
|
||||||
|
|
||||||
|
console.time('createApp');
|
||||||
|
|
||||||
// Setup rates.
|
// Setup rates.
|
||||||
const rates = await Rates.from();
|
// Setup rates.
|
||||||
|
const ratesStore = await appStorage.createOrGetStore('rates');
|
||||||
|
const rates = await Rates.from(ratesStore);
|
||||||
rates.start();
|
rates.start();
|
||||||
|
console.log('started rates')
|
||||||
|
|
||||||
// Setup wallets storage adapter.
|
// Setup wallets storage adapter.
|
||||||
const walletStorage = await StorageLocalStorage.createOrOpen(
|
const walletStorage = await StorageLocalStorage.createOrOpen(
|
||||||
'xoWallets_V0.0.5'
|
'IncomeTaxWallets_V0.0.1'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Setup cache storage adapter.
|
// Setup cache storage adapter.
|
||||||
const cacheStorage = await StorageLocalStorage.createOrOpen(
|
const cacheStorage = await StorageLocalStorage.createOrOpen(
|
||||||
'xoWalletCache_V0.0.5'
|
'IncomeTaxWalletCache_V0.0.1'
|
||||||
);
|
);
|
||||||
const blockchainCache = await cacheStorage.createOrGetStore(
|
const blockchainCache = await cacheStorage.createOrGetStore(
|
||||||
'electrumBlockchain',
|
'electrumBlockchain',
|
||||||
@@ -79,13 +85,18 @@ export class App {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const blockchainElectrum = new BlockchainElectrum({
|
||||||
|
store: blockchainCache,
|
||||||
|
});
|
||||||
|
blockchainElectrum.start();
|
||||||
|
|
||||||
// Setup blockchain (and use our cache).
|
// Setup blockchain (and use our cache).
|
||||||
const blockchain = await ReactiveBlockchain.from(
|
const blockchain = await ReactiveBlockchain.from(
|
||||||
await BlockchainElectrum.from({
|
blockchainElectrum
|
||||||
store: blockchainCache,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('started blockchain')
|
||||||
|
|
||||||
// Setup wallet manager.
|
// Setup wallet manager.
|
||||||
const walletManager = new Wallets(
|
const walletManager = new Wallets(
|
||||||
blockchain.blockchain,
|
blockchain.blockchain,
|
||||||
@@ -94,6 +105,10 @@ export class App {
|
|||||||
);
|
);
|
||||||
await walletManager.start();
|
await walletManager.start();
|
||||||
|
|
||||||
|
console.log('started wallet manager')
|
||||||
|
|
||||||
|
console.timeEnd('createApp');
|
||||||
|
|
||||||
// Create new instance of app.
|
// Create new instance of app.
|
||||||
return new this(
|
return new this(
|
||||||
{
|
{
|
||||||
@@ -101,7 +116,7 @@ export class App {
|
|||||||
blockchain,
|
blockchain,
|
||||||
cache: cacheStorage,
|
cache: cacheStorage,
|
||||||
rates,
|
rates,
|
||||||
settings,
|
// settings,
|
||||||
walletStorage,
|
walletStorage,
|
||||||
wallets: walletManager,
|
wallets: walletManager,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,60 @@
|
|||||||
import { ref, triggerRef } from 'vue';
|
import { triggerRef } from 'vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BaseRates,
|
type BaseRates,
|
||||||
RatesComposite,
|
RatesComposite,
|
||||||
|
RatesBitPay,
|
||||||
|
RatesCoinbase,
|
||||||
|
// RatesCryptoCompare,
|
||||||
RatesOracle,
|
RatesOracle,
|
||||||
ratesMedianPolicy,
|
ratesMedianPolicy,
|
||||||
|
BaseStore,
|
||||||
} from '@xocash/stack';
|
} from '@xocash/stack';
|
||||||
|
|
||||||
import { Mixin } from '@/utils/mixin';
|
import { OracleClient } from '@generalprotocols/oracle-client';
|
||||||
|
import { ReactiveStore } from 'src/utils/reactive-store.js';
|
||||||
|
|
||||||
|
export type RatesStore = { [key: string]: Rate | undefined };
|
||||||
|
export type Rate = {
|
||||||
|
price: number;
|
||||||
|
lastUpdated: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*/
|
*/
|
||||||
export class Rates extends Mixin([BaseRates]){
|
export class Rates {
|
||||||
// Our rates adapter.
|
// Our rates adapter.
|
||||||
private readonly adapter: BaseRates;
|
private readonly adapter: BaseRates;
|
||||||
|
|
||||||
// Our individual rates as a reactives.
|
// Our individual rates as a reactive store.
|
||||||
// NOTE: Because our adapter sends us updates one by one, we must use triggerRef to update this.
|
// NOTE: Because our adapter sends us updates one by one, we must use triggerRef to update this.
|
||||||
private rates = ref<{ [key: string]: number | undefined }>({});
|
private readonly ratesStore: ReactiveStore<RatesStore>;
|
||||||
|
|
||||||
static async from(refreshMilliseconds = 60_000) {
|
static async from(store: BaseStore, refreshMilliseconds = 60_000) {
|
||||||
const oracle = await RatesOracle.from();
|
// Create a reactive store to persist our rates if the user opens the wallet without internet
|
||||||
|
const ratesStore = await ReactiveStore.from<RatesStore>(store, {});
|
||||||
|
|
||||||
|
const oracleClient = new OracleClient();
|
||||||
|
const oracle = await RatesOracle.from(oracleClient);
|
||||||
|
|
||||||
|
const coinbase = RatesCoinbase.from();
|
||||||
|
const bitpay = RatesBitPay.from();
|
||||||
|
// NOTE: This API is way too slow and was used so that Saqib could get conversions in Cambodia.
|
||||||
|
// So we are disabling it for now.
|
||||||
|
// const cryptocompare = RatesCryptoCompare.from();
|
||||||
|
|
||||||
const compositeAdapter = new RatesComposite(
|
const compositeAdapter = new RatesComposite(
|
||||||
[oracle],
|
[oracle, coinbase, bitpay],
|
||||||
ratesMedianPolicy,
|
ratesMedianPolicy,
|
||||||
refreshMilliseconds
|
refreshMilliseconds
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Rates(compositeAdapter);
|
return new Rates(compositeAdapter, ratesStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(ratesAdapter: BaseRates) {
|
constructor(ratesAdapter: BaseRates, ratesStore: ReactiveStore<RatesStore>) {
|
||||||
super(ratesAdapter);
|
|
||||||
|
|
||||||
this.adapter = ratesAdapter;
|
this.adapter = ratesAdapter;
|
||||||
|
this.ratesStore = ratesStore;
|
||||||
|
|
||||||
this.adapter.on(
|
this.adapter.on(
|
||||||
'rateUpdated',
|
'rateUpdated',
|
||||||
@@ -43,20 +63,30 @@ export class Rates extends Mixin([BaseRates]){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rates.value[numeratorUnitCode] = price;
|
this.ratesStore.set(numeratorUnitCode, {
|
||||||
triggerRef(this.rates);
|
price,
|
||||||
|
lastUpdated: Date.now() / 1000,
|
||||||
|
});
|
||||||
|
triggerRef(this.ratesStore.prop(numeratorUnitCode));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
console.log('rates started');
|
||||||
|
|
||||||
|
// Start our rates adapter.
|
||||||
|
await this.adapter.start();
|
||||||
|
}
|
||||||
|
|
||||||
toBCH(amount: number, fromCurrency: string): number {
|
toBCH(amount: number, fromCurrency: string): number {
|
||||||
const rate = this.rates.value[fromCurrency];
|
const rate = this.ratesStore.prop(fromCurrency).value;
|
||||||
if (rate === undefined) {
|
if (!rate) {
|
||||||
return 0;
|
return 0;
|
||||||
// throw new Error(`Currency ${fromCurrency} not supported.`);
|
// throw new Error(`Currency ${fromCurrency} not supported.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Rates.roundToDigits(amount / rate, 8);
|
return Rates.roundToDigits(amount / rate.price, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
toSats(amount: number, fromCurrency: string): number {
|
toSats(amount: number, fromCurrency: string): number {
|
||||||
@@ -72,13 +102,14 @@ export class Rates extends Mixin([BaseRates]){
|
|||||||
}
|
}
|
||||||
|
|
||||||
fromBCH(amount: number, targetCurrency: string): number {
|
fromBCH(amount: number, targetCurrency: string): number {
|
||||||
const rate = this.rates.value[targetCurrency];
|
const rate = this.ratesStore.prop(targetCurrency).value;
|
||||||
if (rate === undefined) {
|
|
||||||
|
if (!rate) {
|
||||||
return 0;
|
return 0;
|
||||||
// throw new Error(`Currency ${targetCurrency} not supported.`);
|
// throw new Error(`Currency ${targetCurrency} not supported.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Rates.roundToDigits(amount * rate, 2);
|
return Rates.roundToDigits(amount * rate.price, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
formatSats(sats: number | bigint, targetCurrency: string) {
|
formatSats(sats: number | bigint, targetCurrency: string) {
|
||||||
@@ -87,6 +118,28 @@ export class Rates extends Mixin([BaseRates]){
|
|||||||
return this.formatCurrency(amount, targetCurrency);
|
return this.formatCurrency(amount, targetCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currency: string,
|
||||||
|
opts: Partial<Intl.NumberFormatOptions> = {}
|
||||||
|
) {
|
||||||
|
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||||
|
AUD: 2,
|
||||||
|
BCH: 8,
|
||||||
|
USD: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
currencyDisplay: 'narrowSymbol', // Uses a shorter currency symbol when available
|
||||||
|
minimumFractionDigits: minimumFractionDigitsMap[currency] || 0, // Controls decimal places
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
static roundToDigits(numberToRound: number, digits: number): number {
|
static roundToDigits(numberToRound: number, digits: number): number {
|
||||||
// Set the options of the Number Format object.
|
// Set the options of the Number Format object.
|
||||||
const options: Intl.NumberFormatOptions = {
|
const options: Intl.NumberFormatOptions = {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class ReactiveWallet<T extends BaseWallet = BaseWallet> extends Mixin([
|
|||||||
]) {
|
]) {
|
||||||
public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
|
public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
|
||||||
const reactiveWallet = new ReactiveWallet(deps);
|
const reactiveWallet = new ReactiveWallet(deps);
|
||||||
await reactiveWallet.start();
|
// await reactiveWallet.start();
|
||||||
return reactiveWallet;
|
return reactiveWallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,44 +2,43 @@ import { shallowReactive, toRaw } from 'vue';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type BaseStorage,
|
type BaseStorage,
|
||||||
type WalletHDEntropy,
|
|
||||||
type WalletHDDerivationData,
|
|
||||||
type WalletHDGenesisData,
|
|
||||||
type WalletBlockchain,
|
type WalletBlockchain,
|
||||||
type WalletP2PKHGenesisData,
|
|
||||||
type WalletName,
|
type WalletName,
|
||||||
StoreInMemory,
|
StoreInMemory,
|
||||||
BaseStore,
|
BaseStore,
|
||||||
WalletHD,
|
|
||||||
WalletP2PKH,
|
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
Mnemonic,
|
|
||||||
type MnemonicRaw,
|
|
||||||
} from '@xocash/stack';
|
} from '@xocash/stack';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type WalletHDWatchEntropy,
|
||||||
|
type WalletHDWatchDerivationData,
|
||||||
|
type WalletHDWatchGenesisData,
|
||||||
|
type WalletP2PKHWatchGenesisData,
|
||||||
|
WalletHDWatch,
|
||||||
|
WalletP2PKHWatch,
|
||||||
|
} from '../xo-extensions/index.js';
|
||||||
|
|
||||||
import { ReactiveWallet } from 'src/services/wallet.js';
|
import { ReactiveWallet } from 'src/services/wallet.js';
|
||||||
|
|
||||||
// Remove our definition for using Mnemonic in the WalletHDGenesisData type, we store them as MnemonicRaw so we can instantiate them.
|
// Remove our definition for using Mnemonic in the WalletHDGenesisData type, we store them as MnemonicRaw so we can instantiate them.
|
||||||
export type AppWalletHDGenesisData = // Remove mnemonic option
|
export type AppWalletHDWatchGenesisData = // Remove mnemonic option
|
||||||
((
|
((
|
||||||
| Exclude<WalletHDEntropy, { mnemonic: Mnemonic }>
|
WalletHDWatchEntropy
|
||||||
// Add mnemonic option with MnemonicRaw type
|
|
||||||
| { mnemonic: MnemonicRaw }
|
|
||||||
) &
|
) &
|
||||||
WalletHDDerivationData) & {
|
WalletHDWatchDerivationData) & {
|
||||||
type: 'WalletHD';
|
type: 'WalletHDWatch';
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppWalletP2PKHGenesisData = WalletP2PKHGenesisData & {
|
export type AppWalletP2PKHWatchGenesisData = WalletP2PKHWatchGenesisData & {
|
||||||
type: 'WalletP2PKH';
|
type: 'WalletP2PKHWatch';
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
export type AppWalletData = AppWalletP2PKHGenesisData | AppWalletHDGenesisData;
|
export type AppWalletData = AppWalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
|
||||||
export type AppWalletStore = { [uid: string]: AppWalletData };
|
export type AppWalletStore = { [uid: string]: AppWalletData };
|
||||||
|
|
||||||
export type WalletsStore = WalletP2PKHGenesisData | AppWalletHDGenesisData;
|
export type WalletsStore = WalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
|
||||||
export type WalletsSupported = WalletP2PKHGenesisData | AppWalletHDGenesisData;
|
export type WalletsSupported = WalletP2PKHWatchGenesisData | AppWalletHDWatchGenesisData;
|
||||||
|
|
||||||
export class Wallets {
|
export class Wallets {
|
||||||
public walletsStore?: BaseStore;
|
public walletsStore?: BaseStore;
|
||||||
@@ -72,24 +71,17 @@ export class Wallets {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If this is a WalletHD type...
|
// If this is a WalletHD type...
|
||||||
if (genesisData.type === 'WalletHD') {
|
if (genesisData.type === 'WalletHDWatch') {
|
||||||
// Convert the mnemonic from Raw to Instance if we are using a mnemonic.
|
|
||||||
const walletData =
|
|
||||||
'mnemonic' in genesisData
|
|
||||||
? {
|
|
||||||
...genesisData,
|
|
||||||
mnemonic: Mnemonic.fromRaw(genesisData.mnemonic),
|
|
||||||
}
|
|
||||||
: genesisData;
|
|
||||||
|
|
||||||
// Instantiate the wallet.
|
// Instantiate the wallet.
|
||||||
const wallet = await WalletHD.from(
|
console.time('WalletHDWatch.from');
|
||||||
|
const wallet = await WalletHDWatch.from(
|
||||||
{
|
{
|
||||||
blockchain: this.blockchain,
|
blockchain: this.blockchain,
|
||||||
cache: cacheStore,
|
cache: cacheStore,
|
||||||
},
|
},
|
||||||
walletData
|
genesisData
|
||||||
);
|
);
|
||||||
|
console.timeEnd('WalletHDWatch.from');
|
||||||
|
|
||||||
// Add a persistent store for our activities.
|
// Add a persistent store for our activities.
|
||||||
await wallet.activities.store.addStore(
|
await wallet.activities.store.addStore(
|
||||||
@@ -100,16 +92,18 @@ export class Wallets {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Push it to our list of wallets.
|
// Push it to our list of wallets.
|
||||||
|
console.time('ReactiveWallet.from');
|
||||||
this.wallets[walletId] = await ReactiveWallet.from({
|
this.wallets[walletId] = await ReactiveWallet.from({
|
||||||
wallet,
|
wallet,
|
||||||
});
|
});
|
||||||
|
console.timeEnd('ReactiveWallet.from');
|
||||||
|
|
||||||
// Start the wallet.
|
// Start the wallet.
|
||||||
// NOTE: We deliberately do not await this as we want it to happen in the background.
|
// NOTE: We deliberately do not await this as we want it to happen in the background.
|
||||||
wallet.start();
|
wallet.start();
|
||||||
} else if (genesisData.type === 'WalletP2PKH') {
|
} else if (genesisData.type === 'WalletP2PKHWatch') {
|
||||||
// Instantiate the wallet.
|
// Instantiate the wallet.
|
||||||
const wallet = await WalletP2PKH.from(
|
const wallet = await WalletP2PKHWatch.from(
|
||||||
{
|
{
|
||||||
blockchain: this.blockchain,
|
blockchain: this.blockchain,
|
||||||
},
|
},
|
||||||
@@ -125,9 +119,11 @@ export class Wallets {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Push it to our list of wallets.
|
// Push it to our list of wallets.
|
||||||
|
console.time('ReactiveWallet.from');
|
||||||
this.wallets[walletId] = await ReactiveWallet.from({
|
this.wallets[walletId] = await ReactiveWallet.from({
|
||||||
wallet,
|
wallet,
|
||||||
});
|
});
|
||||||
|
console.timeEnd('ReactiveWallet.from');
|
||||||
|
|
||||||
// Start the wallet.
|
// Start the wallet.
|
||||||
// NOTE: We deliberately do not await this as we want it to happen in the background.
|
// NOTE: We deliberately do not await this as we want it to happen in the background.
|
||||||
@@ -150,7 +146,7 @@ export class Wallets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createWallet(
|
async createWallet(
|
||||||
walletData: WalletHDGenesisData | WalletP2PKHGenesisData
|
walletData: WalletHDWatchGenesisData | WalletP2PKHWatchGenesisData
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Extract the raw Wallet Data.
|
// Extract the raw Wallet Data.
|
||||||
// NOTE: This is a Vue Quirk. We cannot store reactives as structuredClones cannot be performed on them.
|
// NOTE: This is a Vue Quirk. We cannot store reactives as structuredClones cannot be performed on them.
|
||||||
@@ -166,10 +162,10 @@ export class Wallets {
|
|||||||
cache: walletCache,
|
cache: walletCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (walletDataRaw.type === 'WalletHD') {
|
if (walletDataRaw.type === 'WalletHDWatch') {
|
||||||
wallet = await WalletHD.from(dependencies, walletDataRaw);
|
wallet = await WalletHDWatch.from(dependencies, walletDataRaw);
|
||||||
} else if (walletData.type === 'WalletP2PKH') {
|
} else if (walletData.type === 'WalletP2PKHWatch') {
|
||||||
wallet = await WalletP2PKH.from(dependencies, walletDataRaw);
|
wallet = await WalletP2PKHWatch.from(dependencies, walletDataRaw);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported wallet data: ${walletData}`);
|
throw new Error(`Unsupported wallet data: ${walletData}`);
|
||||||
}
|
}
|
||||||
@@ -177,11 +173,13 @@ export class Wallets {
|
|||||||
// Get the Wallet ID.
|
// Get the Wallet ID.
|
||||||
const walletId = await wallet.getId();
|
const walletId = await wallet.getId();
|
||||||
|
|
||||||
|
if (this.wallets[walletId]) {
|
||||||
|
return walletId;
|
||||||
|
}
|
||||||
|
|
||||||
// Add the wallet to our store.
|
// Add the wallet to our store.
|
||||||
await this.walletsStore?.set(walletId, {
|
await this.walletsStore?.set(walletId, {
|
||||||
...walletData,
|
...walletData,
|
||||||
mnemonic:
|
|
||||||
'mnemonic' in walletData ? walletData.mnemonic.toRaw() : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a persistent store for this wallet's activities.
|
// Add a persistent store for this wallet's activities.
|
||||||
@@ -220,4 +218,5 @@ export class Wallets {
|
|||||||
|
|
||||||
delete this.wallets[walletId];
|
delete this.wallets[walletId];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
118
src/utils/reactive-store.ts
Normal file
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