Initial commit
This commit is contained in:
80
.gitignore
vendored
Normal file
80
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal file
5
README.md
Normal 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
21
components.json
Normal 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
13
index.html
Normal 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
2185
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
1
public/vite.svg
Normal 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
11
src/App.vue
Normal 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
1
src/assets/vue.svg
Normal 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
29
src/boot/app.ts
Normal 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}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal 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
7
src/lib/utils.ts
Normal 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
12
src/main.ts
Normal 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
742
src/pages/AddressesPage.vue
Normal 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
815
src/pages/HomePage.vue
Normal 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
717
src/pages/WalletPage.vue
Normal 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
39
src/router/index.ts
Normal 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
127
src/services/app.ts
Normal 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;
|
||||
}
|
||||
60
src/services/blockchain.ts
Normal file
60
src/services/blockchain.ts
Normal 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
109
src/services/rates.ts
Normal 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
309
src/services/wallet.ts
Normal 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
223
src/services/wallets.ts
Normal 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
120
src/style.css
Normal 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
96
src/utils/mixin.ts
Normal 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
22
tsconfig.app.json
Normal 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
13
tsconfig.json
Normal 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
26
tsconfig.node.json
Normal 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
8
vite.config.ts
Normal 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()],
|
||||
})
|
||||
Reference in New Issue
Block a user