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