Initial commit

This commit is contained in:
2026-01-15 13:39:10 +11:00
commit e99af43f06
29 changed files with 5867 additions and 0 deletions

80
.gitignore vendored Normal file
View File

@@ -0,0 +1,80 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Build output
dist/
build/

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>www</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2185
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "www",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@generalprotocols/oracle-client": "^0.0.1",
"@tailwindcss/vite": "^4.1.17",
"@xocash/stack": "file:../stack/packages/stack",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.554.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vite-tsconfig-paths": "^5.1.4",
"vue-tsc": "^3.1.4"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

11
src/App.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>
<style scoped>
/* App-level styles can go here */
</style>

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

29
src/boot/app.ts Normal file
View File

@@ -0,0 +1,29 @@
import { App } from '../services/app'
import type { App as VueApp } from 'vue'
import type { Router } from 'vue-router'
export default async ({
app: vueApp,
router,
}: {
app: VueApp
router: Router
}): Promise<void> => {
console.log('booting app')
try {
// Instantiate new app instance.
const app = await App.create(router)
// Inject the app instance so that all children can access it.
vueApp.provide('app', app)
} catch (error) {
// Log the error to the console.
console.error(error)
// Create a dialog that never resolves so execution does not continue.
await new Promise(() => {
alert(`Failed to initialize app: ${error}`)
})
}
}

View File

@@ -0,0 +1,41 @@
<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>

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

12
src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
import './style.css';
const app = createApp(App);
app.use(router);
app.mount('#app');

742
src/pages/AddressesPage.vue Normal file
View File

@@ -0,0 +1,742 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, shallowRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BlockchainElectrum,
WalletP2PKHWatch,
WalletHDWatch,
HDPublicNode,
PublicKey,
type Address,
} from '@xocash/stack';
import { ReactiveWallet } from '../services/wallet.js';
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
type WalletType = 'p2pkh' | 'hd';
type AddressInfo = {
address: Address;
chainPath: number;
index: number;
cashAddr: string;
derivationPath: string;
};
//-----------------------------------------------------------------------------
// Route & Router
//-----------------------------------------------------------------------------
const route = useRoute();
const router = useRouter();
//-----------------------------------------------------------------------------
// State
//-----------------------------------------------------------------------------
/** The wallet key from the route param. */
const key = computed(() => route.params.key as string);
/** The account path from query params (for master xpubs). */
const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */
const walletType = ref<WalletType | null>(null);
/** Loading state. */
const isLoading = ref(true);
/** Error message if wallet creation fails. */
const error = ref<string | null>(null);
/** The reactive wallet instance. */
const wallet = shallowRef<ReactiveWallet | null>(null);
/** The blockchain adapter (shared). */
let blockchain: BlockchainElectrum | null = null;
/** Derived addresses with metadata. */
const addressList = ref<AddressInfo[]>([]);
/** Filter for chain path (0 = external, 1 = internal, null = all). */
const chainPathFilter = ref<number | null>(null);
/** Search query for filtering addresses. */
const searchQuery = ref('');
//-----------------------------------------------------------------------------
// Wallet Type Detection
//-----------------------------------------------------------------------------
function isXPub(key: string): boolean {
return key.startsWith('xpub') || key.startsWith('tpub');
}
function isPublicKey(key: string): boolean {
if (key.length !== 66) return false;
if (!key.startsWith('02') && !key.startsWith('03')) return false;
return /^[0-9a-fA-F]+$/.test(key);
}
function detectWalletType(key: string): WalletType | null {
if (isXPub(key)) return 'hd';
if (isPublicKey(key)) return 'p2pkh';
return null;
}
//-----------------------------------------------------------------------------
// Wallet Creation
//-----------------------------------------------------------------------------
async function initializeWallet() {
isLoading.value = true;
error.value = null;
try {
const type = detectWalletType(key.value);
if (!type) {
throw new Error(
'Invalid key format. Please provide a valid public key or xpub.'
);
}
walletType.value = type;
blockchain = new BlockchainElectrum({
servers: ['bch.imaginary.cash'],
});
await blockchain.start();
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
if (type === 'hd') {
let hdNode: HDPublicNode;
try {
hdNode = HDPublicNode.fromXPub(key.value);
} catch {
throw new Error('Invalid xpub format.');
}
// Determine the xpub to use and the derivation path for display.
let accountXpub: string;
let displayPath: string;
if (accountPath.value) {
// Master xpub provided - derive the account-level xpub.
try {
const accountNode = hdNode.derivePath(accountPath.value);
accountXpub = accountNode.toXPub();
displayPath = `m/${accountPath.value}`;
} catch (e) {
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else {
// Already an account-level xpub.
accountXpub = key.value;
displayPath = "m/44'/145'/0'";
}
baseWallet = await WalletHDWatch.from(
{ blockchain },
{
xpub: accountXpub,
derivationPath: displayPath,
}
);
} else {
try {
PublicKey.fromHex(key.value);
} catch {
throw new Error('Invalid public key format.');
}
baseWallet = await WalletP2PKHWatch.from(
{ blockchain },
{ publicKey: key.value }
);
}
wallet.value = new ReactiveWallet(baseWallet);
await wallet.value.start();
// Build the address list.
buildAddressList();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err);
} finally {
isLoading.value = false;
}
}
/**
* Builds the address list from the wallet.
*/
function buildAddressList() {
if (!wallet.value) return;
const addresses: AddressInfo[] = [];
const baseWallet = wallet.value.wallet;
if (walletType.value === 'hd' && 'wallets' in baseWallet) {
// HD Wallet - iterate through child wallets.
const hdWallet = baseWallet as WalletHDWatch;
const basePath = hdWallet.genesisData.derivationPath;
for (const [chainPath, childWallets] of Object.entries(hdWallet.wallets)) {
childWallets.forEach((childWallet, index) => {
const address = childWallet.publicKey.deriveAddress();
const chainPathNum = Number(chainPath);
addresses.push({
address,
chainPath: chainPathNum,
index,
cashAddr: address.toCashAddr(),
derivationPath: `${basePath}/${chainPathNum}/${index}`,
});
});
}
} else {
// P2PKH Wallet - single address.
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
const address = p2pkhWallet.publicKey.deriveAddress();
addresses.push({
address,
chainPath: 0,
index: 0,
cashAddr: address.toCashAddr(),
derivationPath: '—',
});
}
addressList.value = addresses;
}
async function cleanup() {
if (wallet.value) {
await wallet.value.stop();
await wallet.value.destroy();
wallet.value = null;
}
if (blockchain) {
await blockchain.stop();
blockchain = null;
}
}
//-----------------------------------------------------------------------------
// Lifecycle
//-----------------------------------------------------------------------------
onMounted(() => {
initializeWallet();
});
onUnmounted(() => {
cleanup();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/** Filtered addresses based on chain path and search query. */
const filteredAddresses = computed(() => {
let result = addressList.value;
// Filter by chain path.
if (chainPathFilter.value !== null) {
result = result.filter((a) => a.chainPath === chainPathFilter.value);
}
// Filter by search query.
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter((a) => a.cashAddr.toLowerCase().includes(query));
}
return result;
});
/** Count of external addresses. */
const externalCount = computed(() =>
addressList.value.filter((a) => a.chainPath === 0).length
);
/** Count of internal (change) addresses. */
const internalCount = computed(() =>
addressList.value.filter((a) => a.chainPath === 1).length
);
//-----------------------------------------------------------------------------
// Actions
//-----------------------------------------------------------------------------
function goBack() {
router.push({
name: 'wallet',
params: { key: key.value },
query: accountPath.value ? { accountPath: accountPath.value } : {},
});
}
function copyAddress(cashAddr: string) {
navigator.clipboard.writeText(cashAddr);
}
function getChainLabel(chainPath: number): string {
return chainPath === 0 ? 'External' : 'Change';
}
function setFilter(filter: number | null) {
chainPathFilter.value = filter;
}
</script>
<template>
<div class="addresses-page">
<!-- Header -->
<header class="header">
<button class="back-button" @click="goBack">
Back to Wallet
</button>
<div class="wallet-type-badge" v-if="walletType">
{{ walletType === 'hd' ? 'HD Wallet' : 'P2PKH Wallet' }}
</div>
</header>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>Loading addresses...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<h2>Error</h2>
<p>{{ error }}</p>
<button class="retry-button" @click="goBack">
Go Back
</button>
</div>
<!-- Addresses Content -->
<div v-else class="addresses-content">
<h1 class="page-title">Addresses</h1>
<!-- Stats -->
<div class="stats-row" v-if="walletType === 'hd'">
<div class="stat-card">
<div class="stat-value">{{ addressList.length }}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ externalCount }}</div>
<div class="stat-label">External</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ internalCount }}</div>
<div class="stat-label">Change</div>
</div>
</div>
<!-- Filters -->
<div class="filters" v-if="walletType === 'hd'">
<div class="filter-buttons">
<button
:class="['filter-btn', { active: chainPathFilter === null }]"
@click="setFilter(null)"
>
All
</button>
<button
:class="['filter-btn', { active: chainPathFilter === 0 }]"
@click="setFilter(0)"
>
External
</button>
<button
:class="['filter-btn', { active: chainPathFilter === 1 }]"
@click="setFilter(1)"
>
Change
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search addresses..."
class="search-input"
/>
</div>
<!-- Address List -->
<div class="address-list">
<div
v-for="(addr, idx) in filteredAddresses"
:key="addr.cashAddr"
class="address-item"
>
<div class="address-info">
<div class="address-index">
<span class="chain-badge" :class="addr.chainPath === 0 ? 'external' : 'internal'">
{{ getChainLabel(addr.chainPath) }}
</span>
<span class="index-number">#{{ addr.index }}</span>
<span class="derivation-path">{{ addr.derivationPath }}</span>
</div>
<code class="address-value">{{ addr.cashAddr }}</code>
</div>
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
📋
</button>
</div>
<div v-if="filteredAddresses.length === 0" class="empty-state">
<p v-if="searchQuery">No addresses match your search.</p>
<p v-else>No addresses found.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.addresses-page {
min-height: 100vh;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
color: #fff;
padding: 1.5rem;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.back-button {
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;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #4a5568;
}
.wallet-type-badge {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid #2d3748;
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: #a0aec0;
font-size: 1.125rem;
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.error-state p {
color: #fc8181;
margin-bottom: 1.5rem;
}
.retry-button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
border: none;
border-radius: 8px;
background: #2d3748;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-button:hover {
background: #4a5568;
}
/* Content */
.addresses-content {
max-width: 900px;
margin: 0 auto;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* Filters */
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-buttons {
display: flex;
gap: 0.5rem;
}
.filter-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: #a0aec0;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.filter-btn.active {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
border-color: rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 1px solid #2d3748;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.search-input::placeholder {
color: #4a5568;
}
.search-input:focus {
outline: none;
border-color: #00d4ff;
}
/* Address List */
.address-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.address-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1rem 1.25rem;
transition: all 0.2s ease;
}
.address-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #4a5568;
}
.address-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.address-index {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chain-badge {
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 4px;
}
.chain-badge.external {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
}
.chain-badge.internal {
background: rgba(237, 137, 54, 0.2);
color: #ed8936;
}
.index-number {
font-size: 0.75rem;
color: #718096;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.derivation-path {
font-size: 0.7rem;
color: #4a5568;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
margin-left: 0.5rem;
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.address-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: #a0aec0;
word-break: break-all;
}
.copy-button {
padding: 0.5rem;
font-size: 1rem;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.copy-button:hover {
background: rgba(255, 255, 255, 0.1);
transform: scale(1.05);
}
.empty-state {
text-align: center;
padding: 3rem;
color: #718096;
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
border: 1px dashed #2d3748;
}
</style>

815
src/pages/HomePage.vue Normal file
View File

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

717
src/pages/WalletPage.vue Normal file
View File

@@ -0,0 +1,717 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, shallowRef, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
BlockchainElectrum,
WalletP2PKHWatch,
WalletHDWatch,
HDPublicNode,
PublicKey,
BlockHeader,
} from '@xocash/stack';
import { ReactiveWallet } from '../services/wallet.js';
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
type WalletType = 'p2pkh' | 'hd';
//-----------------------------------------------------------------------------
// Route & Router
//-----------------------------------------------------------------------------
const route = useRoute();
const router = useRouter();
//-----------------------------------------------------------------------------
// State
//-----------------------------------------------------------------------------
/** The wallet key from the route param. */
const key = computed(() => route.params.key as string);
/** The account path from query params (for master xpubs). */
const accountPath = computed(() => route.query.accountPath as string | undefined);
/** The detected wallet type. */
const walletType = ref<WalletType | null>(null);
/** Loading state. */
const isLoading = ref(true);
/** Error message if wallet creation fails. */
const error = ref<string | null>(null);
/** The reactive wallet instance. */
const wallet = shallowRef<ReactiveWallet | null>(null);
/** The blockchain adapter (shared). */
let blockchain: BlockchainElectrum | null = null;
/** The block headers. */
const blockHeaders = ref<{ [height: number]: BlockHeader }>({});
//-----------------------------------------------------------------------------
// Wallet Type Detection
//-----------------------------------------------------------------------------
/**
* Determines if a key is an xpub (extended public key).
*
* @param key - The key to check.
* @returns True if the key is an xpub.
*/
function isXPub(key: string): boolean {
return key.startsWith('xpub') || key.startsWith('tpub');
}
/**
* Determines if a key is a valid compressed public key.
*
* @param key - The key to check.
* @returns True if the key is a valid public key.
*/
function isPublicKey(key: string): boolean {
// Compressed public keys are 33 bytes (66 hex characters).
// They start with 02 or 03.
if (key.length !== 66) return false;
if (!key.startsWith('02') && !key.startsWith('03')) return false;
// Check if it's valid hex.
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
//-----------------------------------------------------------------------------
/**
* Creates and initializes the wallet based on the key type.
*/
async function initializeWallet() {
isLoading.value = true;
error.value = null;
try {
// Detect the wallet type.
const type = detectWalletType(key.value);
if (!type) {
throw new Error(
'Invalid key format. Please provide a valid public key (02... or 03...) or xpub.'
);
}
walletType.value = type;
// Create the blockchain adapter.
blockchain = new BlockchainElectrum({
// TODO: Make this configurable
servers: ['bch.imaginary.cash'],
});
// Wait for the blockchain to connect.
await blockchain.start();
// Create the appropriate wallet type.
let baseWallet: WalletP2PKHWatch | WalletHDWatch;
if (type === 'hd') {
// Validate the xpub.
let hdNode: HDPublicNode;
try {
hdNode = HDPublicNode.fromXPub(key.value);
} catch {
throw new Error('Invalid xpub format. Please check and try again.');
}
// Determine the xpub to use and the derivation path for display.
let accountXpub: string;
let displayPath: string;
if (accountPath.value) {
// Master xpub provided - derive the account-level xpub.
try {
const accountNode = hdNode.derivePath(accountPath.value);
accountXpub = accountNode.toXPub();
displayPath = `m/${accountPath.value}`;
} catch (e) {
throw new Error(`Failed to derive account path: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else {
// Already an account-level xpub.
accountXpub = key.value;
displayPath = "m/44'/145'/0'";
}
// Create the HD watch wallet with the account-level xpub.
baseWallet = await WalletHDWatch.from(
{ blockchain },
{
xpub: accountXpub,
derivationPath: displayPath,
}
);
} else {
// Validate the public key.
try {
PublicKey.fromHex(key.value);
} catch {
throw new Error('Invalid public key format. Please check and try again.');
}
// Create the P2PKH watch wallet.
baseWallet = await WalletP2PKHWatch.from(
{ blockchain },
{ publicKey: key.value }
);
}
// Wrap in reactive wallet.
wallet.value = new ReactiveWallet(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) {
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
console.error('Wallet initialization error:', err);
} finally {
isLoading.value = false;
}
}
/**
* 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
//-----------------------------------------------------------------------------
onMounted(() => {
initializeWallet();
});
onUnmounted(() => {
cleanup();
});
//-----------------------------------------------------------------------------
// Computed Values
//-----------------------------------------------------------------------------
/** Formatted balance in BCH. */
const balanceBCH = computed(() => {
if (!wallet.value) return '0.00000000';
const sats = wallet.value.balanceSats.value;
return (Number(sats) / 100_000_000).toFixed(8);
});
/** Transaction count. */
const transactionCount = computed(() => {
if (!wallet.value) return 0;
return wallet.value.transactions.value.size;
});
/** Sorted transactions (newest first). */
const sortedTransactions = computed(() => {
if (!wallet.value) return [];
return wallet.value.transactions.value
.toArray()
.sort((a, b) => {
// Unconfirmed transactions first (height <= 0).
if (a.height <= 0 && b.height > 0) return -1;
if (b.height <= 0 && a.height > 0) return 1;
// Then by height descending.
return b.height - a.height;
});
});
/**
* Formats a satoshi amount with sign.
*/
function formatBalanceChange(tx: any): string {
if (!wallet.value) return '0';
const change = wallet.value.calculateBalanceChange(tx);
const bch = Number(change) / 100_000_000;
const sign = change >= 0n ? '+' : '';
return `${sign}${bch.toFixed(8)} BCH`;
}
/**
* Gets the CSS class for a balance change.
*/
function getBalanceChangeClass(tx: any): string {
if (!wallet.value) return '';
const change = wallet.value.calculateBalanceChange(tx);
return change >= 0n ? 'positive' : 'negative';
}
/**
* Formats a block height for display.
*/
function formatHeight(height: number): string {
if (height <= 0) return 'Unconfirmed';
return height.toLocaleString();
}
/**
* Truncates a transaction hash for display.
*/
function truncateHash(hash: string): string {
return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
}
/**
* Navigate back to home.
*/
function goBack() {
router.push({ name: 'home' });
}
/**
* Navigate to addresses page.
*/
function viewAddresses() {
router.push({
name: 'addresses',
params: { key: key.value },
query: accountPath.value ? { accountPath: accountPath.value } : {},
});
}
</script>
<template>
<div class="wallet-page">
<!-- Header -->
<header class="header">
<button class="back-button" @click="goBack">
Back
</button>
<div class="wallet-type-badge" v-if="walletType">
{{ walletType === 'hd' ? 'HD Wallet' : 'P2PKH Wallet' }}
</div>
</header>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>Loading wallet...</p>
<p class="loading-subtext">
{{ walletType === 'hd' ? 'Scanning addresses...' : 'Fetching transactions...' }}
</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<h2>Error</h2>
<p>{{ error }}</p>
<button class="retry-button" @click="goBack">
Go Back
</button>
</div>
<!-- Wallet Content -->
<div v-else-if="wallet" class="wallet-content">
<!-- Balance Card -->
<div class="balance-card">
<div class="balance-label">Balance</div>
<div class="balance-value">{{ balanceBCH }} BCH</div>
<div class="balance-sats">{{ wallet.balanceSats.value.toLocaleString() }} sats</div>
</div>
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ transactionCount }}</div>
<div class="stat-label">Transactions</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ wallet.unspents.value.size }}</div>
<div class="stat-label">UTXOs</div>
</div>
<div class="stat-card clickable" @click="viewAddresses">
<div class="stat-value">{{ wallet.addresses.value.size }}</div>
<div class="stat-label">Addresses </div>
</div>
</div>
<!-- Transactions List -->
<div class="transactions-section">
<h2 class="section-title">Transaction History</h2>
<div v-if="sortedTransactions.length === 0" class="empty-state">
<p>No transactions found</p>
</div>
<div v-else class="transactions-list">
<div
v-for="tx in sortedTransactions"
:key="tx.hash.toHex()"
class="transaction-item"
>
<div class="tx-main">
<div class="tx-hash">
<code>{{ truncateHash(tx.hash.toHex()) }}</code>
</div>
<div
class="tx-amount"
:class="getBalanceChangeClass(tx)"
>
{{ formatBalanceChange(tx) }}
</div>
</div>
<div class="tx-meta">
<span class="tx-height" :class="{ unconfirmed: tx.height <= 0 }">
{{ formatHeight(tx.height) }}
</span>
<!-- block timestamp -->
<span class="tx-time">
{{ blockHeaders[tx.height]?.getTimestampDate().toLocaleString('en-AU', { dateStyle: 'short', timeStyle: 'short' }) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.wallet-page {
min-height: 100vh;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
color: #fff;
padding: 1.5rem;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.back-button {
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;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #4a5568;
}
.wallet-type-badge {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid #2d3748;
border-top-color: #00d4ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: #a0aec0;
font-size: 1.125rem;
}
.loading-subtext {
font-size: 0.875rem !important;
color: #718096 !important;
margin-top: 0.5rem;
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.error-state p {
color: #fc8181;
margin-bottom: 1.5rem;
}
.retry-button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
border: none;
border-radius: 8px;
background: #2d3748;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-button:hover {
background: #4a5568;
}
/* Wallet Content */
.wallet-content {
max-width: 800px;
margin: 0 auto;
}
/* Balance Card */
.balance-card {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.1), rgba(123, 44, 191, 0.1));
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 16px;
padding: 2rem;
text-align: center;
margin-bottom: 1.5rem;
}
.balance-label {
font-size: 0.875rem;
font-weight: 500;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.balance-value {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.balance-sats {
font-size: 0.875rem;
color: #718096;
margin-top: 0.25rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1.25rem;
text-align: center;
transition: all 0.2s ease;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
background: rgba(255, 255, 255, 0.06);
border-color: #00d4ff;
transform: translateY(-2px);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* Transactions Section */
.transactions-section {
margin-top: 2rem;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: #e2e8f0;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #718096;
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
border: 1px dashed #2d3748;
}
.transactions-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.transaction-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2d3748;
border-radius: 12px;
padding: 1rem 1.25rem;
transition: all 0.2s ease;
}
.transaction-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #4a5568;
}
.tx-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.tx-hash code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.875rem;
color: #a0aec0;
}
.tx-amount {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.875rem;
font-weight: 600;
}
.tx-amount.positive {
color: #48bb78;
}
.tx-amount.negative {
color: #fc8181;
}
.tx-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
}
.tx-height {
color: #718096;
}
.tx-height.unconfirmed {
color: #ed8936;
}
</style>

39
src/router/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomePage from '../pages/HomePage.vue';
import WalletPage from '../pages/WalletPage.vue';
import AddressesPage from '../pages/AddressesPage.vue';
/**
* Application routes.
*/
const routes = [
{
path: '/',
name: 'home',
component: HomePage,
},
{
path: '/wallet/:key',
name: 'wallet',
component: WalletPage,
props: true,
},
{
path: '/wallet/:key/addresses',
name: 'addresses',
component: AddressesPage,
props: true,
},
];
/**
* Vue Router instance.
*/
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;

127
src/services/app.ts Normal file
View File

@@ -0,0 +1,127 @@
import { Rates } from './rates.js';
import { Settings } from './settings.js';
import { Wallets } from './wallets.js';
import { inject, ref } from 'vue';
import { type Router } from 'vue-router';
// XO Stack
import {
type BaseStorage,
BlockchainElectrum,
StorageLocalStorage,
} from '@xocash/stack';
import { ReactiveBlockchain } from './blockchain.js';
export type AppDependencies = {
router: Router;
settings: Settings;
blockchain: ReactiveBlockchain;
rates: Rates;
walletStorage: BaseStorage;
wallets: Wallets;
cache: BaseStorage;
};
export class App {
public isDebugMode = ref(false);
//---------------------------------------------------------------------------
// Initialization
//---------------------------------------------------------------------------
public router: Router;
public settings: Settings;
public blockchain: ReactiveBlockchain;
public rates: Rates;
public walletStorage: BaseStorage;
public wallets: Wallets;
public cache: BaseStorage;
constructor(
dependencies: AppDependencies
) {
this.router = dependencies.router;
this.settings = dependencies.settings;
this.blockchain = dependencies.blockchain;
this.rates = dependencies.rates;
this.walletStorage = dependencies.walletStorage;
this.wallets = dependencies.wallets;
this.cache = dependencies.cache;
}
static async create(router: Router) {
// Setup app storage adapter.
const appStorage = await StorageLocalStorage.createOrOpen('xoApp_V0.0.1');
const settingsStore = await appStorage.createOrGetStore('settings');
// Create settings class.
const settings = await Settings.from(settingsStore);
// Setup rates.
const rates = await Rates.from();
rates.start();
// Setup wallets storage adapter.
const walletStorage = await StorageLocalStorage.createOrOpen(
'xoWallets_V0.0.5'
);
// Setup cache storage adapter.
const cacheStorage = await StorageLocalStorage.createOrOpen(
'xoWalletCache_V0.0.5'
);
const blockchainCache = await cacheStorage.createOrGetStore(
'electrumBlockchain',
{
syncInMemory: true,
}
);
// Setup blockchain (and use our cache).
const blockchain = await ReactiveBlockchain.from(
await BlockchainElectrum.from({
store: blockchainCache,
})
);
// Setup wallet manager.
const walletManager = new Wallets(
blockchain.blockchain,
walletStorage,
cacheStorage,
);
await walletManager.start();
// Create new instance of app.
return new this(
{
router,
blockchain,
cache: cacheStorage,
rates,
settings,
walletStorage,
wallets: walletManager,
}
);
}
async initializeBrowser(): Promise<void> {
// Make sure that this browser supports Mutex Locks (navigator.lock).
// TODO: Will we even need this for CashStamps?
if (typeof navigator.locks === 'undefined') {
throw new Error(
'Your browser does not support Mutex Locks. Please update your browser or switch to a browser that supports this feature.'
);
}
}
}
export function useApp() {
const app = inject<App>('app');
if (!app) throw new Error('App not properly initialized');
return app;
}

View File

@@ -0,0 +1,60 @@
import { Mixin } from '@/utils/mixin.js';
import { ref } from 'vue';
import {
type AddressStatusPayload,
type BlockHeightPayload,
BaseBlockchain,
} from '@xocash/stack';
export class ReactiveBlockchain extends Mixin([
BaseBlockchain<AddressStatusPayload, BlockHeightPayload>,
]) {
static async from(
blockchain: BaseBlockchain<AddressStatusPayload, BlockHeightPayload>
) {
return new ReactiveBlockchain(blockchain);
}
// Dependencies.
public readonly blockchain: BaseBlockchain<
AddressStatusPayload,
BlockHeightPayload
>;
// Reactives.
isConnected = ref(false);
blockHeight = ref<number | undefined>(undefined);
constructor(
blockchain: BaseBlockchain<AddressStatusPayload, BlockHeightPayload>
) {
super(blockchain);
// Bind our events.
blockchain.on(
'isConnectedUpdated',
(isConnected) => {
this.isConnected.value = isConnected;
},
500
);
blockchain.on(
'blockHeightUpdated',
(blockHeight) => {
this.blockHeight.value = blockHeight;
},
500
);
// Assign our class members.
this.blockchain = blockchain;
// Set the initial block height.
this.blockchain.fetchChainTip().then((chainTip) => {
this.blockHeight.value = chainTip.height;
});
}
}

109
src/services/rates.ts Normal file
View File

@@ -0,0 +1,109 @@
import { ref, triggerRef } from 'vue';
import {
BaseRates,
RatesComposite,
RatesOracle,
ratesMedianPolicy,
} from '@xocash/stack';
import { Mixin } from '@/utils/mixin';
/**
*/
export class Rates extends Mixin([BaseRates]){
// Our rates adapter.
private readonly adapter: BaseRates;
// Our individual rates as a reactives.
// NOTE: Because our adapter sends us updates one by one, we must use triggerRef to update this.
private rates = ref<{ [key: string]: number | undefined }>({});
static async from(refreshMilliseconds = 60_000) {
const oracle = await RatesOracle.from();
const compositeAdapter = new RatesComposite(
[oracle],
ratesMedianPolicy,
refreshMilliseconds
);
return new Rates(compositeAdapter);
}
constructor(ratesAdapter: BaseRates) {
super(ratesAdapter);
this.adapter = ratesAdapter;
this.adapter.on(
'rateUpdated',
({ numeratorUnitCode, denominatorUnitCode, price }) => {
if (denominatorUnitCode !== 'BCH') {
return;
}
this.rates.value[numeratorUnitCode] = price;
triggerRef(this.rates);
}
);
}
toBCH(amount: number, fromCurrency: string): number {
const rate = this.rates.value[fromCurrency];
if (rate === undefined) {
return 0;
// throw new Error(`Currency ${fromCurrency} not supported.`);
}
return Rates.roundToDigits(amount / rate, 8);
}
toSats(amount: number, fromCurrency: string): number {
return this.toBCH(amount * 100_000_000, fromCurrency);
}
fromSats(satoshis: number | bigint, targetCurrency: string): number {
if (typeof satoshis === 'bigint') {
satoshis = Number(satoshis);
}
return this.fromBCH(satoshis / 100_000_000, targetCurrency);
}
fromBCH(amount: number, targetCurrency: string): number {
const rate = this.rates.value[targetCurrency];
if (rate === undefined) {
return 0;
// throw new Error(`Currency ${targetCurrency} not supported.`);
}
return Rates.roundToDigits(amount * rate, 2);
}
formatSats(sats: number | bigint, targetCurrency: string) {
const amount = this.fromSats(sats, targetCurrency);
return this.formatCurrency(amount, targetCurrency);
}
static roundToDigits(numberToRound: number, digits: number): number {
// Set the options of the Number Format object.
const options: Intl.NumberFormatOptions = {
style: 'decimal',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
};
// Create an instance of number format using above options.
// NOTE: We force the locale to en-GB so that the number is formatted correctly (e.g. with a decimal, not a comma).
const numberFormat = new Intl.NumberFormat('en-GB', options);
// Format the number.
const formattedAmount = numberFormat.format(numberToRound);
// Return the formatted number.
return Number(formattedAmount);
}
}

309
src/services/wallet.ts Normal file
View File

@@ -0,0 +1,309 @@
import { computed, ref, shallowRef, triggerRef } from 'vue';
import {
type BlockchainUTXO,
type BlockchainUTXOs,
type MapDiff,
type WalletAddresses,
type WalletTransactions,
type WalletTransaction,
BaseWallet,
BlockHeader,
Bytes,
ExtMap,
calculateBalanceSats,
calculateBalanceTokens,
} from '@xocash/stack';
import { Mixin } from '../utils/mixin.js';
import { binToHex } from '@bitauth/libauth';
type ReactiveWalletDependencies<T extends BaseWallet> = {
wallet: T;
};
//-----------------------------------------------------------------------------
// ReactiveWallet - Generic Reactive Wallet Wrapper
//-----------------------------------------------------------------------------
/**
* A reactive Vue wrapper for any wallet that extends BaseWallet.
*
* @remarks
*
* This class wraps any wallet from the stack package and adds reactive Vue
* bindings for use in the UI. It uses the Mixin pattern to delegate all
* wallet functionality to the underlying wallet instance.
*
* @example
* ```ts
* // With a watch-only HD wallet
* const hdWallet = await WalletHDWatch.from(deps, { xpub: '...' });
* const reactiveHD = new ReactiveWallet(hdWallet);
* await reactiveHD.start();
*
* // With a watch-only P2PKH wallet
* const p2pkhWallet = await WalletP2PKHWatch.from(deps, { publicKey: '...' });
* const reactiveP2PKH = new ReactiveWallet(p2pkhWallet);
* await reactiveP2PKH.start();
*
* // With a regular wallet (signing capable)
* const wallet = await WalletHD.from(deps, { mnemonic: '...' });
* const reactiveWallet = new ReactiveWallet(wallet);
* await reactiveWallet.start();
* ```
*/
export class ReactiveWallet<T extends BaseWallet = BaseWallet> extends Mixin([
BaseWallet,
]) {
public static async from<T extends BaseWallet>(deps: ReactiveWalletDependencies<T>): Promise<ReactiveWallet<T>> {
const reactiveWallet = new ReactiveWallet(deps);
await reactiveWallet.start();
return reactiveWallet;
}
//-----------------------------------
// Reactive Wallet State
//-----------------------------------
/** All addresses belonging to this wallet. */
addresses = shallowRef<WalletAddresses>(new ExtMap());
/** All transactions involving this wallet. */
transactions = shallowRef<WalletTransactions>(new ExtMap());
/** All unspent transaction outputs belonging to this wallet. */
unspents = shallowRef<BlockchainUTXOs>(new ExtMap());
/** Block headers indexed by height. */
blockHeaders = ref<{ [height: number]: BlockHeader }>({});
/** Whether the wallet has completed its initial load. */
isReady = ref(false);
//-----------------------------------
// Derived State
//-----------------------------------
/** The current balance in satoshis. */
balanceSats = computed(() => {
return calculateBalanceSats(
this.unspents.value.map((blockchainUTXO) => blockchainUTXO.utxo).toArray()
);
});
/** The current token balances by category. */
balanceTokens = computed(() => {
return calculateBalanceTokens(
this.unspents.value.map((blockchainUTXO) => blockchainUTXO.utxo).toArray()
);
});
/** The underlying wallet instance. */
readonly wallet: T;
/**
* Creates a new ReactiveWallet.
*
* @param deps - The dependencies of the reactive wallet.
*/
constructor(deps: ReactiveWalletDependencies<T>) {
super(deps.wallet);
this.wallet = deps.wallet;
// Listen for state updates from the underlying wallet.
deps.wallet.on(
'stateUpdated',
async () => {
await Promise.all([
this.updateAddresses(),
this.updateUnspents(),
this.updateTransactions(),
]);
// Mark the wallet as ready after the first update.
this.isReady.value = true;
},
500
);
}
/**
* Updates the reactive addresses from the underlying wallet.
*/
async updateAddresses(): Promise<void> {
const addresses = await this.wallet.getAddresses();
this.addresses.value = addresses;
triggerRef(this.addresses);
}
/**
* Updates the reactive transactions from the underlying wallet.
*
* @returns The diff of added, removed, and updated transactions.
*/
async updateTransactions(): Promise<MapDiff<WalletTransaction>> {
const transactions = await this.wallet.getTransactions();
// For each transaction, fetch the block header in the background.
transactions.forEach((tx) => {
if (tx.height <= 0) return;
this.wallet.blockchain.fetchBlockHeader(tx.height).then((blockHeader) => {
this.blockHeaders.value[tx.height] = blockHeader;
});
});
// Create a diff of added, removed and updated transactions.
const { added, removed, updated } = this.transactions.value.diff(
transactions,
(a, b) => a.height === b.height
);
// Update existing transactions.
updated.forEach((tx) => {
const existingTx = this.transactions.value.get(tx.hash.toHex());
if (existingTx) {
Object.assign(existingTx, tx);
}
});
// Add new transactions.
added.forEach((tx) => this.transactions.value.set(tx.hash.toHex(), tx));
// Remove old transactions.
removed.forEach((tx) => this.transactions.value.delete(tx.hash.toHex()));
// Trigger reactivity if there were changes.
if (added.size || updated.size || removed.size) {
triggerRef(this.transactions);
}
return { added, removed, updated };
}
/**
* Updates the reactive unspents from the underlying wallet.
*
* @returns The diff of added, removed, and updated unspents.
*/
async updateUnspents(): Promise<MapDiff<BlockchainUTXO>> {
const unspents = await this.wallet.getUnspents();
// Create a diff of added, removed and updated unspents.
const { added, removed, updated } = this.unspents.value.diff(
unspents,
(a, b) => a.utxo.outpoint.toString() === b.utxo.outpoint.toString()
);
// Add new unspents.
added.forEach((unspent) =>
this.unspents.value.set(unspent.utxo.outpoint.toString(), unspent)
);
// Update existing unspents.
updated.forEach((unspent) => {
const existingUnspent = this.unspents.value.get(
unspent.utxo.outpoint.toString()
);
if (existingUnspent) {
Object.assign(existingUnspent, unspent);
}
});
// Remove old unspents.
removed.forEach((unspent) =>
this.unspents.value.delete(unspent.utxo.outpoint.toString())
);
// Trigger reactivity if there were changes.
if (added.size || updated.size || removed.size) {
triggerRef(this.unspents);
}
return { added, removed, updated };
}
/**
* Calculates the balance change for a given transaction.
*
* @param tx - The transaction to calculate the balance change for.
* @returns The net balance change in satoshis (positive = incoming, negative = outgoing).
*/
calculateBalanceChange(tx: WalletTransaction): bigint {
let incoming = 0n;
let outgoing = 0n;
// Calculate incoming funds from outputs.
tx.transaction.getOutputs().forEach((output) => {
const outputAddress = binToHex(output.lockingBytecode);
if (this.addresses.value.get(outputAddress)) {
incoming += BigInt(output.valueSatoshis);
}
});
// Calculate outgoing funds from inputs.
tx.sourceOutputs.forEach((sourceOutput) => {
const lockscriptHex = binToHex(sourceOutput.lockingBytecode);
if (this.addresses.value.get(lockscriptHex)) {
outgoing += sourceOutput.valueSatoshis;
}
});
return incoming - outgoing;
}
/**
* Calculates the token balance change for a given transaction.
*
* @param tx - The transaction to calculate the token balance change for.
* @returns An object with category IDs as keys and balance changes as values.
*/
calculateTokenBalanceChange(tx: WalletTransaction): {
[categoryId: string]: bigint;
} {
const categories: {
[categoryId: string]: { incoming: bigint; outgoing: bigint };
} = {};
// Calculate incoming tokens from outputs.
tx.transaction.getOutputs().forEach((output) => {
if (output.token) {
const categoryId = Bytes.from(output.token.category).toHex();
if (!categories[categoryId]) {
categories[categoryId] = { incoming: 0n, outgoing: 0n };
}
const outputAddress = binToHex(output.lockingBytecode);
if (this.addresses.value.get(outputAddress)) {
categories[categoryId].incoming += BigInt(output.token.amount);
}
}
});
// Calculate outgoing tokens from inputs.
tx.sourceOutputs.forEach((sourceOutput) => {
if (sourceOutput.token) {
const categoryId = Bytes.from(sourceOutput.token.category).toHex();
if (!categories[categoryId]) {
categories[categoryId] = { incoming: 0n, outgoing: 0n };
}
const lockscriptHex = binToHex(sourceOutput.lockingBytecode);
if (this.addresses.value.get(lockscriptHex)) {
categories[categoryId].outgoing += BigInt(sourceOutput.token.amount);
}
}
});
return Object.entries(categories).reduce(
(acc, [categoryId, amounts]) => {
acc[categoryId] = amounts.incoming - amounts.outgoing;
return acc;
},
{} as { [categoryId: string]: bigint }
);
}
}

223
src/services/wallets.ts Normal file
View File

@@ -0,0 +1,223 @@
import { shallowReactive, toRaw } from 'vue';
import {
type BaseStorage,
type WalletHDEntropy,
type WalletHDDerivationData,
type WalletHDGenesisData,
type WalletBlockchain,
type WalletP2PKHGenesisData,
type WalletName,
StoreInMemory,
BaseStore,
WalletHD,
WalletP2PKH,
BaseWallet,
Mnemonic,
type MnemonicRaw,
} from '@xocash/stack';
import { ReactiveWallet } from 'src/services/wallet.js';
// Remove our definition for using Mnemonic in the WalletHDGenesisData type, we store them as MnemonicRaw so we can instantiate them.
export type AppWalletHDGenesisData = // Remove mnemonic option
((
| Exclude<WalletHDEntropy, { mnemonic: Mnemonic }>
// Add mnemonic option with MnemonicRaw type
| { mnemonic: MnemonicRaw }
) &
WalletHDDerivationData) & {
type: 'WalletHD';
name: string;
};
export type AppWalletP2PKHGenesisData = WalletP2PKHGenesisData & {
type: 'WalletP2PKH';
name: string;
};
export type AppWalletData = AppWalletP2PKHGenesisData | AppWalletHDGenesisData;
export type AppWalletStore = { [uid: string]: AppWalletData };
export type WalletsStore = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export type WalletsSupported = WalletP2PKHGenesisData | AppWalletHDGenesisData;
export class Wallets {
public walletsStore?: BaseStore;
public wallets = shallowReactive<{
[walletId: string]: ReactiveWallet;
}>({});
constructor(
public blockchain: WalletBlockchain,
public storage: BaseStorage,
public cache: BaseStorage,
) {}
async start(): Promise<void> {
// Get or create a store for WalletGenesis data.
this.walletsStore = await this.storage.createOrGetStore('wallets', {
syncInMemory: true,
});
// Get all child wallets.
const wallets = await this.walletsStore.all<WalletsSupported>();
// Initialize each wallet.
const walletInitPromises = Object.entries(wallets).map(
async ([walletId, genesisData]) => {
// Get the cache for this wallet.
const cacheStore = await this.cache.createOrGetStore(walletId, {
syncInMemory: true,
});
// If this is a WalletHD type...
if (genesisData.type === 'WalletHD') {
// Convert the mnemonic from Raw to Instance if we are using a mnemonic.
const walletData =
'mnemonic' in genesisData
? {
...genesisData,
mnemonic: Mnemonic.fromRaw(genesisData.mnemonic),
}
: genesisData;
// Instantiate the wallet.
const wallet = await WalletHD.from(
{
blockchain: this.blockchain,
cache: cacheStore,
},
walletData
);
// Add a persistent store for our activities.
await wallet.activities.store.addStore(
await this.storage.createStore(
`activities_${await wallet.getId()}`,
{}
)
);
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
} else if (genesisData.type === 'WalletP2PKH') {
// Instantiate the wallet.
const wallet = await WalletP2PKH.from(
{
blockchain: this.blockchain,
},
genesisData
);
// Add a persistent store for our activities.
await wallet.activities.store.addStore(
await this.storage.createStore(
`activities_${await wallet.getId()}`,
{}
)
);
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
}
// Otherwise, this is an unsupported wallet type.
else {
console.warn(
`${walletId} has an unsupported wallet type`,
genesisData
);
}
}
);
console.time('walletInit');
// Wait for all wallets to initialize.
await Promise.all(walletInitPromises);
console.timeEnd('walletInit');
}
async createWallet(
walletData: WalletHDGenesisData | WalletP2PKHGenesisData
): Promise<string> {
// Extract the raw Wallet Data.
// NOTE: This is a Vue Quirk. We cannot store reactives as structuredClones cannot be performed on them.
// Thus, we must extract the raw value.
const walletDataRaw = toRaw(walletData);
const walletCache = StoreInMemory.from();
let wallet: BaseWallet;
const dependencies = {
blockchain: this.blockchain,
cache: walletCache,
};
if (walletDataRaw.type === 'WalletHD') {
wallet = await WalletHD.from(dependencies, walletDataRaw);
} else if (walletData.type === 'WalletP2PKH') {
wallet = await WalletP2PKH.from(dependencies, walletDataRaw);
} else {
throw new Error(`Unsupported wallet data: ${walletData}`);
}
// Get the Wallet ID.
const walletId = await wallet.getId();
// Add the wallet to our store.
await this.walletsStore?.set(walletId, {
...walletData,
mnemonic:
'mnemonic' in walletData ? walletData.mnemonic.toRaw() : undefined,
});
// Add a persistent store for this wallet's activities.
await wallet.activities.store.addStore(
await this.storage.createStore(`activities_${await wallet.getId()}`, {})
);
// Determine the new Wallet's Default name.
const walletCount = Object.keys(this.wallets).length;
const walletName =
walletCount === 0 ? 'Default Wallet' : `Wallet ${walletCount + 1}`;
// Set the default metadata for this wallet.
// NOTE: Give it a timestamp of 0. This is to support wallet syncing. Without it, this new activity would overwrite the current wallet name activity from the server/other wallets
await wallet.activities.add<WalletName>({
key: 'WalletName',
value: walletName,
timestamp: BigInt(0),
});
// Start the wallet.
// NOTE: We deliberately do not await this as we want it to happen in the background.
wallet.start();
// Push it to our list of wallets.
this.wallets[walletId] = await ReactiveWallet.from({
wallet,
});
return walletId;
}
async deleteWallet(walletId: string): Promise<void> {
await this.walletsStore?.delete(walletId);
await this.storage.deleteStore(`activities_${walletId}`);
delete this.wallets[walletId];
}
}

120
src/style.css Normal file
View File

@@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

96
src/utils/mixin.ts Normal file
View File

@@ -0,0 +1,96 @@
// NOTE: This uses a lot of magic.
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types */
// More flexible constructor types that can handle protected constructors
type AnyConstructor<T = {}> =
| {
new (...args: any[]): T;
}
| {
prototype: T;
};
type AnyAbstractConstructor<T = {}> =
| {
new (...args: any[]): T;
}
| {
prototype: T;
};
export function Mixin<T extends (AnyConstructor | AnyAbstractConstructor)[]>(
_baseClasses: [...T]
) {
// For instances, we need a more flexible type that works with the constructor protection level
type ClassType<T> = T extends { new (...args: any[]): infer R }
? R
: T extends { prototype: infer P }
? P
: never;
type Instances = { [K in keyof T]: ClassType<T[K]> };
class MultiDelegatingBase {
protected _internals: Instances;
constructor(...instances: Instances) {
this._internals = instances as Instances;
return new Proxy(this, {
get: (target, prop, receiver) => {
// If the property exists on the delegating class, use that
if (prop in target && prop !== '_internals') {
return Reflect.get(target, prop, receiver);
}
// Try to find the property in one of the internal instances
for (const internal of target._internals) {
if (prop in internal) {
const value = Reflect.get(internal, prop, internal);
// If it's a method, bind it to the appropriate internal instance
if (typeof value === 'function') {
return function (...args: any[]) {
return value.apply(internal, args);
};
}
return value;
}
}
return undefined;
},
});
}
}
// Helper type that makes all properties concrete (non-abstract)
type Concrete<T> = {
[P in keyof T]: T[P];
};
// Utility type to convert union to intersection
type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;
// Get the instance type in a more flexible way
type InstanceType<T> = T extends { new (...args: any[]): infer R }
? R
: T extends { prototype: infer P }
? P
: never;
// Merged type of all instance types with abstract methods treated as concrete
type ComposedInstance = {
_internals: Instances;
} & Concrete<UnionToIntersection<InstanceType<T[number]>>>;
return MultiDelegatingBase as unknown as new (
...instances: Instances
) => ComposedInstance;
}

22
tsconfig.app.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [vue(), tailwindcss(), tsconfigPaths()],
})