Compare commits
10 commits
d81b5fe81b
...
563bfa3909
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563bfa3909 | ||
|
|
e572578c8c | ||
|
|
099cb8942b | ||
|
|
9cdccece34 | ||
|
|
681f40cb5e | ||
|
|
b6522c21ef | ||
|
|
f08eb965c2 | ||
|
|
c32622f34f | ||
|
|
81347ab15d | ||
|
|
63c52b26b2 |
14
README.md
|
|
@ -11,20 +11,22 @@ Quantum-secure decentralized cloud computing platform built on DAG-based consens
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Desktop Wallet
|
### Desktop Wallet (v0.1.1)
|
||||||
|
|
||||||
Download the latest release for your platform:
|
Download the latest release for your platform:
|
||||||
|
|
||||||
| Platform | Download | Notes |
|
| Platform | Download | Notes |
|
||||||
|----------|----------|-------|
|
|----------|----------|-------|
|
||||||
| **macOS (Apple Silicon)** | `Synor-Wallet_x.x.x_aarch64.dmg` | Drag to Applications |
|
| **macOS (Apple Silicon)** | `Synor-Wallet_0.1.1_aarch64.dmg` | Drag to Applications |
|
||||||
| **macOS (Intel)** | `Synor-Wallet_x.x.x_x64.dmg` | Drag to Applications |
|
| **macOS (Intel)** | `Synor-Wallet_0.1.1_x64.dmg` | Drag to Applications |
|
||||||
| **Windows** | `Synor-Wallet_x.x.x_x64_en-US.msi` | Run installer (recommended) |
|
| **Windows** | `Synor-Wallet_0.1.1_x64_en-US.msi` | Run installer (recommended) |
|
||||||
| **Windows (portable)** | `Synor-Wallet_x.x.x_x64-setup.exe` | Alternative installer |
|
| **Windows (portable)** | `Synor-Wallet_0.1.1_x64-setup.exe` | Alternative installer |
|
||||||
| **Linux** | `Synor-Wallet_x.x.x_amd64.AppImage` | `chmod +x && ./` |
|
| **Linux** | `Synor-Wallet_0.1.1_amd64.AppImage` | `chmod +x && ./` |
|
||||||
|
|
||||||
**First launch on macOS**: Right-click → Open (to bypass Gatekeeper if not code-signed)
|
**First launch on macOS**: Right-click → Open (to bypass Gatekeeper if not code-signed)
|
||||||
|
|
||||||
|
See [Desktop Wallet README](apps/desktop-wallet/README.md) for detailed features and development guide.
|
||||||
|
|
||||||
### Node Daemon (synord)
|
### Node Daemon (synord)
|
||||||
|
|
||||||
Download pre-built binaries:
|
Download pre-built binaries:
|
||||||
|
|
|
||||||
26
apps/desktop-wallet/Dockerfile.dev
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Development Dockerfile for Synor Desktop Wallet Frontend
|
||||||
|
# This runs the Vite dev server for hot-reload development
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Install curl for healthcheck
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose the dev server port
|
||||||
|
EXPOSE 19420
|
||||||
|
|
||||||
|
# Start the Vite dev server
|
||||||
|
CMD ["pnpm", "run", "dev", "--host", "0.0.0.0", "--port", "19420"]
|
||||||
243
apps/desktop-wallet/README.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Synor Desktop Wallet
|
||||||
|
|
||||||
|
A secure desktop wallet for the Synor blockchain network with post-quantum cryptography support (Dilithium3).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **24-word BIP39 Mnemonic**: Industry-standard seed phrase generation
|
||||||
|
- **Post-Quantum Signatures**: Dilithium3 (NIST FIPS 204) for future-proof security
|
||||||
|
- **QR Code Generation**: Easily share receive addresses with scannable QR codes
|
||||||
|
- **OS Keychain Integration**: Secure storage via macOS Keychain, Windows Credential Manager, or Linux Secret Service
|
||||||
|
- **System Tray**: Minimize to tray for background operation
|
||||||
|
- **Auto-Updates**: Built-in updater for seamless version upgrades (when code-signed)
|
||||||
|
- **Multiple Addresses**: Generate and manage multiple receiving addresses
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Download Pre-built Binaries
|
||||||
|
|
||||||
|
Download the latest release from [GitHub Releases](https://github.com/g1-technologies/synor/releases):
|
||||||
|
|
||||||
|
| Platform | File | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| **macOS (Apple Silicon)** | `Synor-Wallet_x.x.x_aarch64.dmg` | M1/M2/M3 Macs |
|
||||||
|
| **macOS (Intel)** | `Synor-Wallet_x.x.x_x64.dmg` | Intel Macs |
|
||||||
|
| **Windows** | `Synor-Wallet_x.x.x_x64_en-US.msi` | Recommended installer |
|
||||||
|
| **Windows (portable)** | `Synor-Wallet_x.x.x_x64-setup.exe` | Alternative installer |
|
||||||
|
| **Linux** | `Synor-Wallet_x.x.x_amd64.AppImage` | Universal Linux |
|
||||||
|
|
||||||
|
### macOS First Launch
|
||||||
|
|
||||||
|
If the app is not code-signed, macOS Gatekeeper will block it. To bypass:
|
||||||
|
1. Right-click the app → Open
|
||||||
|
2. Click "Open" in the dialog
|
||||||
|
|
||||||
|
Or run from terminal:
|
||||||
|
```bash
|
||||||
|
xattr -cr /Applications/Synor\ Wallet.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+**
|
||||||
|
- **pnpm** (`npm install -g pnpm`)
|
||||||
|
- **Rust 1.75+** (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`)
|
||||||
|
- **Tauri CLI** (`cargo install tauri-cli`)
|
||||||
|
|
||||||
|
**macOS additional:**
|
||||||
|
```bash
|
||||||
|
brew install rocksdb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux additional:**
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libclang-dev \
|
||||||
|
llvm-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
#### Full Development (with Rust backend)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop-wallet
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Browser Preview Mode (UI only)
|
||||||
|
|
||||||
|
For rapid UI development without compiling Rust:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop-wallet
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:1420 in your browser.
|
||||||
|
|
||||||
|
> **Note**: Browser preview mode uses mock data. Wallet operations (create, import, sign) are simulated.
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop-wallet
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output locations:
|
||||||
|
- **macOS**: `src-tauri/target/release/bundle/dmg/`
|
||||||
|
- **Windows**: `src-tauri/target/release/bundle/msi/`
|
||||||
|
- **Linux**: `src-tauri/target/release/bundle/appimage/`
|
||||||
|
|
||||||
|
### Docker Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop-wallet
|
||||||
|
docker-compose -f docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs frontend at http://localhost:19420
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Playwright E2E tests
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Run with UI
|
||||||
|
pnpm test:ui
|
||||||
|
|
||||||
|
# Run headed (visible browser)
|
||||||
|
pnpm test:headed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/desktop-wallet/
|
||||||
|
├── src/ # React frontend
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── pages/ # Route pages
|
||||||
|
│ │ ├── Welcome.tsx # Create/import wallet
|
||||||
|
│ │ ├── Dashboard.tsx # Balance overview
|
||||||
|
│ │ ├── Send.tsx # Send transactions
|
||||||
|
│ │ ├── Receive.tsx # Receive with QR codes
|
||||||
|
│ │ ├── Transactions.tsx # Transaction history
|
||||||
|
│ │ └── Settings.tsx # App settings
|
||||||
|
│ ├── store/ # Zustand state management
|
||||||
|
│ │ ├── wallet.ts # Wallet state
|
||||||
|
│ │ ├── node.ts # Node connection state
|
||||||
|
│ │ └── mining.ts # Mining state
|
||||||
|
│ ├── lib/ # Utilities
|
||||||
|
│ │ └── tauri.ts # Tauri invoke wrapper
|
||||||
|
│ └── App.tsx # Route definitions
|
||||||
|
├── src-tauri/ # Rust backend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── commands.rs # Tauri command handlers
|
||||||
|
│ │ ├── keychain.rs # OS keychain integration
|
||||||
|
│ │ ├── crypto.rs # Cryptographic operations
|
||||||
|
│ │ └── lib.rs # Main library
|
||||||
|
│ ├── Cargo.toml # Rust dependencies
|
||||||
|
│ ├── tauri.conf.json # Tauri configuration
|
||||||
|
│ └── icons/ # App icons
|
||||||
|
├── e2e/ # Playwright E2E tests
|
||||||
|
├── playwright.config.ts # Test configuration
|
||||||
|
└── package.json # Node.js dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Tauri Configuration
|
||||||
|
|
||||||
|
Edit `src-tauri/tauri.conf.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [{
|
||||||
|
"title": "Synor Wallet",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 768,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": ["https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
The Content Security Policy (CSP) is configured to:
|
||||||
|
- Allow connections to `*.synor.io` and localhost
|
||||||
|
- Block inline scripts (except WASM)
|
||||||
|
- Prevent embedding in iframes
|
||||||
|
|
||||||
|
## Embedded Node (Optional)
|
||||||
|
|
||||||
|
The wallet can optionally include a full node for decentralized operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build with embedded node support
|
||||||
|
cargo build --release --features embedded-node
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables:
|
||||||
|
- Running a full node inside the wallet
|
||||||
|
- Mining directly from the wallet
|
||||||
|
- No external RPC dependency
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v0.1.1 (2026-02-02)
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- QR code generation on Receive page for easy address sharing
|
||||||
|
- Improved navigation flow on unlock screen
|
||||||
|
|
||||||
|
**Bug Fixes:**
|
||||||
|
- Fixed app crash on macOS after installation (Tauri 2.0 plugin configuration)
|
||||||
|
- Fixed icon bit depth compatibility issue (16-bit to 8-bit RGBA)
|
||||||
|
- Removed deprecated plugin scope configurations
|
||||||
|
|
||||||
|
**Technical:**
|
||||||
|
- Updated to Tauri 2.0 stable plugin APIs
|
||||||
|
- Added missing Rust dependencies (`once_cell`, `md5`)
|
||||||
|
- Removed duplicate imports in commands.rs
|
||||||
|
|
||||||
|
### v0.1.0 (Initial Release)
|
||||||
|
|
||||||
|
- Basic wallet creation and import
|
||||||
|
- Send/receive SYN tokens
|
||||||
|
- Transaction history
|
||||||
|
- Multiple address support
|
||||||
|
- OS keychain integration
|
||||||
|
- System tray support
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Seed phrases** are encrypted with Argon2id and stored in OS keychain
|
||||||
|
- **Private keys** are derived using BIP32 HD wallet standard
|
||||||
|
- **Transactions** are signed locally; private keys never leave the device
|
||||||
|
- **Post-quantum signatures** use Dilithium3 (NIST FIPS 204)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT OR Apache-2.0
|
||||||
34
apps/desktop-wallet/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Development Docker Compose for Synor Desktop Wallet
|
||||||
|
# Note: This runs the Vite dev server for frontend development
|
||||||
|
# The full Tauri app requires native compilation and can't run in Docker
|
||||||
|
|
||||||
|
services:
|
||||||
|
wallet-frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: synor-wallet-frontend-dev
|
||||||
|
ports:
|
||||||
|
- "19420:19420" # Reserved port for wallet dev server
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src:delegated
|
||||||
|
- ./public:/app/public:delegated
|
||||||
|
- ./index.html:/app/index.html:ro
|
||||||
|
- ./vite.config.ts:/app/vite.config.ts:ro
|
||||||
|
- ./tailwind.config.js:/app/tailwind.config.js:ro
|
||||||
|
- ./postcss.config.js:/app/postcss.config.js:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
# Exclude node_modules to use container's installed packages
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- VITE_DEV_SERVER_PORT=19420
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:19420"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
150
apps/desktop-wallet/e2e/smoke.spec.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke tests for Synor Desktop Wallet
|
||||||
|
*
|
||||||
|
* These tests verify the development server is running and serving content.
|
||||||
|
* Note: Full E2E testing of Tauri features requires running the complete
|
||||||
|
* Tauri application with the Rust backend.
|
||||||
|
*
|
||||||
|
* For comprehensive E2E testing, use:
|
||||||
|
* pnpm tauri:dev (to run full app)
|
||||||
|
*
|
||||||
|
* These smoke tests verify:
|
||||||
|
* - Dev server responds
|
||||||
|
* - HTML content is served
|
||||||
|
* - React app bundles load
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Smoke Tests', () => {
|
||||||
|
test('dev server should respond', async ({ page }) => {
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should serve HTML content', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const html = await page.content();
|
||||||
|
// Should have basic HTML structure
|
||||||
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
|
expect(html).toContain('<html');
|
||||||
|
expect(html).toContain('</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have root element for React', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const html = await page.content();
|
||||||
|
// Should have React root element
|
||||||
|
expect(html).toContain('id="root"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load JavaScript bundles', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const html = await page.content();
|
||||||
|
// Should include script tags
|
||||||
|
expect(html).toContain('<script');
|
||||||
|
expect(html).toContain('type="module"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load CSS', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const html = await page.content();
|
||||||
|
// Should include styles (either link stylesheet or style tag)
|
||||||
|
const hasStylesheet = html.includes('stylesheet') || html.includes('<style');
|
||||||
|
expect(hasStylesheet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct title', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const title = await page.title();
|
||||||
|
// Should have a title set
|
||||||
|
expect(title.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all routes should return 200', async ({ page }) => {
|
||||||
|
const routes = [
|
||||||
|
'/',
|
||||||
|
'/setup',
|
||||||
|
'/dashboard',
|
||||||
|
'/send',
|
||||||
|
'/receive',
|
||||||
|
'/history',
|
||||||
|
'/node',
|
||||||
|
'/mining',
|
||||||
|
'/staking',
|
||||||
|
'/swap',
|
||||||
|
'/market',
|
||||||
|
'/contracts',
|
||||||
|
'/tokens',
|
||||||
|
'/nfts',
|
||||||
|
'/settings',
|
||||||
|
'/storage',
|
||||||
|
'/hosting',
|
||||||
|
'/compute',
|
||||||
|
'/database',
|
||||||
|
'/privacy',
|
||||||
|
'/bridge',
|
||||||
|
'/governance',
|
||||||
|
'/zk',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.status(), `Route ${route} should return 200`).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Build Verification', () => {
|
||||||
|
test('should load without unexpected JavaScript errors', async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
const text = msg.text();
|
||||||
|
// Ignore expected errors when running outside Tauri context:
|
||||||
|
// - Tauri API errors (__TAURI__, invoke, etc.)
|
||||||
|
// - React error boundary messages (expected when components fail without Tauri)
|
||||||
|
// - Window property checks
|
||||||
|
const isExpectedError =
|
||||||
|
text.includes('__TAURI__') ||
|
||||||
|
text.includes('tauri') ||
|
||||||
|
text.includes('invoke') ||
|
||||||
|
text.includes('window.') ||
|
||||||
|
text.includes('error boundary') ||
|
||||||
|
text.includes('Error occurred in') ||
|
||||||
|
text.includes('TitleBar') ||
|
||||||
|
text.includes('getCurrentWindow');
|
||||||
|
|
||||||
|
if (!isExpectedError) {
|
||||||
|
errors.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForTimeout(2000); // Wait for async operations
|
||||||
|
|
||||||
|
// Log any errors found for debugging
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log('Unexpected console errors found:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have no unexpected errors
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have network failures for static assets', async ({ page }) => {
|
||||||
|
const failedRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
failedRequests.push(`${request.url()} - ${request.failure()?.errorText}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should have no failed requests
|
||||||
|
expect(failedRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@synor/desktop-wallet",
|
"name": "@synor/desktop-wallet",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -10,27 +10,32 @@
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-store": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
"zustand": "^4.4.7",
|
"tailwind-merge": "^2.2.0",
|
||||||
"lucide-react": "^0.303.0",
|
"zustand": "^4.4.7"
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"tailwind-merge": "^2.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
|
|
||||||
65
apps/desktop-wallet/playwright.config.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright E2E test configuration for Synor Desktop Wallet
|
||||||
|
*
|
||||||
|
* Tests run against the Vite dev server with mocked Tauri APIs.
|
||||||
|
* This allows testing the complete UI flow without requiring the
|
||||||
|
* full Tauri application to be running.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
|
||||||
|
// Run tests in parallel
|
||||||
|
fullyParallel: true,
|
||||||
|
|
||||||
|
// Fail the build on CI if you accidentally left test.only in the source code
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
// Retry on CI only
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
// Workers for parallel execution
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
|
||||||
|
// Reporter configuration
|
||||||
|
reporter: [
|
||||||
|
['html', { outputFolder: 'playwright-report' }],
|
||||||
|
['list'],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Shared settings for all projects
|
||||||
|
use: {
|
||||||
|
// Base URL for the dev server
|
||||||
|
baseURL: 'http://localhost:1420',
|
||||||
|
|
||||||
|
// Collect trace when retrying the failed test
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
// Screenshot on failure
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
// Video on failure
|
||||||
|
video: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure projects for different browsers
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Run dev server before starting tests
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm run dev',
|
||||||
|
url: 'http://localhost:1420',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
50
apps/desktop-wallet/pnpm-lock.yaml
generated
|
|
@ -41,6 +41,9 @@ importers:
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.303.0
|
specifier: ^0.303.0
|
||||||
version: 0.303.0(react@18.3.1)
|
version: 0.303.0(react@18.3.1)
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -57,6 +60,9 @@ importers:
|
||||||
specifier: ^4.4.7
|
specifier: ^4.4.7
|
||||||
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.40.0
|
||||||
|
version: 1.58.1
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.9.6
|
version: 2.9.6
|
||||||
|
|
@ -340,6 +346,11 @@ packages:
|
||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@playwright/test@1.58.1':
|
||||||
|
resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@remix-run/router@1.23.2':
|
'@remix-run/router@1.23.2':
|
||||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -716,6 +727,11 @@ packages:
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -852,6 +868,16 @@ packages:
|
||||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
playwright-core@1.58.1:
|
||||||
|
resolution: {integrity: sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.58.1:
|
||||||
|
resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss-import@15.1.0:
|
postcss-import@15.1.0:
|
||||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -899,6 +925,11 @@ packages:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0:
|
||||||
|
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
|
@ -1282,6 +1313,10 @@ snapshots:
|
||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
'@playwright/test@1.58.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.58.1
|
||||||
|
|
||||||
'@remix-run/router@1.23.2': {}
|
'@remix-run/router@1.23.2': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
@ -1608,6 +1643,9 @@ snapshots:
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -1704,6 +1742,14 @@ snapshots:
|
||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
|
playwright-core@1.58.1: {}
|
||||||
|
|
||||||
|
playwright@1.58.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.58.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.6):
|
postcss-import@15.1.0(postcss@8.5.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|
@ -1741,6 +1787,10 @@ snapshots:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
react-dom@18.3.1(react@18.3.1):
|
react-dom@18.3.1(react@18.3.1):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "synor-wallet"
|
name = "synor-wallet"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Secure Synor blockchain wallet with post-quantum cryptography"
|
description = "Secure Synor blockchain wallet with post-quantum cryptography"
|
||||||
authors = ["Synor Team"]
|
authors = ["Synor Team"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -53,6 +53,11 @@ futures-util = "0.3"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
once_cell = "1"
|
||||||
|
md5 = "0.7"
|
||||||
|
|
||||||
# Local crates from the monorepo (required for wallet functionality)
|
# Local crates from the monorepo (required for wallet functionality)
|
||||||
synor-crypto = { path = "../../../crates/synor-crypto" }
|
synor-crypto = { path = "../../../crates/synor-crypto" }
|
||||||
synor-types = { path = "../../../crates/synor-types" }
|
synor-types = { path = "../../../crates/synor-types" }
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 24 KiB |
|
|
@ -68,6 +68,9 @@ pub enum Error {
|
||||||
#[error("Contract error: {0}")]
|
#[error("Contract error: {0}")]
|
||||||
ContractError(String),
|
ContractError(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ mod keychain;
|
||||||
mod node;
|
mod node;
|
||||||
mod rpc_client;
|
mod rpc_client;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
mod wallet_manager;
|
||||||
|
mod watch_only;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem},
|
menu::{Menu, MenuItem},
|
||||||
|
|
@ -114,10 +116,18 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Initialize wallet state
|
// Initialize wallet state (legacy, for backwards compatibility)
|
||||||
let wallet_state = wallet::WalletState::new();
|
let wallet_state = wallet::WalletState::new();
|
||||||
app.manage(wallet_state);
|
app.manage(wallet_state);
|
||||||
|
|
||||||
|
// Initialize wallet manager (multi-wallet support)
|
||||||
|
let wallet_manager = wallet_manager::WalletManager::new();
|
||||||
|
app.manage(wallet_manager);
|
||||||
|
|
||||||
|
// Initialize watch-only address manager
|
||||||
|
let watch_only_manager = watch_only::WatchOnlyManager::new();
|
||||||
|
app.manage(watch_only_manager);
|
||||||
|
|
||||||
// Initialize node manager with app handle for events
|
// Initialize node manager with app handle for events
|
||||||
let node_manager = std::sync::Arc::new(
|
let node_manager = std::sync::Arc::new(
|
||||||
node::NodeManager::with_app_handle(app.handle().clone())
|
node::NodeManager::with_app_handle(app.handle().clone())
|
||||||
|
|
@ -171,13 +181,32 @@ pub fn run() {
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Wallet management
|
// Wallet management (legacy single-wallet)
|
||||||
commands::create_wallet,
|
commands::create_wallet,
|
||||||
commands::import_wallet,
|
commands::import_wallet,
|
||||||
commands::unlock_wallet,
|
commands::unlock_wallet,
|
||||||
commands::lock_wallet,
|
commands::lock_wallet,
|
||||||
commands::get_wallet_info,
|
commands::get_wallet_info,
|
||||||
commands::export_mnemonic,
|
commands::export_mnemonic,
|
||||||
|
// Multi-wallet management
|
||||||
|
commands::wallets_list,
|
||||||
|
commands::wallets_create,
|
||||||
|
commands::wallets_import,
|
||||||
|
commands::wallets_switch,
|
||||||
|
commands::wallets_rename,
|
||||||
|
commands::wallets_delete,
|
||||||
|
commands::wallets_get_active,
|
||||||
|
commands::wallets_unlock_active,
|
||||||
|
commands::wallets_lock_active,
|
||||||
|
commands::wallets_migrate_legacy,
|
||||||
|
// Watch-only addresses
|
||||||
|
commands::watch_only_list,
|
||||||
|
commands::watch_only_add,
|
||||||
|
commands::watch_only_update,
|
||||||
|
commands::watch_only_remove,
|
||||||
|
commands::watch_only_get,
|
||||||
|
commands::watch_only_refresh_balance,
|
||||||
|
commands::watch_only_get_tags,
|
||||||
// Addresses & UTXOs
|
// Addresses & UTXOs
|
||||||
commands::get_addresses,
|
commands::get_addresses,
|
||||||
commands::generate_address,
|
commands::generate_address,
|
||||||
|
|
@ -188,6 +217,44 @@ pub fn run() {
|
||||||
commands::sign_transaction,
|
commands::sign_transaction,
|
||||||
commands::broadcast_transaction,
|
commands::broadcast_transaction,
|
||||||
commands::get_transaction_history,
|
commands::get_transaction_history,
|
||||||
|
// Batch transactions
|
||||||
|
commands::create_batch_transaction,
|
||||||
|
// Fee market analytics
|
||||||
|
commands::fee_get_mempool_stats,
|
||||||
|
commands::fee_get_recommendations,
|
||||||
|
commands::fee_get_analytics,
|
||||||
|
commands::fee_get_history,
|
||||||
|
commands::fee_calculate,
|
||||||
|
// Time-locked vaults
|
||||||
|
commands::vault_list,
|
||||||
|
commands::vault_get_summary,
|
||||||
|
commands::vault_create,
|
||||||
|
commands::vault_get,
|
||||||
|
commands::vault_withdraw,
|
||||||
|
commands::vault_delete,
|
||||||
|
commands::vault_time_remaining,
|
||||||
|
// Social recovery
|
||||||
|
commands::recovery_get_config,
|
||||||
|
commands::recovery_setup,
|
||||||
|
commands::recovery_add_guardian,
|
||||||
|
commands::recovery_remove_guardian,
|
||||||
|
commands::recovery_list_guardians,
|
||||||
|
commands::recovery_update_threshold,
|
||||||
|
commands::recovery_initiate,
|
||||||
|
commands::recovery_approve,
|
||||||
|
commands::recovery_get_request,
|
||||||
|
commands::recovery_list_requests,
|
||||||
|
commands::recovery_cancel,
|
||||||
|
commands::recovery_disable,
|
||||||
|
// Decoy wallets
|
||||||
|
commands::decoy_is_enabled,
|
||||||
|
commands::decoy_setup,
|
||||||
|
commands::decoy_create,
|
||||||
|
commands::decoy_list,
|
||||||
|
commands::decoy_update_balance,
|
||||||
|
commands::decoy_delete,
|
||||||
|
commands::decoy_check_duress,
|
||||||
|
commands::decoy_disable,
|
||||||
// Network (legacy)
|
// Network (legacy)
|
||||||
commands::connect_node,
|
commands::connect_node,
|
||||||
commands::disconnect_node,
|
commands::disconnect_node,
|
||||||
|
|
@ -238,6 +305,158 @@ pub fn run() {
|
||||||
commands::nft_list_owned_in_collection,
|
commands::nft_list_owned_in_collection,
|
||||||
commands::nft_set_approval_for_all,
|
commands::nft_set_approval_for_all,
|
||||||
commands::nft_set_base_uri,
|
commands::nft_set_base_uri,
|
||||||
|
// Staking
|
||||||
|
commands::staking_get_pools,
|
||||||
|
commands::staking_get_user_stakes,
|
||||||
|
commands::staking_stake,
|
||||||
|
commands::staking_unstake,
|
||||||
|
commands::staking_claim_rewards,
|
||||||
|
// DEX/Swap
|
||||||
|
commands::swap_get_quote,
|
||||||
|
commands::swap_execute,
|
||||||
|
commands::swap_get_pools,
|
||||||
|
commands::swap_add_liquidity,
|
||||||
|
commands::swap_remove_liquidity,
|
||||||
|
// Address Book
|
||||||
|
commands::addressbook_get_all,
|
||||||
|
commands::addressbook_add,
|
||||||
|
commands::addressbook_update,
|
||||||
|
commands::addressbook_delete,
|
||||||
|
// Market/Prices
|
||||||
|
commands::market_get_prices,
|
||||||
|
commands::market_get_history,
|
||||||
|
// Multi-sig
|
||||||
|
commands::multisig_create,
|
||||||
|
commands::multisig_get_info,
|
||||||
|
commands::multisig_propose_tx,
|
||||||
|
commands::multisig_sign_tx,
|
||||||
|
commands::multisig_execute_tx,
|
||||||
|
commands::multisig_get_pending_txs,
|
||||||
|
// Backup/Export
|
||||||
|
commands::backup_export_wallet,
|
||||||
|
commands::backup_import_wallet,
|
||||||
|
commands::backup_export_history,
|
||||||
|
// Hardware Wallet
|
||||||
|
commands::hardware_detect_devices,
|
||||||
|
commands::hardware_get_address,
|
||||||
|
commands::hardware_sign_transaction,
|
||||||
|
// QR Code
|
||||||
|
commands::qr_generate,
|
||||||
|
commands::qr_parse_payment,
|
||||||
|
// DApp Browser
|
||||||
|
commands::dapp_get_connected,
|
||||||
|
commands::dapp_connect,
|
||||||
|
commands::dapp_disconnect,
|
||||||
|
commands::dapp_handle_request,
|
||||||
|
// Storage
|
||||||
|
commands::storage_upload,
|
||||||
|
commands::storage_download,
|
||||||
|
commands::storage_get_file_info,
|
||||||
|
commands::storage_list_files,
|
||||||
|
commands::storage_pin,
|
||||||
|
commands::storage_unpin,
|
||||||
|
commands::storage_delete,
|
||||||
|
commands::storage_get_usage,
|
||||||
|
// Hosting
|
||||||
|
commands::hosting_register_name,
|
||||||
|
commands::hosting_deploy,
|
||||||
|
commands::hosting_list_sites,
|
||||||
|
commands::hosting_add_custom_domain,
|
||||||
|
commands::hosting_verify_domain,
|
||||||
|
commands::hosting_delete_site,
|
||||||
|
// Compute
|
||||||
|
commands::compute_list_providers,
|
||||||
|
commands::compute_submit_job,
|
||||||
|
commands::compute_get_job,
|
||||||
|
commands::compute_list_jobs,
|
||||||
|
commands::compute_cancel_job,
|
||||||
|
// Database
|
||||||
|
commands::database_create,
|
||||||
|
commands::database_list,
|
||||||
|
commands::database_get_info,
|
||||||
|
commands::database_delete,
|
||||||
|
commands::database_query,
|
||||||
|
// Privacy
|
||||||
|
commands::privacy_get_balance,
|
||||||
|
commands::privacy_send,
|
||||||
|
commands::privacy_generate_stealth_address,
|
||||||
|
commands::privacy_shield,
|
||||||
|
commands::privacy_unshield,
|
||||||
|
commands::privacy_create_token,
|
||||||
|
commands::privacy_deploy_contract,
|
||||||
|
// Bridge
|
||||||
|
commands::bridge_get_chains,
|
||||||
|
commands::bridge_deposit,
|
||||||
|
commands::bridge_withdraw,
|
||||||
|
commands::bridge_get_transfer,
|
||||||
|
commands::bridge_list_transfers,
|
||||||
|
commands::bridge_get_wrapped_balance,
|
||||||
|
// Governance
|
||||||
|
commands::governance_get_proposals,
|
||||||
|
commands::governance_get_proposal,
|
||||||
|
commands::governance_create_proposal,
|
||||||
|
commands::governance_vote,
|
||||||
|
commands::governance_execute_proposal,
|
||||||
|
commands::governance_get_voting_power,
|
||||||
|
commands::governance_delegate,
|
||||||
|
// ZK-Rollup
|
||||||
|
commands::zk_get_stats,
|
||||||
|
commands::zk_get_account,
|
||||||
|
commands::zk_deposit,
|
||||||
|
commands::zk_withdraw,
|
||||||
|
commands::zk_transfer,
|
||||||
|
// Transaction Mixer (Phase 8)
|
||||||
|
commands::mixer_get_denominations,
|
||||||
|
commands::mixer_get_pool_status,
|
||||||
|
commands::mixer_create_request,
|
||||||
|
commands::mixer_get_request,
|
||||||
|
commands::mixer_list_requests,
|
||||||
|
commands::mixer_cancel_request,
|
||||||
|
// Limit Orders (Phase 9)
|
||||||
|
commands::limit_order_create,
|
||||||
|
commands::limit_order_get,
|
||||||
|
commands::limit_order_list,
|
||||||
|
commands::limit_order_cancel,
|
||||||
|
commands::limit_order_get_orderbook,
|
||||||
|
// Yield Aggregator (Phase 10)
|
||||||
|
commands::yield_get_opportunities,
|
||||||
|
commands::yield_deposit,
|
||||||
|
commands::yield_withdraw,
|
||||||
|
commands::yield_list_positions,
|
||||||
|
commands::yield_compound,
|
||||||
|
// Portfolio Analytics (Phase 11)
|
||||||
|
commands::portfolio_get_summary,
|
||||||
|
commands::portfolio_get_holdings,
|
||||||
|
commands::portfolio_get_tax_report,
|
||||||
|
commands::portfolio_export_tax_report,
|
||||||
|
commands::portfolio_get_history,
|
||||||
|
// Price Alerts (Phase 12)
|
||||||
|
commands::alert_create,
|
||||||
|
commands::alert_list,
|
||||||
|
commands::alert_delete,
|
||||||
|
commands::alert_toggle,
|
||||||
|
// CLI Mode (Phase 13)
|
||||||
|
commands::cli_execute,
|
||||||
|
commands::cli_get_history,
|
||||||
|
// RPC Profiles (Phase 14)
|
||||||
|
commands::rpc_profile_create,
|
||||||
|
commands::rpc_profile_list,
|
||||||
|
commands::rpc_profile_set_active,
|
||||||
|
commands::rpc_profile_delete,
|
||||||
|
commands::rpc_profile_test,
|
||||||
|
// Transaction Builder (Phase 15)
|
||||||
|
commands::tx_builder_create,
|
||||||
|
commands::tx_builder_sign,
|
||||||
|
commands::tx_builder_broadcast,
|
||||||
|
commands::tx_builder_decode,
|
||||||
|
// Plugin System (Phase 16)
|
||||||
|
commands::plugin_list_available,
|
||||||
|
commands::plugin_list_installed,
|
||||||
|
commands::plugin_install,
|
||||||
|
commands::plugin_uninstall,
|
||||||
|
commands::plugin_toggle,
|
||||||
|
commands::plugin_get_settings,
|
||||||
|
commands::plugin_set_settings,
|
||||||
// Updates
|
// Updates
|
||||||
check_update,
|
check_update,
|
||||||
install_update,
|
install_update,
|
||||||
|
|
|
||||||
491
apps/desktop-wallet/src-tauri/src/wallet_manager.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
//! Multi-wallet management for the desktop wallet
|
||||||
|
//!
|
||||||
|
//! Supports multiple wallets with:
|
||||||
|
//! - Unique IDs for each wallet
|
||||||
|
//! - Labels/names for easy identification
|
||||||
|
//! - Switching between wallets
|
||||||
|
//! - Wallet-specific data directories
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::wallet::{WalletMetadata, WalletState};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
/// Wallet index file name
|
||||||
|
const WALLETS_INDEX_FILE: &str = "wallets.json";
|
||||||
|
|
||||||
|
/// Summary info for a wallet (non-sensitive, used in listings)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WalletSummary {
|
||||||
|
/// Unique wallet identifier
|
||||||
|
pub id: String,
|
||||||
|
/// User-defined label/name
|
||||||
|
pub label: String,
|
||||||
|
/// Primary address (first derived)
|
||||||
|
pub primary_address: String,
|
||||||
|
/// Network (mainnet/testnet)
|
||||||
|
pub network: String,
|
||||||
|
/// Creation timestamp
|
||||||
|
pub created_at: i64,
|
||||||
|
/// Last access timestamp
|
||||||
|
pub last_accessed: i64,
|
||||||
|
/// Whether this is the active wallet
|
||||||
|
#[serde(skip)]
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted wallet index
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct WalletsIndex {
|
||||||
|
/// Map of wallet ID to summary
|
||||||
|
pub wallets: HashMap<String, WalletSummary>,
|
||||||
|
/// Currently active wallet ID
|
||||||
|
pub active_wallet_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages multiple wallets
|
||||||
|
pub struct WalletManager {
|
||||||
|
/// Base data directory (contains wallet subdirectories)
|
||||||
|
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
/// Index of all wallets
|
||||||
|
pub index: Arc<RwLock<WalletsIndex>>,
|
||||||
|
/// Currently active wallet state
|
||||||
|
pub active_wallet: Arc<RwLock<Option<WalletState>>>,
|
||||||
|
/// Currently active wallet ID
|
||||||
|
pub active_wallet_id: Arc<RwLock<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WalletManager {
|
||||||
|
/// Create a new wallet manager
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
data_dir: Arc::new(RwLock::new(None)),
|
||||||
|
index: Arc::new(RwLock::new(WalletsIndex::default())),
|
||||||
|
active_wallet: Arc::new(RwLock::new(None)),
|
||||||
|
active_wallet_id: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the base data directory
|
||||||
|
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
|
||||||
|
tokio::fs::create_dir_all(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let mut data_dir = self.data_dir.write().await;
|
||||||
|
*data_dir = Some(path);
|
||||||
|
|
||||||
|
// Load existing index
|
||||||
|
drop(data_dir);
|
||||||
|
self.load_index().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the wallets index file path
|
||||||
|
async fn index_path(&self) -> Result<PathBuf> {
|
||||||
|
let data_dir = self.data_dir.read().await;
|
||||||
|
data_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join(WALLETS_INDEX_FILE))
|
||||||
|
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a wallet's data directory
|
||||||
|
async fn wallet_dir(&self, wallet_id: &str) -> Result<PathBuf> {
|
||||||
|
let data_dir = self.data_dir.read().await;
|
||||||
|
data_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join("wallets").join(wallet_id))
|
||||||
|
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the wallets index from disk
|
||||||
|
pub async fn load_index(&self) -> Result<()> {
|
||||||
|
let path = self.index_path().await?;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(()); // No index yet, will be created on first wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = tokio::fs::read_to_string(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let loaded_index: WalletsIndex = serde_json::from_str(&json)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
*index = loaded_index;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the wallets index to disk
|
||||||
|
pub async fn save_index(&self) -> Result<()> {
|
||||||
|
let path = self.index_path().await?;
|
||||||
|
let index = self.index.read().await;
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&*index)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&path, json).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all wallets
|
||||||
|
pub async fn list_wallets(&self) -> Vec<WalletSummary> {
|
||||||
|
let index = self.index.read().await;
|
||||||
|
let active_id = self.active_wallet_id.read().await;
|
||||||
|
|
||||||
|
let mut wallets: Vec<WalletSummary> = index.wallets.values().cloned().collect();
|
||||||
|
|
||||||
|
// Mark active wallet
|
||||||
|
if let Some(active) = active_id.as_ref() {
|
||||||
|
for wallet in &mut wallets {
|
||||||
|
wallet.is_active = &wallet.id == active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by last accessed (most recent first)
|
||||||
|
wallets.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
|
||||||
|
|
||||||
|
wallets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get active wallet ID
|
||||||
|
pub async fn get_active_wallet_id(&self) -> Option<String> {
|
||||||
|
self.active_wallet_id.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new wallet
|
||||||
|
pub async fn create_wallet(
|
||||||
|
&self,
|
||||||
|
label: String,
|
||||||
|
password: &str,
|
||||||
|
testnet: bool,
|
||||||
|
) -> Result<(String, String, String)> {
|
||||||
|
// Generate unique wallet ID
|
||||||
|
let wallet_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Create wallet directory
|
||||||
|
let wallet_dir = self.wallet_dir(&wallet_id).await?;
|
||||||
|
tokio::fs::create_dir_all(&wallet_dir).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
// Create wallet state for this wallet
|
||||||
|
let wallet_state = WalletState::new();
|
||||||
|
wallet_state.set_data_dir(wallet_dir).await?;
|
||||||
|
|
||||||
|
// Create the wallet (generates mnemonic)
|
||||||
|
let (mnemonic, address) = wallet_state.create(password, testnet).await?;
|
||||||
|
|
||||||
|
// Add to index
|
||||||
|
let summary = WalletSummary {
|
||||||
|
id: wallet_id.clone(),
|
||||||
|
label,
|
||||||
|
primary_address: address.clone(),
|
||||||
|
network: if testnet { "testnet".to_string() } else { "mainnet".to_string() },
|
||||||
|
created_at: current_timestamp(),
|
||||||
|
last_accessed: current_timestamp(),
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
index.wallets.insert(wallet_id.clone(), summary);
|
||||||
|
index.active_wallet_id = Some(wallet_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save index
|
||||||
|
self.save_index().await?;
|
||||||
|
|
||||||
|
// Set as active wallet
|
||||||
|
{
|
||||||
|
let mut active = self.active_wallet.write().await;
|
||||||
|
*active = Some(wallet_state);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut active_id = self.active_wallet_id.write().await;
|
||||||
|
*active_id = Some(wallet_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((wallet_id, mnemonic, address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a wallet from mnemonic
|
||||||
|
pub async fn import_wallet(
|
||||||
|
&self,
|
||||||
|
label: String,
|
||||||
|
mnemonic: &str,
|
||||||
|
password: &str,
|
||||||
|
testnet: bool,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
// Generate unique wallet ID
|
||||||
|
let wallet_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Create wallet directory
|
||||||
|
let wallet_dir = self.wallet_dir(&wallet_id).await?;
|
||||||
|
tokio::fs::create_dir_all(&wallet_dir).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
// Create wallet state for this wallet
|
||||||
|
let wallet_state = WalletState::new();
|
||||||
|
wallet_state.set_data_dir(wallet_dir).await?;
|
||||||
|
|
||||||
|
// Import the wallet
|
||||||
|
let address = wallet_state.import(mnemonic, password, testnet).await?;
|
||||||
|
|
||||||
|
// Add to index
|
||||||
|
let summary = WalletSummary {
|
||||||
|
id: wallet_id.clone(),
|
||||||
|
label,
|
||||||
|
primary_address: address.clone(),
|
||||||
|
network: if testnet { "testnet".to_string() } else { "mainnet".to_string() },
|
||||||
|
created_at: current_timestamp(),
|
||||||
|
last_accessed: current_timestamp(),
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
index.wallets.insert(wallet_id.clone(), summary);
|
||||||
|
index.active_wallet_id = Some(wallet_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save index
|
||||||
|
self.save_index().await?;
|
||||||
|
|
||||||
|
// Set as active wallet
|
||||||
|
{
|
||||||
|
let mut active = self.active_wallet.write().await;
|
||||||
|
*active = Some(wallet_state);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut active_id = self.active_wallet_id.write().await;
|
||||||
|
*active_id = Some(wallet_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((wallet_id, address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different wallet
|
||||||
|
pub async fn switch_wallet(&self, wallet_id: &str) -> Result<()> {
|
||||||
|
// Check wallet exists
|
||||||
|
{
|
||||||
|
let index = self.index.read().await;
|
||||||
|
if !index.wallets.contains_key(wallet_id) {
|
||||||
|
return Err(Error::WalletNotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock current wallet if any
|
||||||
|
{
|
||||||
|
let mut active = self.active_wallet.write().await;
|
||||||
|
if let Some(wallet) = active.as_ref() {
|
||||||
|
wallet.lock().await;
|
||||||
|
}
|
||||||
|
*active = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the new wallet's data
|
||||||
|
let wallet_dir = self.wallet_dir(wallet_id).await?;
|
||||||
|
let wallet_state = WalletState::new();
|
||||||
|
wallet_state.set_data_dir(wallet_dir).await?;
|
||||||
|
wallet_state.load_metadata().await?;
|
||||||
|
|
||||||
|
// Update active wallet
|
||||||
|
{
|
||||||
|
let mut active = self.active_wallet.write().await;
|
||||||
|
*active = Some(wallet_state);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut active_id = self.active_wallet_id.write().await;
|
||||||
|
*active_id = Some(wallet_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index with new active and last accessed
|
||||||
|
{
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
index.active_wallet_id = Some(wallet_id.to_string());
|
||||||
|
if let Some(summary) = index.wallets.get_mut(wallet_id) {
|
||||||
|
summary.last_accessed = current_timestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.save_index().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a wallet
|
||||||
|
pub async fn rename_wallet(&self, wallet_id: &str, new_label: String) -> Result<()> {
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
|
||||||
|
let summary = index.wallets.get_mut(wallet_id)
|
||||||
|
.ok_or(Error::WalletNotFound)?;
|
||||||
|
|
||||||
|
summary.label = new_label;
|
||||||
|
drop(index);
|
||||||
|
|
||||||
|
self.save_index().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a wallet
|
||||||
|
pub async fn delete_wallet(&self, wallet_id: &str) -> Result<()> {
|
||||||
|
// Don't allow deleting the active wallet while it's active
|
||||||
|
{
|
||||||
|
let active_id = self.active_wallet_id.read().await;
|
||||||
|
if active_id.as_ref() == Some(&wallet_id.to_string()) {
|
||||||
|
return Err(Error::Internal(
|
||||||
|
"Cannot delete the currently active wallet. Switch to another wallet first.".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from index
|
||||||
|
{
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
index.wallets.remove(wallet_id);
|
||||||
|
}
|
||||||
|
self.save_index().await?;
|
||||||
|
|
||||||
|
// Delete wallet directory
|
||||||
|
let wallet_dir = self.wallet_dir(wallet_id).await?;
|
||||||
|
if wallet_dir.exists() {
|
||||||
|
tokio::fs::remove_dir_all(&wallet_dir).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get active wallet state (returns a clone reference for thread safety)
|
||||||
|
pub async fn get_active_wallet(&self) -> Result<Arc<RwLock<Option<WalletState>>>> {
|
||||||
|
Ok(self.active_wallet.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if active wallet is unlocked
|
||||||
|
pub async fn is_active_unlocked(&self) -> bool {
|
||||||
|
let active = self.active_wallet.read().await;
|
||||||
|
if let Some(wallet) = active.as_ref() {
|
||||||
|
wallet.is_unlocked().await
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock the active wallet
|
||||||
|
pub async fn unlock_active(&self, password: &str) -> Result<()> {
|
||||||
|
let active = self.active_wallet.read().await;
|
||||||
|
let wallet = active.as_ref().ok_or(Error::WalletNotFound)?;
|
||||||
|
wallet.unlock(password).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock the active wallet
|
||||||
|
pub async fn lock_active(&self) -> Result<()> {
|
||||||
|
let active = self.active_wallet.read().await;
|
||||||
|
if let Some(wallet) = active.as_ref() {
|
||||||
|
wallet.lock().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from existing single wallet (migration)
|
||||||
|
/// This migrates a legacy single-wallet setup to multi-wallet
|
||||||
|
pub async fn migrate_legacy_wallet(&self) -> Result<Option<String>> {
|
||||||
|
let data_dir = self.data_dir.read().await;
|
||||||
|
let base_dir = data_dir.as_ref()
|
||||||
|
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))?;
|
||||||
|
|
||||||
|
// Check for legacy wallet.json in base directory
|
||||||
|
let legacy_path = base_dir.join("wallet.json");
|
||||||
|
if !legacy_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read legacy wallet
|
||||||
|
let json = tokio::fs::read_to_string(&legacy_path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let legacy_meta: WalletMetadata = serde_json::from_str(&json)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
// Generate ID for migrated wallet
|
||||||
|
let wallet_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Create new wallet directory
|
||||||
|
let wallet_dir = base_dir.join("wallets").join(&wallet_id);
|
||||||
|
tokio::fs::create_dir_all(&wallet_dir).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
// Move wallet.json to new location
|
||||||
|
let new_wallet_path = wallet_dir.join("wallet.json");
|
||||||
|
tokio::fs::copy(&legacy_path, &new_wallet_path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
// Create summary
|
||||||
|
let primary_address = legacy_meta.addresses.first()
|
||||||
|
.map(|a| a.address.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let summary = WalletSummary {
|
||||||
|
id: wallet_id.clone(),
|
||||||
|
label: "Main Wallet".to_string(),
|
||||||
|
primary_address,
|
||||||
|
network: legacy_meta.network,
|
||||||
|
created_at: legacy_meta.created_at,
|
||||||
|
last_accessed: current_timestamp(),
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
{
|
||||||
|
let mut index = self.index.write().await;
|
||||||
|
index.wallets.insert(wallet_id.clone(), summary);
|
||||||
|
index.active_wallet_id = Some(wallet_id.clone());
|
||||||
|
}
|
||||||
|
self.save_index().await?;
|
||||||
|
|
||||||
|
// Rename legacy file to indicate migration
|
||||||
|
let backup_path = base_dir.join("wallet.json.migrated");
|
||||||
|
tokio::fs::rename(&legacy_path, &backup_path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
// Load the migrated wallet as active
|
||||||
|
self.switch_wallet(&wallet_id).await?;
|
||||||
|
|
||||||
|
Ok(Some(wallet_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get wallet count
|
||||||
|
pub async fn wallet_count(&self) -> usize {
|
||||||
|
let index = self.index.read().await;
|
||||||
|
index.wallets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any wallets exist
|
||||||
|
pub async fn has_wallets(&self) -> bool {
|
||||||
|
let index = self.index.read().await;
|
||||||
|
!index.wallets.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WalletManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current timestamp
|
||||||
|
fn current_timestamp() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
283
apps/desktop-wallet/src-tauri/src/watch_only.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
//! Watch-only address management
|
||||||
|
//!
|
||||||
|
//! Allows monitoring addresses without holding private keys.
|
||||||
|
//! Useful for:
|
||||||
|
//! - Monitoring cold storage addresses
|
||||||
|
//! - Tracking other wallets
|
||||||
|
//! - Observing addresses before import
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
/// Watch-only addresses file name
|
||||||
|
const WATCH_ONLY_FILE: &str = "watch_only.json";
|
||||||
|
|
||||||
|
/// A watch-only address entry
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WatchOnlyAddress {
|
||||||
|
/// Bech32 encoded address
|
||||||
|
pub address: String,
|
||||||
|
/// User-defined label
|
||||||
|
pub label: String,
|
||||||
|
/// Network (mainnet/testnet)
|
||||||
|
pub network: String,
|
||||||
|
/// When this address was added
|
||||||
|
pub added_at: i64,
|
||||||
|
/// Optional notes
|
||||||
|
pub notes: Option<String>,
|
||||||
|
/// Tags for categorization
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Last known balance (cached)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached_balance: Option<u64>,
|
||||||
|
/// When balance was last updated
|
||||||
|
#[serde(default)]
|
||||||
|
pub balance_updated_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted watch-only addresses
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct WatchOnlyData {
|
||||||
|
/// Map of address -> entry
|
||||||
|
pub addresses: HashMap<String, WatchOnlyAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch-only address manager
|
||||||
|
pub struct WatchOnlyManager {
|
||||||
|
/// Data directory
|
||||||
|
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
/// Watch-only addresses
|
||||||
|
pub data: Arc<RwLock<WatchOnlyData>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchOnlyManager {
|
||||||
|
/// Create a new manager
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
data_dir: Arc::new(RwLock::new(None)),
|
||||||
|
data: Arc::new(RwLock::new(WatchOnlyData::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the data directory
|
||||||
|
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
|
||||||
|
tokio::fs::create_dir_all(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let mut data_dir = self.data_dir.write().await;
|
||||||
|
*data_dir = Some(path);
|
||||||
|
|
||||||
|
// Load existing data
|
||||||
|
drop(data_dir);
|
||||||
|
self.load().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the file path
|
||||||
|
async fn file_path(&self) -> Result<PathBuf> {
|
||||||
|
let data_dir = self.data_dir.read().await;
|
||||||
|
data_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join(WATCH_ONLY_FILE))
|
||||||
|
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load watch-only addresses from disk
|
||||||
|
pub async fn load(&self) -> Result<()> {
|
||||||
|
let path = self.file_path().await?;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = tokio::fs::read_to_string(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let loaded: WatchOnlyData = serde_json::from_str(&json)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
*data = loaded;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save watch-only addresses to disk
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let path = self.file_path().await?;
|
||||||
|
let data = self.data.read().await;
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&*data)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&path, json).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a watch-only address
|
||||||
|
pub async fn add_address(
|
||||||
|
&self,
|
||||||
|
address: String,
|
||||||
|
label: String,
|
||||||
|
network: String,
|
||||||
|
notes: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<WatchOnlyAddress> {
|
||||||
|
// Validate address format (basic check)
|
||||||
|
if !address.starts_with("synor1") && !address.starts_with("tsynor1") {
|
||||||
|
return Err(Error::Validation("Invalid address format".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
{
|
||||||
|
let data = self.data.read().await;
|
||||||
|
if data.addresses.contains_key(&address) {
|
||||||
|
return Err(Error::Validation("Address already exists".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = WatchOnlyAddress {
|
||||||
|
address: address.clone(),
|
||||||
|
label,
|
||||||
|
network,
|
||||||
|
added_at: current_timestamp(),
|
||||||
|
notes,
|
||||||
|
tags,
|
||||||
|
cached_balance: None,
|
||||||
|
balance_updated_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
data.addresses.insert(address.clone(), entry.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a watch-only address
|
||||||
|
pub async fn update_address(
|
||||||
|
&self,
|
||||||
|
address: &str,
|
||||||
|
label: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
) -> Result<WatchOnlyAddress> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
let entry = data.addresses.get_mut(address)
|
||||||
|
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||||
|
|
||||||
|
if let Some(l) = label {
|
||||||
|
entry.label = l;
|
||||||
|
}
|
||||||
|
if let Some(n) = notes {
|
||||||
|
entry.notes = Some(n);
|
||||||
|
}
|
||||||
|
if let Some(t) = tags {
|
||||||
|
entry.tags = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = entry.clone();
|
||||||
|
drop(data);
|
||||||
|
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a watch-only address
|
||||||
|
pub async fn remove_address(&self, address: &str) -> Result<()> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
if data.addresses.remove(address).is_none() {
|
||||||
|
return Err(Error::NotFound("Watch-only address not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(data);
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all watch-only addresses
|
||||||
|
pub async fn list_addresses(&self) -> Vec<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
let mut addresses: Vec<WatchOnlyAddress> = data.addresses.values().cloned().collect();
|
||||||
|
addresses.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific watch-only address
|
||||||
|
pub async fn get_address(&self, address: &str) -> Option<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.get(address).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cached balance for an address
|
||||||
|
pub async fn update_balance(&self, address: &str, balance: u64) -> Result<()> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
let entry = data.addresses.get_mut(address)
|
||||||
|
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||||
|
|
||||||
|
entry.cached_balance = Some(balance);
|
||||||
|
entry.balance_updated_at = Some(current_timestamp());
|
||||||
|
|
||||||
|
drop(data);
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get addresses by tag
|
||||||
|
pub async fn get_addresses_by_tag(&self, tag: &str) -> Vec<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.values()
|
||||||
|
.filter(|a| a.tags.contains(&tag.to_string()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all unique tags
|
||||||
|
pub async fn get_all_tags(&self) -> Vec<String> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
let mut tags: Vec<String> = data.addresses.values()
|
||||||
|
.flat_map(|a| a.tags.clone())
|
||||||
|
.collect();
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total count
|
||||||
|
pub async fn count(&self) -> usize {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WatchOnlyManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current timestamp
|
||||||
|
fn current_timestamp() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Synor Wallet",
|
"productName": "Synor Wallet",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"identifier": "io.synor.wallet",
|
"identifier": "io.synor.wallet",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|
@ -34,17 +34,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"fs": {
|
|
||||||
"scope": {
|
|
||||||
"allow": ["$APPDATA/*", "$HOME/.synor/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"store": {},
|
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
},
|
||||||
"dialog": {},
|
|
||||||
"clipboard-manager": {},
|
|
||||||
"updater": {
|
"updater": {
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"
|
"https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"
|
||||||
|
|
@ -53,8 +45,7 @@
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "passive"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notification": {}
|
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,75 @@ import { useTrayEvents } from './hooks/useTrayEvents';
|
||||||
import { useNodeEvents } from './hooks/useNodeEvents';
|
import { useNodeEvents } from './hooks/useNodeEvents';
|
||||||
import { useMiningEvents } from './hooks/useMiningEvents';
|
import { useMiningEvents } from './hooks/useMiningEvents';
|
||||||
|
|
||||||
// Pages
|
// Onboarding Pages
|
||||||
import Welcome from './pages/Welcome';
|
import Welcome from './pages/Welcome';
|
||||||
import CreateWallet from './pages/CreateWallet';
|
import CreateWallet from './pages/CreateWallet';
|
||||||
import ImportWallet from './pages/ImportWallet';
|
import ImportWallet from './pages/ImportWallet';
|
||||||
import Unlock from './pages/Unlock';
|
import Unlock from './pages/Unlock';
|
||||||
|
|
||||||
|
// Core Wallet Pages
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Send from './pages/Send';
|
import Send from './pages/Send';
|
||||||
import Receive from './pages/Receive';
|
import Receive from './pages/Receive';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
// Node & Mining Pages
|
||||||
import NodeDashboard from './pages/Node/NodeDashboard';
|
import NodeDashboard from './pages/Node/NodeDashboard';
|
||||||
import MiningDashboard from './pages/Mining/MiningDashboard';
|
import MiningDashboard from './pages/Mining/MiningDashboard';
|
||||||
|
|
||||||
|
// Smart Contract Pages
|
||||||
import ContractsDashboard from './pages/Contracts/ContractsDashboard';
|
import ContractsDashboard from './pages/Contracts/ContractsDashboard';
|
||||||
import TokensDashboard from './pages/Tokens/TokensDashboard';
|
import TokensDashboard from './pages/Tokens/TokensDashboard';
|
||||||
import NftsDashboard from './pages/NFTs/NftsDashboard';
|
import NftsDashboard from './pages/NFTs/NftsDashboard';
|
||||||
|
|
||||||
|
// DeFi Pages
|
||||||
|
import StakingDashboard from './pages/Staking/StakingDashboard';
|
||||||
|
import SwapDashboard from './pages/Swap/SwapDashboard';
|
||||||
|
import MarketDashboard from './pages/Market/MarketDashboard';
|
||||||
|
|
||||||
|
// Infrastructure Pages
|
||||||
|
import StorageDashboard from './pages/Storage/StorageDashboard';
|
||||||
|
import HostingDashboard from './pages/Hosting/HostingDashboard';
|
||||||
|
import ComputeDashboard from './pages/Compute/ComputeDashboard';
|
||||||
|
import DatabaseDashboard from './pages/Database/DatabaseDashboard';
|
||||||
|
|
||||||
|
// Privacy & Bridge Pages
|
||||||
|
import PrivacyDashboard from './pages/Privacy/PrivacyDashboard';
|
||||||
|
import BridgeDashboard from './pages/Bridge/BridgeDashboard';
|
||||||
|
|
||||||
|
// Governance & L2 Pages
|
||||||
|
import GovernanceDashboard from './pages/Governance/GovernanceDashboard';
|
||||||
|
import ZKDashboard from './pages/ZK/ZKDashboard';
|
||||||
|
|
||||||
|
// Tools Pages
|
||||||
|
import DAppBrowser from './pages/DApps/DAppBrowser';
|
||||||
|
import AddressBookPage from './pages/AddressBook/AddressBookPage';
|
||||||
|
import MultisigDashboard from './pages/Multisig/MultisigDashboard';
|
||||||
|
import HardwareWalletPage from './pages/Hardware/HardwareWalletPage';
|
||||||
|
import QRScannerPage from './pages/QRScanner/QRScannerPage';
|
||||||
|
import BackupPage from './pages/Backup/BackupPage';
|
||||||
|
import WatchOnlyDashboard from './pages/WatchOnly/WatchOnlyDashboard';
|
||||||
|
import BatchSendDashboard from './pages/BatchSend/BatchSendDashboard';
|
||||||
|
import FeeAnalyticsDashboard from './pages/FeeAnalytics/FeeAnalyticsDashboard';
|
||||||
|
import VaultsDashboard from './pages/Vaults/VaultsDashboard';
|
||||||
|
import RecoveryDashboard from './pages/Recovery/RecoveryDashboard';
|
||||||
|
import DecoyDashboard from './pages/Decoy/DecoyDashboard';
|
||||||
|
import MixerDashboard from './pages/Mixer/MixerDashboard';
|
||||||
|
import LimitOrdersDashboard from './pages/LimitOrders/LimitOrdersDashboard';
|
||||||
|
import YieldDashboard from './pages/Yield/YieldDashboard';
|
||||||
|
import PortfolioDashboard from './pages/Portfolio/PortfolioDashboard';
|
||||||
|
import AlertsDashboard from './pages/Alerts/AlertsDashboard';
|
||||||
|
import CliDashboard from './pages/CLI/CliDashboard';
|
||||||
|
import RpcProfilesDashboard from './pages/RpcProfiles/RpcProfilesDashboard';
|
||||||
|
import TxBuilderDashboard from './pages/TxBuilder/TxBuilderDashboard';
|
||||||
|
import PluginsDashboard from './pages/Plugins/PluginsDashboard';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isUnlocked } = useWalletStore();
|
||||||
|
return isUnlocked ? <>{children}</> : <Navigate to="/unlock" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isInitialized, isUnlocked } = useWalletStore();
|
const { isInitialized, isUnlocked } = useWalletStore();
|
||||||
|
|
||||||
|
|
@ -67,64 +120,355 @@ function App() {
|
||||||
|
|
||||||
{/* Protected routes (require unlocked wallet) */}
|
{/* Protected routes (require unlocked wallet) */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
|
{/* Core Wallet */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
isUnlocked ? <Dashboard /> : <Navigate to="/unlock" replace />
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/send"
|
path="/send"
|
||||||
element={
|
element={
|
||||||
isUnlocked ? <Send /> : <Navigate to="/unlock" replace />
|
<ProtectedRoute>
|
||||||
|
<Send />
|
||||||
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/receive"
|
path="/receive"
|
||||||
element={
|
element={
|
||||||
isUnlocked ? <Receive /> : <Navigate to="/unlock" replace />
|
<ProtectedRoute>
|
||||||
|
<Receive />
|
||||||
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/history"
|
path="/history"
|
||||||
element={
|
element={
|
||||||
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
|
<ProtectedRoute>
|
||||||
}
|
<History />
|
||||||
/>
|
</ProtectedRoute>
|
||||||
<Route
|
|
||||||
path="/node"
|
|
||||||
element={
|
|
||||||
isUnlocked ? <NodeDashboard /> : <Navigate to="/unlock" replace />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/mining"
|
|
||||||
element={
|
|
||||||
isUnlocked ? <MiningDashboard /> : <Navigate to="/unlock" replace />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/contracts"
|
|
||||||
element={
|
|
||||||
isUnlocked ? <ContractsDashboard /> : <Navigate to="/unlock" replace />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/tokens"
|
|
||||||
element={
|
|
||||||
isUnlocked ? <TokensDashboard /> : <Navigate to="/unlock" replace />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/nfts"
|
|
||||||
element={
|
|
||||||
isUnlocked ? <NftsDashboard /> : <Navigate to="/unlock" replace />
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
isUnlocked ? <Settings /> : <Navigate to="/unlock" replace />
|
<ProtectedRoute>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node & Mining */}
|
||||||
|
<Route
|
||||||
|
path="/node"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<NodeDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mining"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MiningDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Smart Contracts */}
|
||||||
|
<Route
|
||||||
|
path="/contracts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ContractsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tokens"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TokensDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/nfts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<NftsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DeFi */}
|
||||||
|
<Route
|
||||||
|
path="/staking"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<StakingDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/swap"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SwapDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/market"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MarketDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Infrastructure */}
|
||||||
|
<Route
|
||||||
|
path="/storage"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<StorageDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/hosting"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HostingDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/compute"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ComputeDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/database"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DatabaseDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Privacy & Bridge */}
|
||||||
|
<Route
|
||||||
|
path="/privacy"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PrivacyDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bridge"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BridgeDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Governance & L2 */}
|
||||||
|
<Route
|
||||||
|
path="/governance"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GovernanceDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/zk"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ZKDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
<Route
|
||||||
|
path="/dapps"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DAppBrowser />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/addressbook"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AddressBookPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/multisig"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MultisigDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/hardware"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HardwareWalletPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/qr"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<QRScannerPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/backup"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BackupPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/watch-only"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<WatchOnlyDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/batch-send"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BatchSendDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fee-analytics"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FeeAnalyticsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/vaults"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<VaultsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/recovery"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RecoveryDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/decoy"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DecoyDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mixer"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MixerDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/limit-orders"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LimitOrdersDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/yield"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<YieldDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolio"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfolioDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/alerts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AlertsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cli"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CliDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/rpc-profiles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RpcProfilesDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tx-builder"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TxBuilderDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/plugins"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PluginsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
270
apps/desktop-wallet/src/components/Animations.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade in animation wrapper
|
||||||
|
*/
|
||||||
|
export function FadeIn({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
duration = 300,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setVisible(true), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transition: `opacity ${duration}ms ease-in-out`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide in from direction
|
||||||
|
*/
|
||||||
|
export function SlideIn({
|
||||||
|
children,
|
||||||
|
direction = 'left',
|
||||||
|
delay = 0,
|
||||||
|
duration = 300,
|
||||||
|
distance = 20,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
direction?: 'left' | 'right' | 'up' | 'down';
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
distance?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setVisible(true), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
const transforms = {
|
||||||
|
left: `translateX(${visible ? 0 : -distance}px)`,
|
||||||
|
right: `translateX(${visible ? 0 : distance}px)`,
|
||||||
|
up: `translateY(${visible ? 0 : -distance}px)`,
|
||||||
|
down: `translateY(${visible ? 0 : distance}px)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: transforms[direction],
|
||||||
|
transition: `all ${duration}ms ease-out`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale in animation
|
||||||
|
*/
|
||||||
|
export function ScaleIn({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
duration = 200,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setVisible(true), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: `scale(${visible ? 1 : 0.95})`,
|
||||||
|
transition: `all ${duration}ms ease-out`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stagger children animations
|
||||||
|
*/
|
||||||
|
export function StaggerChildren({
|
||||||
|
children,
|
||||||
|
staggerDelay = 50,
|
||||||
|
initialDelay = 0,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode[];
|
||||||
|
staggerDelay?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{children.map((child, index) => (
|
||||||
|
<FadeIn key={index} delay={initialDelay + index * staggerDelay}>
|
||||||
|
{child}
|
||||||
|
</FadeIn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulse animation (for attention)
|
||||||
|
*/
|
||||||
|
export function Pulse({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`animate-pulse ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounce animation
|
||||||
|
*/
|
||||||
|
export function Bounce({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`animate-bounce ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number counter animation
|
||||||
|
*/
|
||||||
|
export function CountUp({
|
||||||
|
end,
|
||||||
|
start = 0,
|
||||||
|
duration = 1000,
|
||||||
|
decimals = 0,
|
||||||
|
prefix = '',
|
||||||
|
suffix = '',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
end: number;
|
||||||
|
start?: number;
|
||||||
|
duration?: number;
|
||||||
|
decimals?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [count, setCount] = useState(start);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const diff = end - start;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Ease out cubic
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = start + diff * eased;
|
||||||
|
|
||||||
|
setCount(current);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [end, start, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{prefix}
|
||||||
|
{count.toFixed(decimals)}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typing animation for text
|
||||||
|
*/
|
||||||
|
export function TypeWriter({
|
||||||
|
text,
|
||||||
|
speed = 50,
|
||||||
|
delay = 0,
|
||||||
|
className = '',
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
speed?: number;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}) {
|
||||||
|
const [displayed, setDisplayed] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let index = 0;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDisplayed(text.slice(0, index + 1));
|
||||||
|
index++;
|
||||||
|
if (index >= text.length) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
}, speed);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [text, speed, delay, onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{displayed}
|
||||||
|
<span className="animate-pulse">|</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
apps/desktop-wallet/src/components/CreateWalletModal.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Eye, EyeOff, Copy, Check, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useWalletManagerStore } from '../store/walletManager';
|
||||||
|
|
||||||
|
interface CreateWalletModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateWalletModal({ onClose }: CreateWalletModalProps) {
|
||||||
|
const [step, setStep] = useState<'form' | 'mnemonic' | 'verify'>('form');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isTestnet, setIsTestnet] = useState(true);
|
||||||
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [mnemonicConfirmed, setMnemonicConfirmed] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const { createWallet, isLoading } = useWalletManagerStore();
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Please enter a wallet label');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createWallet(label.trim(), password, isTestnet);
|
||||||
|
setMnemonic(result.mnemonic);
|
||||||
|
setAddress(result.address);
|
||||||
|
setStep('mnemonic');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create wallet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyMnemonic = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(mnemonic);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback for clipboard API failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
if (!mnemonicConfirmed) {
|
||||||
|
setError('Please confirm you have saved your recovery phrase');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{step === 'form' && 'Create New Wallet'}
|
||||||
|
{step === 'mnemonic' && 'Backup Recovery Phrase'}
|
||||||
|
{step === 'verify' && 'Verify Backup'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{step === 'form' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Wallet Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Wallet Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g., Main Wallet, Trading, Savings"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Network
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(true)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(false)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
!isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mainnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'mnemonic' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-500 mt-0.5" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-300 font-medium text-sm">
|
||||||
|
Write down this recovery phrase
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-300/70 text-xs mt-1">
|
||||||
|
This is the ONLY way to recover your wallet. Store it securely
|
||||||
|
and never share it with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mnemonic Display */}
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{mnemonic.split(' ').map((word, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 bg-gray-900/50 rounded px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-gray-600 text-xs w-4">{index + 1}.</span>
|
||||||
|
<span className="text-white text-sm font-mono">{word}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMnemonic}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
<span className="text-green-400">Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={16} />
|
||||||
|
Copy to clipboard
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
<p className="text-gray-500">Your wallet address:</p>
|
||||||
|
<p className="text-synor-400 font-mono text-xs mt-1 break-all">
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Checkbox */}
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mnemonicConfirmed}
|
||||||
|
onChange={(e) => setMnemonicConfirmed(e.target.checked)}
|
||||||
|
className="mt-1 w-4 h-4 rounded border-gray-600 bg-gray-800 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
I have securely saved my recovery phrase and understand that
|
||||||
|
losing it means losing access to my funds forever.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{step === 'form' && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Wallet'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 'mnemonic' && (
|
||||||
|
<button
|
||||||
|
onClick={handleFinish}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/desktop-wallet/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error Boundary component for graceful error handling
|
||||||
|
*
|
||||||
|
* Catches JavaScript errors anywhere in the child component tree
|
||||||
|
* and displays a fallback UI instead of crashing the whole app.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 bg-red-900/10 rounded-xl border border-red-800/50">
|
||||||
|
<AlertTriangle className="text-red-400 mb-4" size={48} />
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-gray-400 text-center mb-4 max-w-md">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOC to wrap any component with error boundary
|
||||||
|
*/
|
||||||
|
export function withErrorBoundary<P extends object>(
|
||||||
|
WrappedComponent: React.ComponentType<P>,
|
||||||
|
fallback?: ReactNode
|
||||||
|
) {
|
||||||
|
return function WithErrorBoundary(props: P) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={fallback}>
|
||||||
|
<WrappedComponent {...props} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
263
apps/desktop-wallet/src/components/ImportWalletModal.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Eye, EyeOff, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
import { useWalletManagerStore } from '../store/walletManager';
|
||||||
|
|
||||||
|
interface ImportWalletModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportWalletModal({ onClose }: ImportWalletModalProps) {
|
||||||
|
const [step, setStep] = useState<'form' | 'success'>('form');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isTestnet, setIsTestnet] = useState(true);
|
||||||
|
const [importedAddress, setImportedAddress] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const { importWallet, isLoading } = useWalletManagerStore();
|
||||||
|
|
||||||
|
// Validate mnemonic word count
|
||||||
|
const mnemonicWords = mnemonic.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const isValidWordCount = mnemonicWords.length === 12 || mnemonicWords.length === 24;
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Please enter a wallet label');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidWordCount) {
|
||||||
|
setError('Recovery phrase must be 12 or 24 words');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = await importWallet(
|
||||||
|
label.trim(),
|
||||||
|
mnemonic.trim().toLowerCase(),
|
||||||
|
password,
|
||||||
|
isTestnet
|
||||||
|
);
|
||||||
|
setImportedAddress(address);
|
||||||
|
setStep('success');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import wallet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{step === 'form' ? 'Import Wallet' : 'Wallet Imported'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{step === 'form' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Wallet Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Wallet Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g., Imported Wallet, Cold Storage"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recovery Phrase */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Recovery Phrase
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={mnemonic}
|
||||||
|
onChange={(e) => setMnemonic(e.target.value)}
|
||||||
|
placeholder="Enter your 12 or 24 word recovery phrase..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5">
|
||||||
|
Words: {mnemonicWords.length}
|
||||||
|
{mnemonicWords.length > 0 && !isValidWordCount && (
|
||||||
|
<span className="text-yellow-500 ml-2">
|
||||||
|
(needs 12 or 24 words)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isValidWordCount && (
|
||||||
|
<span className="text-green-500 ml-2">
|
||||||
|
<CheckCircle size={12} className="inline" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Create a password for this wallet"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Network
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(true)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(false)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
!isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mainnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Warning */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="text-yellow-500 mt-0.5" size={14} />
|
||||||
|
<p className="text-yellow-300/80 text-xs">
|
||||||
|
Make sure you're entering your recovery phrase on a secure device.
|
||||||
|
Never share your phrase with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle size={32} className="text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Wallet Imported!
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Your wallet has been successfully imported and is ready to use.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Wallet Address</p>
|
||||||
|
<p className="text-synor-400 font-mono text-sm break-all">
|
||||||
|
{importedAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
{step === 'form' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isLoading || !isValidWordCount}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Importing...' : 'Import Wallet'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'success' && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,24 +13,108 @@ import {
|
||||||
FileCode2,
|
FileCode2,
|
||||||
Coins,
|
Coins,
|
||||||
Image,
|
Image,
|
||||||
|
PiggyBank,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Users,
|
||||||
|
Globe,
|
||||||
|
Usb,
|
||||||
|
Shield,
|
||||||
|
BarChart3,
|
||||||
|
QrCode,
|
||||||
|
HardDrive,
|
||||||
|
Cloud,
|
||||||
|
Globe2,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
EyeOff,
|
||||||
|
GitBranch,
|
||||||
|
Vote,
|
||||||
|
Layers,
|
||||||
|
Eye,
|
||||||
|
ListPlus,
|
||||||
|
Activity,
|
||||||
|
Timer,
|
||||||
|
ShieldCheck,
|
||||||
|
// Phase 7-16 icons
|
||||||
|
UserX,
|
||||||
|
Shuffle,
|
||||||
|
ArrowUpDown,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Bell,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
import { useNodeStore } from '../store/node';
|
import { useNodeStore } from '../store/node';
|
||||||
import { useMiningStore, formatHashrate } from '../store/mining';
|
import { useMiningStore, formatHashrate } from '../store/mining';
|
||||||
|
import { NotificationsBell } from './NotificationsPanel';
|
||||||
|
import { WalletSelector } from './WalletSelector';
|
||||||
|
import { CreateWalletModal } from './CreateWalletModal';
|
||||||
|
import { ImportWalletModal } from './ImportWalletModal';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ to: '/send', label: 'Send', icon: Send },
|
{ to: '/send', label: 'Send', icon: Send },
|
||||||
|
{ to: '/batch-send', label: 'Batch Send', icon: ListPlus },
|
||||||
{ to: '/receive', label: 'Receive', icon: Download },
|
{ to: '/receive', label: 'Receive', icon: Download },
|
||||||
{ to: '/history', label: 'History', icon: History },
|
{ to: '/history', label: 'History', icon: History },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const defiNavItems = [
|
||||||
|
{ to: '/staking', label: 'Staking', icon: PiggyBank },
|
||||||
|
{ to: '/swap', label: 'Swap', icon: ArrowLeftRight },
|
||||||
|
{ to: '/market', label: 'Market', icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
const advancedNavItems = [
|
const advancedNavItems = [
|
||||||
{ to: '/node', label: 'Node', icon: Server },
|
{ to: '/node', label: 'Node', icon: Server },
|
||||||
{ to: '/mining', label: 'Mining', icon: Hammer },
|
{ to: '/mining', label: 'Mining', icon: Hammer },
|
||||||
{ to: '/contracts', label: 'Contracts', icon: FileCode2 },
|
{ to: '/contracts', label: 'Contracts', icon: FileCode2 },
|
||||||
{ to: '/tokens', label: 'Tokens', icon: Coins },
|
{ to: '/tokens', label: 'Tokens', icon: Coins },
|
||||||
{ to: '/nfts', label: 'NFTs', icon: Image },
|
{ to: '/nfts', label: 'NFTs', icon: Image },
|
||||||
|
];
|
||||||
|
|
||||||
|
const infrastructureNavItems = [
|
||||||
|
{ to: '/storage', label: 'Storage', icon: Cloud },
|
||||||
|
{ to: '/hosting', label: 'Hosting', icon: Globe2 },
|
||||||
|
{ to: '/compute', label: 'Compute', icon: Cpu },
|
||||||
|
{ to: '/database', label: 'Database', icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
const privacyBridgeNavItems = [
|
||||||
|
{ to: '/privacy', label: 'Privacy', icon: EyeOff },
|
||||||
|
{ to: '/bridge', label: 'Bridge', icon: GitBranch },
|
||||||
|
];
|
||||||
|
|
||||||
|
const governanceNavItems = [
|
||||||
|
{ to: '/governance', label: 'Governance', icon: Vote },
|
||||||
|
{ to: '/zk', label: 'ZK-Rollup', icon: Layers },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolsNavItems = [
|
||||||
|
{ to: '/watch-only', label: 'Watch-Only', icon: Eye },
|
||||||
|
{ to: '/fee-analytics', label: 'Fee Analytics', icon: Activity },
|
||||||
|
{ to: '/vaults', label: 'Time Vaults', icon: Timer },
|
||||||
|
{ to: '/recovery', label: 'Recovery', icon: ShieldCheck },
|
||||||
|
{ to: '/decoy', label: 'Decoy Wallets', icon: UserX },
|
||||||
|
{ to: '/mixer', label: 'Mixer', icon: Shuffle },
|
||||||
|
{ to: '/limit-orders', label: 'Limit Orders', icon: ArrowUpDown },
|
||||||
|
{ to: '/yield', label: 'Yield', icon: TrendingUp },
|
||||||
|
{ to: '/portfolio', label: 'Portfolio', icon: PieChart },
|
||||||
|
{ to: '/alerts', label: 'Alerts', icon: Bell },
|
||||||
|
{ to: '/cli', label: 'CLI', icon: Terminal },
|
||||||
|
{ to: '/rpc-profiles', label: 'RPC Profiles', icon: Server },
|
||||||
|
{ to: '/tx-builder', label: 'Tx Builder', icon: Wrench },
|
||||||
|
{ to: '/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
|
{ to: '/dapps', label: 'DApps', icon: Globe },
|
||||||
|
{ to: '/addressbook', label: 'Address Book', icon: Users },
|
||||||
|
{ to: '/multisig', label: 'Multi-sig', icon: Shield },
|
||||||
|
{ to: '/hardware', label: 'Hardware', icon: Usb },
|
||||||
|
{ to: '/qr', label: 'QR Code', icon: QrCode },
|
||||||
|
{ to: '/backup', label: 'Backup', icon: HardDrive },
|
||||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -39,62 +123,30 @@ export default function Layout() {
|
||||||
const nodeStatus = useNodeStore((state) => state.status);
|
const nodeStatus = useNodeStore((state) => state.status);
|
||||||
const miningStatus = useMiningStore((state) => state.status);
|
const miningStatus = useMiningStore((state) => state.status);
|
||||||
|
|
||||||
|
// Modal state for multi-wallet management
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
const handleLock = async () => {
|
const handleLock = async () => {
|
||||||
await lockWallet();
|
await lockWallet();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderNavSection = (
|
||||||
<div className="flex h-full">
|
items: typeof navItems,
|
||||||
{/* Sidebar */}
|
title?: string
|
||||||
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
) => (
|
||||||
{/* Balance display */}
|
<>
|
||||||
<div className="p-6 border-b border-gray-800">
|
{title && (
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
|
||||||
Balance
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-white">
|
|
||||||
{balance?.balanceHuman || '0 SYN'}
|
|
||||||
</p>
|
|
||||||
{balance?.pending ? (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
+ {(balance.pending / 100_000_000).toFixed(8)} SYN pending
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
|
||||||
{navItems.map(({ to, label, icon: Icon }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-synor-600 text-white'
|
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={20} />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="pt-4 pb-2">
|
<div className="pt-4 pb-2">
|
||||||
<p className="px-4 text-xs text-gray-600 uppercase tracking-wider">
|
<p className="px-4 text-xs text-gray-600 uppercase tracking-wider">{title}</p>
|
||||||
Advanced
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Advanced nav items with status indicators */}
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
{advancedNavItems.map(({ to, label, icon: Icon }) => (
|
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center justify-between px-4 py-3 rounded-lg transition-colors ${
|
`flex items-center justify-between px-4 py-2.5 rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-synor-600 text-white'
|
? 'bg-synor-600 text-white'
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
|
@ -102,7 +154,7 @@ export default function Layout() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Icon size={20} />
|
<Icon size={18} />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{/* Status indicators */}
|
{/* Status indicators */}
|
||||||
|
|
@ -116,24 +168,67 @@ export default function Layout() {
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
|
{/* Wallet Selector */}
|
||||||
|
<div className="p-3 border-b border-gray-800">
|
||||||
|
<WalletSelector
|
||||||
|
onCreateWallet={() => setShowCreateModal(true)}
|
||||||
|
onImportWallet={() => setShowImportModal(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance display */}
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{balance?.balanceHuman || '0 SYN'}
|
||||||
|
</p>
|
||||||
|
{balance?.pending ? (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
+ {(balance.pending / 100_000_000).toFixed(8)} pending
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
||||||
|
{renderNavSection(navItems)}
|
||||||
|
{renderNavSection(defiNavItems, 'DeFi')}
|
||||||
|
{renderNavSection(advancedNavItems, 'Advanced')}
|
||||||
|
{renderNavSection(infrastructureNavItems, 'Infrastructure')}
|
||||||
|
{renderNavSection(privacyBridgeNavItems, 'Privacy & Bridge')}
|
||||||
|
{renderNavSection(governanceNavItems, 'Governance')}
|
||||||
|
{renderNavSection(toolsNavItems, 'Tools')}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-gray-800 space-y-2">
|
<div className="p-3 border-t border-gray-800 space-y-2">
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<span className="text-sm text-gray-400">Notifications</span>
|
||||||
|
<NotificationsBell />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Node status */}
|
{/* Node status */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 text-sm">
|
<div className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||||
{nodeStatus.isConnected ? (
|
{nodeStatus.isConnected ? (
|
||||||
<>
|
<>
|
||||||
<Wifi size={16} className="text-green-400" />
|
<Wifi size={14} className="text-green-400" />
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400 text-xs">
|
||||||
{nodeStatus.network || 'Connected'}
|
{nodeStatus.network || 'Connected'}
|
||||||
{nodeStatus.isSyncing && ' (Syncing...)'}
|
{nodeStatus.isSyncing && ' (Syncing)'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WifiOff size={16} className="text-red-400" />
|
<WifiOff size={14} className="text-red-400" />
|
||||||
<span className="text-gray-400">Not Connected</span>
|
<span className="text-gray-400 text-xs">Disconnected</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,9 +243,9 @@ export default function Layout() {
|
||||||
{/* Lock button */}
|
{/* Lock button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleLock}
|
onClick={handleLock}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<Lock size={16} />
|
<Lock size={14} />
|
||||||
Lock Wallet
|
Lock Wallet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,6 +255,14 @@ export default function Layout() {
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Multi-wallet Modals */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateWalletModal onClose={() => setShowCreateModal(false)} />
|
||||||
|
)}
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportWalletModal onClose={() => setShowImportModal(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
189
apps/desktop-wallet/src/components/LoadingStates.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { RefreshCw, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spinning loader component
|
||||||
|
*/
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = 24,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
size={size}
|
||||||
|
className={`animate-spin text-synor-400 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page loading overlay
|
||||||
|
*/
|
||||||
|
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-950/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LoadingSpinner size={48} />
|
||||||
|
<p className="text-gray-300 text-lg">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline loading state
|
||||||
|
*/
|
||||||
|
export function LoadingInline({
|
||||||
|
message = 'Loading...',
|
||||||
|
size = 'md',
|
||||||
|
}: {
|
||||||
|
message?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}) {
|
||||||
|
const sizes = {
|
||||||
|
sm: { icon: 16, text: 'text-sm' },
|
||||||
|
md: { icon: 20, text: 'text-base' },
|
||||||
|
lg: { icon: 24, text: 'text-lg' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, text } = sizes[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<LoadingSpinner size={icon} />
|
||||||
|
<span className={text}>{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button with loading state
|
||||||
|
*/
|
||||||
|
export function LoadingButton({
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-synor-600 hover:bg-synor-700 text-white',
|
||||||
|
secondary: 'bg-gray-700 hover:bg-gray-600 text-white',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium
|
||||||
|
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${variants[variant]} ${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loading placeholder
|
||||||
|
*/
|
||||||
|
export function Skeleton({
|
||||||
|
width = '100%',
|
||||||
|
height = '1rem',
|
||||||
|
rounded = 'rounded',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
rounded?: 'rounded' | 'rounded-md' | 'rounded-lg' | 'rounded-xl' | 'rounded-full';
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`animate-pulse bg-gray-700/50 ${rounded} ${className}`}
|
||||||
|
style={{
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
height: typeof height === 'number' ? `${height}px` : height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card skeleton for loading states
|
||||||
|
*/
|
||||||
|
export function CardSkeleton({ lines = 3 }: { lines?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<Skeleton width="60%" height="1.25rem" className="mb-3" />
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
width={i === lines - 1 ? '40%' : '100%'}
|
||||||
|
height="0.875rem"
|
||||||
|
className={i < lines - 1 ? 'mb-2' : ''}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table skeleton for loading states
|
||||||
|
*/
|
||||||
|
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex gap-4 p-3 bg-gray-900 rounded-lg">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<Skeleton key={i} width={`${100 / columns}%`} height="1rem" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||||
|
<div key={rowIdx} className="flex gap-4 p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
{Array.from({ length: columns }).map((_, colIdx) => (
|
||||||
|
<Skeleton key={colIdx} width={`${100 / columns}%`} height="1rem" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats card skeleton
|
||||||
|
*/
|
||||||
|
export function StatsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-${Math.min(count, 4)} gap-4`}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<Skeleton width="40%" height="0.75rem" className="mb-2" />
|
||||||
|
<Skeleton width="70%" height="1.5rem" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
apps/desktop-wallet/src/components/NotificationsPanel.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
|
Hammer,
|
||||||
|
Coins,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useNotificationsStore,
|
||||||
|
NotificationType,
|
||||||
|
requestNotificationPermission,
|
||||||
|
} from '../store/notifications';
|
||||||
|
|
||||||
|
interface NotificationsPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<NotificationType, React.ReactNode> = {
|
||||||
|
transaction: <Send size={16} className="text-blue-400" />,
|
||||||
|
mining: <Hammer size={16} className="text-yellow-400" />,
|
||||||
|
staking: <Coins size={16} className="text-purple-400" />,
|
||||||
|
system: <Info size={16} className="text-gray-400" />,
|
||||||
|
price: <AlertCircle size={16} className="text-green-400" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationsPanel({ isOpen, onClose }: NotificationsPanelProps) {
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
preferences,
|
||||||
|
unreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
removeNotification,
|
||||||
|
clearAll,
|
||||||
|
updatePreferences,
|
||||||
|
} = useNotificationsStore();
|
||||||
|
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const handleEnableNotifications = async () => {
|
||||||
|
const granted = await requestNotificationPermission();
|
||||||
|
if (granted) {
|
||||||
|
updatePreferences({ enabled: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
if (diff < 60000) return 'Just now';
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="absolute right-4 top-16 w-96 max-h-[70vh] bg-gray-900 rounded-xl border border-gray-800 shadow-xl overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell size={20} className="text-synor-400" />
|
||||||
|
<h2 className="font-semibold text-white">Notifications</h2>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="px-2 py-0.5 bg-synor-600 text-white text-xs rounded-full">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="p-4 border-b border-gray-800 bg-gray-800/50">
|
||||||
|
<h3 className="text-sm font-medium text-white mb-3">Notification Settings</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Enable Notifications</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
handleEnableNotifications();
|
||||||
|
} else {
|
||||||
|
updatePreferences({ enabled: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Transaction Alerts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.transactionAlerts}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePreferences({ transactionAlerts: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Mining Alerts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.miningAlerts}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePreferences({ miningAlerts: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Staking Alerts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.stakingAlerts}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePreferences({ stakingAlerts: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Price Alerts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.priceAlerts}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePreferences({ priceAlerts: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Sound</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.soundEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePreferences({ soundEnabled: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
className="text-sm text-synor-400 hover:text-synor-300"
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-sm text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={`p-4 hover:bg-gray-800/50 transition-colors ${
|
||||||
|
!notification.read ? 'bg-synor-600/5' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => markAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
{TYPE_ICONS[notification.type]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4
|
||||||
|
className={`font-medium ${
|
||||||
|
notification.read ? 'text-gray-400' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeNotification(notification.id);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-600 mt-1 block">
|
||||||
|
{formatTime(notification.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!notification.read && (
|
||||||
|
<div className="w-2 h-2 bg-synor-400 rounded-full mt-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<BellOff size={32} className="mb-3 opacity-50" />
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bell button component to be used in the header/titlebar
|
||||||
|
export function NotificationsBell() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { unreadCount } = useNotificationsStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="relative p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Bell size={20} className="text-gray-400" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-synor-600 text-white text-xs rounded-full flex items-center justify-center">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<NotificationsPanel isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,34 @@
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getCurrentWindow, mockWindow, isTauri } from '../lib/tauri';
|
||||||
import { Minus, Square, X } from 'lucide-react';
|
import { Minus, Square, X } from 'lucide-react';
|
||||||
|
|
||||||
|
type AppWindow = typeof mockWindow;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom title bar for frameless window mode.
|
* Custom title bar for frameless window mode.
|
||||||
* Provides drag region and window controls.
|
* Provides drag region and window controls.
|
||||||
*/
|
*/
|
||||||
export default function TitleBar() {
|
export default function TitleBar() {
|
||||||
const appWindow = getCurrentWindow();
|
const [appWindow, setAppWindow] = useState<AppWindow>(mockWindow);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentWindow().then(setAppWindow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hide title bar in browser mode (no native window controls needed)
|
||||||
|
if (!isTauri()) {
|
||||||
|
return (
|
||||||
|
<div className="h-8 flex items-center justify-between bg-gray-900 border-b border-gray-800 select-none">
|
||||||
|
<div className="flex-1 h-full flex items-center px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded bg-gradient-to-br from-synor-400 to-synor-600" />
|
||||||
|
<span className="text-xs font-medium text-gray-400">Synor Wallet</span>
|
||||||
|
<span className="text-xs text-yellow-500 ml-2">(Browser Preview Mode)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
291
apps/desktop-wallet/src/components/WalletSelector.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Wallet,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Import,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
MoreVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useWalletManagerStore, WalletSummary } from '../store/walletManager';
|
||||||
|
|
||||||
|
interface WalletSelectorProps {
|
||||||
|
onCreateWallet?: () => void;
|
||||||
|
onImportWallet?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletSelector({ onCreateWallet, onImportWallet }: WalletSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState('');
|
||||||
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
wallets,
|
||||||
|
activeWalletId,
|
||||||
|
isLoading,
|
||||||
|
loadWallets,
|
||||||
|
switchWallet,
|
||||||
|
renameWallet,
|
||||||
|
deleteWallet,
|
||||||
|
} = useWalletManagerStore();
|
||||||
|
|
||||||
|
// Load wallets on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadWallets();
|
||||||
|
}, [loadWallets]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setMenuOpenId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeWallet = wallets.find((w) => w.id === activeWalletId);
|
||||||
|
|
||||||
|
const handleSwitch = async (walletId: string) => {
|
||||||
|
if (walletId === activeWalletId) return;
|
||||||
|
try {
|
||||||
|
await switchWallet(walletId);
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (wallet: WalletSummary, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingId(wallet.id);
|
||||||
|
setEditLabel(wallet.label);
|
||||||
|
setMenuOpenId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (walletId: string) => {
|
||||||
|
if (editLabel.trim()) {
|
||||||
|
try {
|
||||||
|
await renameWallet(walletId, editLabel.trim());
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (walletId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpenId(null);
|
||||||
|
|
||||||
|
if (walletId === activeWalletId) {
|
||||||
|
alert('Cannot delete the active wallet. Switch to another wallet first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm('Are you sure you want to delete this wallet? This action cannot be undone.')) {
|
||||||
|
try {
|
||||||
|
await deleteWallet(walletId);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = (walletId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpenId(menuOpenId === walletId ? null : walletId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate address for display
|
||||||
|
const truncateAddress = (address: string) => {
|
||||||
|
if (address.length <= 16) return address;
|
||||||
|
return `${address.slice(0, 8)}...${address.slice(-6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Selector Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors border border-gray-700/50"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-synor-600/20 flex items-center justify-center">
|
||||||
|
<Wallet size={16} className="text-synor-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{activeWallet?.label || 'No Wallet'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{activeWallet ? truncateAddress(activeWallet.primaryAddress) : 'Select a wallet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-gray-900 border border-gray-800 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||||
|
{/* Wallet List */}
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{wallets.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
No wallets yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
wallets.map((wallet) => (
|
||||||
|
<div
|
||||||
|
key={wallet.id}
|
||||||
|
className={`relative ${
|
||||||
|
wallet.id === activeWalletId ? 'bg-synor-600/10' : 'hover:bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editingId === wallet.id ? (
|
||||||
|
// Edit mode
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-synor-500"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit(wallet.id);
|
||||||
|
if (e.key === 'Escape') handleCancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(wallet.id)}
|
||||||
|
className="p-1 text-green-400 hover:bg-green-400/10 rounded"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="p-1 text-red-400 hover:bg-red-400/10 rounded"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Normal mode
|
||||||
|
<button
|
||||||
|
onClick={() => handleSwitch(wallet.id)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-left"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'bg-synor-600/30'
|
||||||
|
: 'bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Wallet
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'text-synor-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium truncate ${
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'text-synor-300'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wallet.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{truncateAddress(wallet.primaryAddress)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{wallet.id === activeWalletId && (
|
||||||
|
<span className="text-xs text-synor-400 bg-synor-600/20 px-2 py-0.5 rounded">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => toggleMenu(wallet.id, e)}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
{menuOpenId === wallet.id && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg z-10 min-w-[120px]">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleStartEdit(wallet, e)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(wallet.id, e)}
|
||||||
|
disabled={wallet.id === activeWalletId}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onCreateWallet?.();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Create New Wallet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onImportWallet?.();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Import size={14} />
|
||||||
|
Import Wallet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/desktop-wallet/src/components/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// UI Components
|
||||||
|
export { default as Layout } from './Layout';
|
||||||
|
export { default as TitleBar } from './TitleBar';
|
||||||
|
export { UpdateBanner } from './UpdateBanner';
|
||||||
|
export { NotificationsBell } from './NotificationsPanel';
|
||||||
|
|
||||||
|
// Error Handling
|
||||||
|
export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary';
|
||||||
|
|
||||||
|
// Loading States
|
||||||
|
export {
|
||||||
|
LoadingSpinner,
|
||||||
|
LoadingOverlay,
|
||||||
|
LoadingInline,
|
||||||
|
LoadingButton,
|
||||||
|
Skeleton,
|
||||||
|
CardSkeleton,
|
||||||
|
TableSkeleton,
|
||||||
|
StatsSkeleton,
|
||||||
|
} from './LoadingStates';
|
||||||
|
|
||||||
|
// Animations
|
||||||
|
export {
|
||||||
|
FadeIn,
|
||||||
|
SlideIn,
|
||||||
|
ScaleIn,
|
||||||
|
StaggerChildren,
|
||||||
|
Pulse,
|
||||||
|
Bounce,
|
||||||
|
CountUp,
|
||||||
|
TypeWriter,
|
||||||
|
} from './Animations';
|
||||||
|
|
||||||
|
// Multi-Wallet Components
|
||||||
|
export { WalletSelector } from './WalletSelector';
|
||||||
|
export { CreateWalletModal } from './CreateWalletModal';
|
||||||
|
export { ImportWalletModal } from './ImportWalletModal';
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen, invoke } from '../lib/tauri';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '../lib/tauri';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
|
|
||||||
503
apps/desktop-wallet/src/lib/tauri.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
/**
|
||||||
|
* Tauri Adapter - Provides mock implementations when running in browser
|
||||||
|
*
|
||||||
|
* This allows the wallet UI to be previewed in a browser without the Rust backend.
|
||||||
|
* When running in Tauri, it uses real invoke() calls.
|
||||||
|
* When running in browser, it returns mock data for UI development/preview.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Detect if we're running inside Tauri
|
||||||
|
export const isTauri = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI__' in window;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock data generators
|
||||||
|
const mockGenerators = {
|
||||||
|
// Wallet commands - 24-word BIP39 mnemonic
|
||||||
|
create_wallet: async (_args?: { password: string }) => ({
|
||||||
|
mnemonic: 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual',
|
||||||
|
address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
|
||||||
|
}),
|
||||||
|
|
||||||
|
import_wallet: async (_args?: { request: { mnemonic: string; password: string } }) =>
|
||||||
|
'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
|
||||||
|
|
||||||
|
unlock_wallet: async (_args?: { password: string }) => true,
|
||||||
|
|
||||||
|
lock_wallet: async () => undefined,
|
||||||
|
|
||||||
|
get_balance: async () => ({
|
||||||
|
balance: 12345678900000,
|
||||||
|
balanceHuman: '123,456.789 SYN',
|
||||||
|
pending: 500000000,
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_addresses: async () => [
|
||||||
|
{ address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y', index: 0, isChange: false, label: 'Main' },
|
||||||
|
{ address: 'tsynor1qa1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8', index: 1, isChange: false, label: 'Savings' },
|
||||||
|
],
|
||||||
|
|
||||||
|
connect_node: async (_args?: { rpcUrl: string; wsUrl?: string }) => ({
|
||||||
|
connected: true,
|
||||||
|
network: 'testnet',
|
||||||
|
blockHeight: 1234567,
|
||||||
|
peerCount: 8,
|
||||||
|
synced: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Node commands
|
||||||
|
node_start: async () => undefined,
|
||||||
|
node_stop: async () => undefined,
|
||||||
|
node_status: async () => ({
|
||||||
|
isRunning: true,
|
||||||
|
isConnected: true,
|
||||||
|
isSyncing: false,
|
||||||
|
network: 'testnet',
|
||||||
|
blockHeight: 1234567,
|
||||||
|
blueScore: 1234500,
|
||||||
|
peerCount: 8,
|
||||||
|
version: '0.1.0',
|
||||||
|
}),
|
||||||
|
get_peers: async () => [
|
||||||
|
{ id: 'peer1', address: '192.168.1.100:19420', connected: true, latency: 45 },
|
||||||
|
{ id: 'peer2', address: '10.0.0.50:19420', connected: true, latency: 120 },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Mining commands
|
||||||
|
mining_start: async () => undefined,
|
||||||
|
mining_stop: async () => undefined,
|
||||||
|
mining_stats: async () => ({
|
||||||
|
isMining: true,
|
||||||
|
hashrate: 1250000,
|
||||||
|
blocksFound: 3,
|
||||||
|
threads: 4,
|
||||||
|
difficulty: 1234567890,
|
||||||
|
coinbaseAddress: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Transaction commands
|
||||||
|
get_transaction_history: async () => [
|
||||||
|
{ txid: 'abc123...', type: 'receive', amount: 1000000000, timestamp: Date.now() - 86400000, confirmations: 100 },
|
||||||
|
{ txid: 'def456...', type: 'send', amount: 500000000, timestamp: Date.now() - 172800000, confirmations: 200 },
|
||||||
|
],
|
||||||
|
create_transaction: async () => ({ txHex: '0x...', fee: 1000 }),
|
||||||
|
sign_transaction: async () => '0x...',
|
||||||
|
broadcast_transaction: async () => 'txid_abc123...',
|
||||||
|
|
||||||
|
// Staking commands
|
||||||
|
get_staking_info: async () => ({
|
||||||
|
isStaking: true,
|
||||||
|
stakedAmount: 5000000000000,
|
||||||
|
pendingRewards: 25000000000,
|
||||||
|
apr: 8.5,
|
||||||
|
validators: [{ address: 'val1', stake: 1000000000000, commission: 5 }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Decoy wallets
|
||||||
|
list_decoy_wallets: async () => [
|
||||||
|
{ id: 'decoy1', name: 'Shopping', balance: 10000000000, isPrimary: false, createdAt: Date.now() - 604800000 },
|
||||||
|
{ id: 'decoy2', name: 'Travel', balance: 5000000000, isPrimary: false, createdAt: Date.now() - 1209600000 },
|
||||||
|
],
|
||||||
|
create_decoy_wallet: async () => ({ id: 'new_decoy', name: 'New Decoy', balance: 0, isPrimary: false, createdAt: Date.now() }),
|
||||||
|
delete_decoy_wallet: async () => undefined,
|
||||||
|
|
||||||
|
// Fee analytics
|
||||||
|
get_fee_analytics: async () => ({
|
||||||
|
currentFeeRate: 1,
|
||||||
|
lowFeeRate: 1,
|
||||||
|
mediumFeeRate: 5,
|
||||||
|
highFeeRate: 10,
|
||||||
|
mempoolSize: 1234,
|
||||||
|
averageBlockTime: 1000,
|
||||||
|
feeHistory: Array.from({ length: 24 }, (_, i) => ({ timestamp: Date.now() - i * 3600000, feeRate: Math.random() * 5 + 1 })),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Watch-only
|
||||||
|
list_watch_addresses: async () => [
|
||||||
|
{ address: 'tsynor1watch1...', label: 'Cold Storage', balance: 100000000000000 },
|
||||||
|
{ address: 'tsynor1watch2...', label: 'Exchange', balance: 50000000000000 },
|
||||||
|
],
|
||||||
|
add_watch_address: async () => undefined,
|
||||||
|
remove_watch_address: async () => undefined,
|
||||||
|
|
||||||
|
// Vaults (Time-locked)
|
||||||
|
list_vaults: async () => [
|
||||||
|
{ id: 'vault1', name: 'Retirement', amount: 50000000000000, unlockTime: Date.now() + 31536000000, status: 'locked' },
|
||||||
|
{ id: 'vault2', name: 'Savings', amount: 10000000000000, unlockTime: Date.now() + 2592000000, status: 'locked' },
|
||||||
|
],
|
||||||
|
create_vault: async () => ({ id: 'new_vault', name: 'New Vault', amount: 0, unlockTime: Date.now() + 86400000, status: 'locked' }),
|
||||||
|
unlock_vault: async () => undefined,
|
||||||
|
|
||||||
|
// Recovery
|
||||||
|
get_recovery_status: async () => ({
|
||||||
|
hasBackup: true,
|
||||||
|
lastBackupDate: Date.now() - 604800000,
|
||||||
|
recoveryMethod: 'mnemonic',
|
||||||
|
socialRecoveryEnabled: false,
|
||||||
|
guardians: [],
|
||||||
|
}),
|
||||||
|
create_backup: async () => 'backup_data_encrypted...',
|
||||||
|
verify_backup: async () => true,
|
||||||
|
|
||||||
|
// Mixer
|
||||||
|
list_mixer_pools: async () => [
|
||||||
|
{ denomination: 100000000, poolSize: 50, minParticipants: 5, fee: 0.001 },
|
||||||
|
{ denomination: 1000000000, poolSize: 30, minParticipants: 5, fee: 0.001 },
|
||||||
|
{ denomination: 10000000000, poolSize: 15, minParticipants: 5, fee: 0.001 },
|
||||||
|
],
|
||||||
|
get_mixer_requests: async () => [
|
||||||
|
{ id: 'mix1', denomination: 1000000000, status: 'pending', createdAt: Date.now() - 3600000 },
|
||||||
|
],
|
||||||
|
create_mix_request: async () => ({ id: 'new_mix', denomination: 1000000000, status: 'pending', createdAt: Date.now() }),
|
||||||
|
cancel_mix_request: async () => undefined,
|
||||||
|
|
||||||
|
// Limit orders
|
||||||
|
list_limit_orders: async () => [
|
||||||
|
{ id: 'order1', pair: 'SYN/BTC', type: 'buy', price: 0.00001, amount: 1000000000, filled: 500000000, status: 'partial' },
|
||||||
|
{ id: 'order2', pair: 'SYN/ETH', type: 'sell', price: 0.0001, amount: 2000000000, filled: 0, status: 'open' },
|
||||||
|
],
|
||||||
|
get_order_book: async () => ({
|
||||||
|
bids: [{ price: 0.00001, amount: 5000000000 }, { price: 0.000009, amount: 10000000000 }],
|
||||||
|
asks: [{ price: 0.000011, amount: 3000000000 }, { price: 0.000012, amount: 8000000000 }],
|
||||||
|
}),
|
||||||
|
create_limit_order: async () => ({ id: 'new_order', pair: 'SYN/BTC', type: 'buy', price: 0.00001, amount: 1000000000, filled: 0, status: 'open' }),
|
||||||
|
cancel_limit_order: async () => undefined,
|
||||||
|
|
||||||
|
// Yield
|
||||||
|
list_yield_opportunities: async () => [
|
||||||
|
{ id: 'yield1', protocol: 'SynorLend', asset: 'SYN', apy: 12.5, tvl: 1000000000000000, risk: 'low' },
|
||||||
|
{ id: 'yield2', protocol: 'SynorFarm', asset: 'SYN-LP', apy: 45.0, tvl: 500000000000000, risk: 'medium' },
|
||||||
|
],
|
||||||
|
list_yield_positions: async () => [
|
||||||
|
{ id: 'pos1', opportunityId: 'yield1', amount: 10000000000000, rewardsEarned: 125000000000, startTime: Date.now() - 2592000000 },
|
||||||
|
],
|
||||||
|
deposit_yield: async () => ({ id: 'new_pos', opportunityId: 'yield1', amount: 5000000000000, rewardsEarned: 0, startTime: Date.now() }),
|
||||||
|
withdraw_yield: async () => undefined,
|
||||||
|
|
||||||
|
// Portfolio
|
||||||
|
get_portfolio_summary: async () => ({
|
||||||
|
totalValueUsd: 125000.50,
|
||||||
|
dayChangeUsd: 2500.25,
|
||||||
|
dayChangePercent: 2.04,
|
||||||
|
totalPnlUsd: 25000.00,
|
||||||
|
totalPnlPercent: 25.0,
|
||||||
|
totalCostBasisUsd: 100000.50,
|
||||||
|
}),
|
||||||
|
list_portfolio_holdings: async () => [
|
||||||
|
{ asset: 'Synor', symbol: 'SYN', balance: 1234567800000000, balanceFormatted: '12,345.678 SYN', valueUsd: 100000, pnlPercent: 25, allocationPercent: 80 },
|
||||||
|
{ asset: 'Bitcoin', symbol: 'BTC', balance: 10000000, balanceFormatted: '0.1 BTC', valueUsd: 25000.50, pnlPercent: 50, allocationPercent: 20 },
|
||||||
|
],
|
||||||
|
get_tax_report: async () => [
|
||||||
|
{ id: 'tx1', timestamp: Date.now() - 86400000, txType: 'buy', asset: 'SYN', amount: 100000000000, totalUsd: 1000, gainLossUsd: undefined, isLongTerm: false },
|
||||||
|
{ id: 'tx2', timestamp: Date.now() - 31536000000, txType: 'sell', asset: 'SYN', amount: 50000000000, totalUsd: 750, gainLossUsd: 250, isLongTerm: true },
|
||||||
|
],
|
||||||
|
export_tax_report: async () => 'Date,Type,Asset,Amount,Total USD,Gain/Loss\n2025-01-01,buy,SYN,100,1000,\n2024-01-01,sell,SYN,50,750,250',
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
list_alerts: async () => [
|
||||||
|
{ id: 'alert1', type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false },
|
||||||
|
{ id: 'alert2', type: 'price_below', asset: 'SYN', threshold: 50, enabled: true, triggered: true },
|
||||||
|
{ id: 'alert3', type: 'balance_below', asset: 'SYN', threshold: 1000, enabled: false, triggered: false },
|
||||||
|
],
|
||||||
|
create_alert: async () => ({ id: 'new_alert', type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false }),
|
||||||
|
update_alert: async () => undefined,
|
||||||
|
delete_alert: async () => undefined,
|
||||||
|
|
||||||
|
// CLI
|
||||||
|
execute_cli_command: async (args?: { command: string }) => ({
|
||||||
|
success: true,
|
||||||
|
output: `Executed: ${args?.command || 'help'}\n\nMock CLI output for browser preview.\nAvailable commands: help, balance, send, receive, history`,
|
||||||
|
executionTime: 150,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// RPC Profiles
|
||||||
|
list_rpc_profiles: async () => [
|
||||||
|
{ id: 'mainnet', name: 'Mainnet', rpcUrl: 'https://rpc.synor.network', wsUrl: 'wss://ws.synor.network', isActive: false },
|
||||||
|
{ id: 'testnet', name: 'Testnet', rpcUrl: 'https://testnet.synor.network', wsUrl: 'wss://testnet-ws.synor.network', isActive: true },
|
||||||
|
{ id: 'local', name: 'Local Node', rpcUrl: 'http://localhost:19423', wsUrl: 'ws://localhost:19424', isActive: false },
|
||||||
|
],
|
||||||
|
create_rpc_profile: async () => ({ id: 'new_profile', name: 'New Profile', rpcUrl: '', wsUrl: '', isActive: false }),
|
||||||
|
update_rpc_profile: async () => undefined,
|
||||||
|
delete_rpc_profile: async () => undefined,
|
||||||
|
set_active_rpc_profile: async () => undefined,
|
||||||
|
|
||||||
|
// Address book
|
||||||
|
list_contacts: async () => [
|
||||||
|
{ id: 'contact1', name: 'Alice', address: 'tsynor1alice...', notes: 'Friend' },
|
||||||
|
{ id: 'contact2', name: 'Bob', address: 'tsynor1bob...', notes: 'Business partner' },
|
||||||
|
],
|
||||||
|
add_contact: async () => ({ id: 'new_contact', name: 'New Contact', address: '', notes: '' }),
|
||||||
|
update_contact: async () => undefined,
|
||||||
|
delete_contact: async () => undefined,
|
||||||
|
|
||||||
|
// Multi-sig
|
||||||
|
list_multisig_wallets: async () => [
|
||||||
|
{ id: 'ms1', name: 'Team Wallet', threshold: 2, signers: ['addr1', 'addr2', 'addr3'], balance: 100000000000000 },
|
||||||
|
],
|
||||||
|
create_multisig_wallet: async () => ({ id: 'new_ms', name: 'New Multisig', threshold: 2, signers: [], balance: 0 }),
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
get_backup_status: async () => ({
|
||||||
|
hasLocalBackup: true,
|
||||||
|
hasCloudBackup: false,
|
||||||
|
lastBackupDate: Date.now() - 604800000,
|
||||||
|
}),
|
||||||
|
create_local_backup: async () => 'backup_created',
|
||||||
|
restore_from_backup: async () => true,
|
||||||
|
|
||||||
|
// QR
|
||||||
|
generate_qr_code: async () => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
|
||||||
|
// Hardware wallet
|
||||||
|
list_hardware_wallets: async () => [],
|
||||||
|
connect_hardware_wallet: async () => ({ id: 'hw1', type: 'ledger', connected: true }),
|
||||||
|
|
||||||
|
// Portfolio commands (snake_case for backend compatibility)
|
||||||
|
portfolio_get_summary: async () => ({
|
||||||
|
total_value_usd: 125000.50,
|
||||||
|
day_change_usd: 2500.25,
|
||||||
|
day_change_percent: 2.04,
|
||||||
|
total_pnl_usd: 25000.00,
|
||||||
|
total_pnl_percent: 25.0,
|
||||||
|
total_cost_basis_usd: 100000.50,
|
||||||
|
}),
|
||||||
|
portfolio_get_holdings: async () => [
|
||||||
|
{ asset: 'Synor', symbol: 'SYN', balance: 1234567800000000, balance_formatted: '12,345.678 SYN', price_usd: 8.1, value_usd: 100000, cost_basis_usd: 80000, pnl_usd: 20000, pnl_percent: 25, allocation_percent: 80 },
|
||||||
|
{ asset: 'Bitcoin', symbol: 'BTC', balance: 10000000, balance_formatted: '0.1 BTC', price_usd: 250000, value_usd: 25000.50, cost_basis_usd: 16700, pnl_usd: 8300.50, pnl_percent: 50, allocation_percent: 20 },
|
||||||
|
],
|
||||||
|
portfolio_get_tax_report: async () => [
|
||||||
|
{ id: 'tx1', timestamp: Date.now() - 86400000, tx_type: 'buy', asset: 'SYN', amount: 100000000000, price_usd: 10, total_usd: 1000, cost_basis_usd: 1000, gain_loss_usd: null, is_long_term: false },
|
||||||
|
{ id: 'tx2', timestamp: Date.now() - 31536000000, tx_type: 'sell', asset: 'SYN', amount: 50000000000, price_usd: 15, total_usd: 750, cost_basis_usd: 500, gain_loss_usd: 250, is_long_term: true },
|
||||||
|
],
|
||||||
|
portfolio_export_tax_report: async () => 'Date,Type,Asset,Amount,Total USD,Gain/Loss\n2025-01-01,buy,SYN,100,1000,\n2024-01-01,sell,SYN,50,750,250',
|
||||||
|
portfolio_get_history: async () => Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
timestamp: Date.now() - i * 86400000,
|
||||||
|
value_usd: 100000 + Math.random() * 50000,
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Wallet manager commands
|
||||||
|
wallets_list: async () => [
|
||||||
|
{ id: 'wallet1', name: 'Main Wallet', address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y', isActive: true, createdAt: Date.now() - 86400000 * 30 },
|
||||||
|
],
|
||||||
|
wallets_create: async () => ({ id: 'new_wallet', name: 'New Wallet', address: 'tsynor1new...', isActive: false, createdAt: Date.now() }),
|
||||||
|
wallets_import: async () => ({ id: 'imported', name: 'Imported Wallet', address: 'tsynor1imp...', isActive: false, createdAt: Date.now() }),
|
||||||
|
wallets_switch: async () => undefined,
|
||||||
|
wallets_delete: async () => undefined,
|
||||||
|
wallets_rename: async () => undefined,
|
||||||
|
|
||||||
|
// Yield commands (snake_case)
|
||||||
|
yield_list_opportunities: async () => [
|
||||||
|
{ id: 'yield1', protocol: 'SynorLend', asset: 'SYN', apy: 12.5, tvl: 1000000000000000, risk: 'low', min_deposit: 100000000 },
|
||||||
|
{ id: 'yield2', protocol: 'SynorFarm', asset: 'SYN-LP', apy: 45.0, tvl: 500000000000000, risk: 'medium', min_deposit: 500000000 },
|
||||||
|
],
|
||||||
|
yield_list_positions: async () => [
|
||||||
|
{ id: 'pos1', opportunity_id: 'yield1', amount: 10000000000000, rewards_earned: 125000000000, start_time: Date.now() - 2592000000 },
|
||||||
|
],
|
||||||
|
yield_deposit: async () => ({ id: 'new_pos', opportunity_id: 'yield1', amount: 5000000000000, rewards_earned: 0, start_time: Date.now() }),
|
||||||
|
yield_withdraw: async () => undefined,
|
||||||
|
|
||||||
|
// Alerts commands
|
||||||
|
alerts_list: async () => [
|
||||||
|
{ id: 'alert1', alert_type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false, created_at: Date.now() - 86400000 },
|
||||||
|
{ id: 'alert2', alert_type: 'price_below', asset: 'SYN', threshold: 50, enabled: true, triggered: true, created_at: Date.now() - 172800000 },
|
||||||
|
],
|
||||||
|
alerts_create: async () => ({ id: 'new_alert', alert_type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false, created_at: Date.now() }),
|
||||||
|
alerts_update: async () => undefined,
|
||||||
|
alerts_delete: async () => undefined,
|
||||||
|
|
||||||
|
// CLI commands
|
||||||
|
cli_execute: async (args?: { command: string }) => ({
|
||||||
|
success: true,
|
||||||
|
output: `$ ${args?.command || 'help'}\n\n[Mock CLI Output]\nCommand executed successfully in browser preview mode.\n\nAvailable commands:\n help - Show this help\n balance - Show wallet balance\n send - Send SYN to an address\n receive - Show receive address\n history - Transaction history`,
|
||||||
|
execution_time: 150,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Mixer commands (snake_case)
|
||||||
|
mixer_get_denominations: async () => [100000000, 1000000000, 10000000000, 100000000000],
|
||||||
|
mixer_list_pools: async () => [
|
||||||
|
{ denomination: 100000000, pool_size: 50, min_participants: 5, fee: 0.001, status: 'active' },
|
||||||
|
{ denomination: 1000000000, pool_size: 30, min_participants: 5, fee: 0.001, status: 'active' },
|
||||||
|
{ denomination: 10000000000, pool_size: 15, min_participants: 5, fee: 0.001, status: 'active' },
|
||||||
|
],
|
||||||
|
mixer_get_pool_status: async () => ({ denomination: 1000000000, pool_size: 30, participants: 12, status: 'active' }),
|
||||||
|
mixer_get_requests: async () => [
|
||||||
|
{ id: 'mix1', denomination: 1000000000, status: 'pending', created_at: Date.now() - 3600000 },
|
||||||
|
],
|
||||||
|
mixer_create_request: async () => ({ id: 'new_mix', denomination: 1000000000, status: 'pending', created_at: Date.now() }),
|
||||||
|
mixer_cancel_request: async () => undefined,
|
||||||
|
|
||||||
|
// Limit orders (snake_case)
|
||||||
|
limit_order_list: async () => [
|
||||||
|
{ id: 'order1', pair: 'SYN/BTC', order_type: 'buy', price: 0.00001, amount: 1000000000, filled: 500000000, status: 'partial', created_at: Date.now() - 86400000 },
|
||||||
|
{ id: 'order2', pair: 'SYN/ETH', order_type: 'sell', price: 0.0001, amount: 2000000000, filled: 0, status: 'open', created_at: Date.now() - 3600000 },
|
||||||
|
],
|
||||||
|
limit_order_get_orderbook: async () => ({
|
||||||
|
bids: [{ price: 0.00001, amount: 5000000000 }, { price: 0.000009, amount: 10000000000 }],
|
||||||
|
asks: [{ price: 0.000011, amount: 3000000000 }, { price: 0.000012, amount: 8000000000 }],
|
||||||
|
}),
|
||||||
|
limit_order_create: async () => ({ id: 'new_order', pair: 'SYN/BTC', order_type: 'buy', price: 0.00001, amount: 1000000000, filled: 0, status: 'open', created_at: Date.now() }),
|
||||||
|
limit_order_cancel: async () => undefined,
|
||||||
|
|
||||||
|
// RPC Profiles (snake_case)
|
||||||
|
rpc_profiles_list: async () => [
|
||||||
|
{ id: 'mainnet', name: 'Mainnet', rpc_url: 'https://rpc.synor.network', ws_url: 'wss://ws.synor.network', is_active: false },
|
||||||
|
{ id: 'testnet', name: 'Testnet', rpc_url: 'https://testnet.synor.network', ws_url: 'wss://testnet-ws.synor.network', is_active: true },
|
||||||
|
{ id: 'local', name: 'Local Node', rpc_url: 'http://localhost:19423', ws_url: 'ws://localhost:19424', is_active: false },
|
||||||
|
],
|
||||||
|
rpc_profiles_create: async () => ({ id: 'new_profile', name: 'New Profile', rpc_url: '', ws_url: '', is_active: false }),
|
||||||
|
rpc_profiles_update: async () => undefined,
|
||||||
|
rpc_profiles_delete: async () => undefined,
|
||||||
|
rpc_profiles_set_active: async () => undefined,
|
||||||
|
|
||||||
|
// Vaults (snake_case)
|
||||||
|
vault_list: async () => [
|
||||||
|
{ id: 'vault1', name: 'Retirement', amount: 50000000000000, unlock_time: Date.now() + 31536000000, status: 'locked', created_at: Date.now() - 86400000 * 365 },
|
||||||
|
{ id: 'vault2', name: 'Savings', amount: 10000000000000, unlock_time: Date.now() + 2592000000, status: 'locked', created_at: Date.now() - 86400000 * 30 },
|
||||||
|
],
|
||||||
|
vault_get_summary: async () => ({
|
||||||
|
total_locked: 60000000000000,
|
||||||
|
total_vaults: 2,
|
||||||
|
next_unlock: Date.now() + 2592000000,
|
||||||
|
}),
|
||||||
|
vault_create: async () => ({ id: 'new_vault', name: 'New Vault', amount: 0, unlock_time: Date.now() + 86400000, status: 'locked', created_at: Date.now() }),
|
||||||
|
vault_withdraw: async () => 'txid_vault_withdraw...',
|
||||||
|
vault_time_remaining: async () => 2592000000,
|
||||||
|
|
||||||
|
// Recovery
|
||||||
|
recovery_get_status: async () => ({
|
||||||
|
has_backup: true,
|
||||||
|
last_backup_date: Date.now() - 604800000,
|
||||||
|
recovery_method: 'mnemonic',
|
||||||
|
social_recovery_enabled: false,
|
||||||
|
guardians: [],
|
||||||
|
}),
|
||||||
|
recovery_create_backup: async () => 'backup_data_encrypted...',
|
||||||
|
recovery_verify_backup: async () => true,
|
||||||
|
|
||||||
|
// Decoy wallets (snake_case)
|
||||||
|
decoy_is_enabled: async () => true,
|
||||||
|
decoy_list: async () => [
|
||||||
|
{ id: 'decoy1', name: 'Shopping', balance: 10000000000, is_primary: false, created_at: Date.now() - 604800000 },
|
||||||
|
{ id: 'decoy2', name: 'Travel', balance: 5000000000, is_primary: false, created_at: Date.now() - 1209600000 },
|
||||||
|
],
|
||||||
|
decoy_create: async () => ({ id: 'new_decoy', name: 'New Decoy', balance: 0, is_primary: false, created_at: Date.now() }),
|
||||||
|
decoy_delete: async () => undefined,
|
||||||
|
decoy_transfer: async () => undefined,
|
||||||
|
|
||||||
|
// Fee analytics
|
||||||
|
fee_get_analytics: async () => ({
|
||||||
|
current_fee_rate: 1,
|
||||||
|
low_fee_rate: 1,
|
||||||
|
medium_fee_rate: 5,
|
||||||
|
high_fee_rate: 10,
|
||||||
|
mempool_size: 1234,
|
||||||
|
average_block_time: 1000,
|
||||||
|
fee_history: Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
timestamp: Date.now() - i * 3600000,
|
||||||
|
fee_rate: Math.random() * 5 + 1,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
fee_calculate: async () => 1500,
|
||||||
|
|
||||||
|
// Watch-only
|
||||||
|
watch_only_list: async () => [
|
||||||
|
{ address: 'tsynor1watch1abc123...', label: 'Cold Storage', balance: 100000000000000 },
|
||||||
|
{ address: 'tsynor1watch2def456...', label: 'Exchange', balance: 50000000000000 },
|
||||||
|
],
|
||||||
|
watch_only_add: async () => undefined,
|
||||||
|
watch_only_remove: async () => undefined,
|
||||||
|
|
||||||
|
// Default fallback for unknown commands
|
||||||
|
default: async (cmd: string) => {
|
||||||
|
console.warn(`[Mock] Unknown command: ${cmd}`);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockGenerators = typeof mockGenerators;
|
||||||
|
type CommandName = keyof MockGenerators;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke a Tauri command with automatic mock fallback for browser
|
||||||
|
*/
|
||||||
|
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (isTauri()) {
|
||||||
|
// Running in Tauri - use real invoke
|
||||||
|
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||||
|
return tauriInvoke<T>(cmd, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running in browser - use mock
|
||||||
|
console.log(`[Mock] ${cmd}`, args || '');
|
||||||
|
|
||||||
|
const generator = mockGenerators[cmd as CommandName] || mockGenerators.default;
|
||||||
|
const result = await generator(args as never);
|
||||||
|
|
||||||
|
// Simulate network latency
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200));
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to Tauri events with mock fallback
|
||||||
|
*/
|
||||||
|
export async function listen<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (payload: { payload: T }) => void
|
||||||
|
): Promise<() => void> {
|
||||||
|
if (isTauri()) {
|
||||||
|
const { listen: tauriListen } = await import('@tauri-apps/api/event');
|
||||||
|
return tauriListen<T>(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In browser, set up mock event emitter
|
||||||
|
console.log(`[Mock] Listening to event: ${event}`);
|
||||||
|
|
||||||
|
// Return a no-op unsubscribe function
|
||||||
|
return () => {
|
||||||
|
console.log(`[Mock] Unsubscribed from event: ${event}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a flag for components to check
|
||||||
|
export const BROWSER_MOCK_MODE = !isTauri();
|
||||||
|
|
||||||
|
// Type for unlisten function
|
||||||
|
export type UnlistenFn = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock window API for browser
|
||||||
|
*/
|
||||||
|
export const mockWindow = {
|
||||||
|
async minimize() {
|
||||||
|
console.log('[Mock] Window minimize');
|
||||||
|
},
|
||||||
|
async maximize() {
|
||||||
|
console.log('[Mock] Window maximize');
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
console.log('[Mock] Window close');
|
||||||
|
},
|
||||||
|
async toggleMaximize() {
|
||||||
|
console.log('[Mock] Window toggleMaximize');
|
||||||
|
},
|
||||||
|
async setTitle(title: string) {
|
||||||
|
console.log('[Mock] Window setTitle:', title);
|
||||||
|
document.title = title;
|
||||||
|
},
|
||||||
|
async isMaximized() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current window with mock fallback
|
||||||
|
*/
|
||||||
|
export async function getCurrentWindow() {
|
||||||
|
if (isTauri()) {
|
||||||
|
const { getCurrentWindow: tauriGetCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
return tauriGetCurrentWindow();
|
||||||
|
}
|
||||||
|
return mockWindow;
|
||||||
|
}
|
||||||
313
apps/desktop-wallet/src/pages/AddressBook/AddressBookPage.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAddressBookStore, AddressBookEntry } from '../../store/addressbook';
|
||||||
|
|
||||||
|
export default function AddressBookPage() {
|
||||||
|
const {
|
||||||
|
entries,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchAll,
|
||||||
|
addEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
} = useAddressBookStore();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingEntry, setEditingEntry] = useState<AddressBookEntry | null>(null);
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formAddress, setFormAddress] = useState('');
|
||||||
|
const [formNotes, setFormNotes] = useState('');
|
||||||
|
const [formTags, setFormTags] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
entry.address.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
entry.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormName('');
|
||||||
|
setFormAddress('');
|
||||||
|
setFormNotes('');
|
||||||
|
setFormTags('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!formName || !formAddress) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tags = formTags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
await addEntry(formName, formAddress, formNotes || undefined, tags);
|
||||||
|
setShowAddModal(false);
|
||||||
|
resetForm();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
if (!editingEntry || !formName || !formAddress) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tags = formTags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
await updateEntry(editingEntry.id, formName, formAddress, formNotes || undefined, tags);
|
||||||
|
setEditingEntry(null);
|
||||||
|
resetForm();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this contact?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteEntry(id);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAddress = (address: string) => {
|
||||||
|
navigator.clipboard.writeText(address);
|
||||||
|
setCopiedAddress(address);
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (entry: AddressBookEntry) => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setFormName(entry.name);
|
||||||
|
setFormAddress(entry.address);
|
||||||
|
setFormNotes(entry.notes || '');
|
||||||
|
setFormTags(entry.tags.join(', '));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEntryCard = (entry: AddressBookEntry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white truncate">{entry.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<code className="text-sm text-gray-400 truncate">{entry.address}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyAddress(entry.address)}
|
||||||
|
className="p-1 hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{copiedAddress === entry.address ? (
|
||||||
|
<Check size={14} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{entry.notes && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2 line-clamp-2">{entry.notes}</p>
|
||||||
|
)}
|
||||||
|
{entry.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{entry.tags.map((tag, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 bg-synor-600/20 text-synor-400 text-xs rounded-full"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(entry)}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} className="text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Address Book</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage your saved addresses</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search by name, address, or tag..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Contacts */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
All Contacts ({filteredEntries.length})
|
||||||
|
</h2>
|
||||||
|
{filteredEntries.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{filteredEntries.map(renderEntryCard)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
{(showAddModal || editingEntry) && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">
|
||||||
|
{editingEntry ? 'Edit Contact' : 'Add Contact'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="Contact name"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formAddress}
|
||||||
|
onChange={(e) => setFormAddress(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Tags (comma separated)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Tag
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formTags}
|
||||||
|
onChange={(e) => setFormTags(e.target.value)}
|
||||||
|
placeholder="e.g., Exchange, Friend, Business"
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={formNotes}
|
||||||
|
onChange={(e) => setFormNotes(e.target.value)}
|
||||||
|
placeholder="Optional notes about this contact"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddModal(false);
|
||||||
|
setEditingEntry(null);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={editingEntry ? handleEdit : handleAdd}
|
||||||
|
disabled={!formName || !formAddress || isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : editingEntry ? 'Save Changes' : 'Add Contact'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
apps/desktop-wallet/src/pages/Alerts/AlertsDashboard.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Bell, Info, AlertCircle, Plus, RefreshCw, Loader2, X, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
|
import { useAlertsStore } from '../../store/alerts';
|
||||||
|
|
||||||
|
export default function AlertsDashboard() {
|
||||||
|
const {
|
||||||
|
alerts,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
listAlerts,
|
||||||
|
createAlert,
|
||||||
|
deleteAlert,
|
||||||
|
toggleAlert,
|
||||||
|
clearError,
|
||||||
|
} = useAlertsStore();
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [asset, setAsset] = useState('SYN');
|
||||||
|
const [condition, setCondition] = useState<'above' | 'below'>('above');
|
||||||
|
const [targetPrice, setTargetPrice] = useState('');
|
||||||
|
const [notificationMethod, setNotificationMethod] = useState<'push' | 'email' | 'both'>('push');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAlerts();
|
||||||
|
}, [listAlerts]);
|
||||||
|
|
||||||
|
const handleCreateAlert = async () => {
|
||||||
|
if (!targetPrice) return;
|
||||||
|
try {
|
||||||
|
await createAlert(asset, condition, parseFloat(targetPrice), notificationMethod);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setTargetPrice('');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeAlerts = alerts.filter(a => a.isEnabled && !a.isTriggered);
|
||||||
|
const triggeredAlerts = alerts.filter(a => a.isTriggered);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Bell className="text-synor-400" />
|
||||||
|
Price Alerts
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Get notified when tokens hit your targets</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={listAlerts}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 hover:bg-synor-700"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
New Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-200/70">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Alerts */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Active Alerts ({activeAlerts.length})</h3>
|
||||||
|
{activeAlerts.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Bell size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No active price alerts</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="mt-2 text-synor-400 hover:text-synor-300 text-sm"
|
||||||
|
>
|
||||||
|
Create your first alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeAlerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
alert.condition === 'above' ? 'bg-green-500/20' : 'bg-red-500/20'
|
||||||
|
}`}>
|
||||||
|
<span className={alert.condition === 'above' ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{alert.condition === 'above' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{alert.asset}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{alert.condition === 'above' ? 'Above' : 'Below'} ${alert.targetPrice.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Current: ${alert.currentPrice.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAlert(alert.id, !alert.isEnabled)}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
{alert.isEnabled ? (
|
||||||
|
<ToggleRight size={20} className="text-synor-400" />
|
||||||
|
) : (
|
||||||
|
<ToggleLeft size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteAlert(alert.id)}
|
||||||
|
className="p-2 text-red-400 hover:bg-red-500/20 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Triggered Alerts */}
|
||||||
|
{triggeredAlerts.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4 text-yellow-400">Triggered Alerts ({triggeredAlerts.length})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{triggeredAlerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="p-4 bg-yellow-500/10 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Bell className="text-yellow-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{alert.asset}</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Reached ${alert.targetPrice.toFixed(4)} on{' '}
|
||||||
|
{alert.triggeredAt ? new Date(alert.triggeredAt * 1000).toLocaleString() : 'Unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteAlert(alert.id)}
|
||||||
|
className="p-2 text-gray-400 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert Types Info */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Alert Types</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-green-400">Above Price</p>
|
||||||
|
<p className="text-xs text-gray-500">Alert when price rises above target</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-red-400">Below Price</p>
|
||||||
|
<p className="text-xs text-gray-500">Alert when price drops below target</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-yellow-400">Notification Methods</p>
|
||||||
|
<p className="text-xs text-gray-500">Push, Email, or Both</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Alert Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium">Create Price Alert</h3>
|
||||||
|
<button onClick={() => setShowCreateModal(false)} className="text-gray-400 hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Asset</label>
|
||||||
|
<select
|
||||||
|
value={asset}
|
||||||
|
onChange={(e) => setAsset(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="SYN">SYN</option>
|
||||||
|
<option value="BTC">BTC</option>
|
||||||
|
<option value="ETH">ETH</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Condition</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCondition('above')}
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium ${
|
||||||
|
condition === 'above' ? 'bg-green-600 text-white' : 'bg-gray-800 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Above Price
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCondition('below')}
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium ${
|
||||||
|
condition === 'below' ? 'bg-red-600 text-white' : 'bg-gray-800 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Below Price
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Target Price (USD)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={targetPrice}
|
||||||
|
onChange={(e) => setTargetPrice(e.target.value)}
|
||||||
|
placeholder="0.0000"
|
||||||
|
step="0.0001"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Notification Method</label>
|
||||||
|
<select
|
||||||
|
value={notificationMethod}
|
||||||
|
onChange={(e) => setNotificationMethod(e.target.value as 'push' | 'email' | 'both')}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="push">Desktop Push</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="both">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateAlert}
|
||||||
|
disabled={isLoading || !targetPrice}
|
||||||
|
className="w-full py-3 bg-synor-600 rounded-lg font-medium hover:bg-synor-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Create Alert'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Alerts use OS notifications to notify you even when the wallet is minimized
|
||||||
|
to the system tray.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
apps/desktop-wallet/src/pages/Backup/BackupPage.tsx
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
FileJson,
|
||||||
|
Shield,
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
HardDrive,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useBackupStore } from '../../store/backup';
|
||||||
|
|
||||||
|
export default function BackupPage() {
|
||||||
|
const {
|
||||||
|
isExporting,
|
||||||
|
isImporting,
|
||||||
|
lastExport,
|
||||||
|
lastHistoryExport,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
exportWallet,
|
||||||
|
importWallet,
|
||||||
|
exportHistory,
|
||||||
|
} = useBackupStore();
|
||||||
|
|
||||||
|
const [exportPassword, setExportPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [importPassword, setImportPassword] = useState('');
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [exportSuccess, setExportSuccess] = useState(false);
|
||||||
|
const [importSuccess, setImportSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleExportWallet = async () => {
|
||||||
|
if (!exportPassword || exportPassword !== confirmPassword) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `synor-wallet-backup-${Date.now()}.enc`,
|
||||||
|
filters: [{ name: 'Encrypted Backup', extensions: ['enc'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
await exportWallet(exportPassword, path);
|
||||||
|
setExportPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setExportSuccess(true);
|
||||||
|
setTimeout(() => setExportSuccess(false), 5000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: 'Encrypted Backup', extensions: ['enc'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
setSelectedFile(selected);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// User cancelled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportWallet = async () => {
|
||||||
|
if (!selectedFile || !importPassword) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importWallet(selectedFile, importPassword);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setImportPassword('');
|
||||||
|
setImportSuccess(true);
|
||||||
|
setTimeout(() => setImportSuccess(false), 5000);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportHistory = async (format: 'json' | 'csv') => {
|
||||||
|
try {
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `synor-history-${Date.now()}.${format}`,
|
||||||
|
filters: [
|
||||||
|
format === 'json'
|
||||||
|
? { name: 'JSON', extensions: ['json'] }
|
||||||
|
: { name: 'CSV', extensions: ['csv'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
await exportHistory(path, format);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordsMatch = exportPassword && exportPassword === confirmPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Backup & Export</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Securely backup your wallet and export transaction history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Alerts */}
|
||||||
|
{exportSuccess && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800 rounded-lg">
|
||||||
|
<Check className="text-green-400" size={20} />
|
||||||
|
<p className="text-green-400">Wallet backup exported successfully!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importSuccess && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800 rounded-lg">
|
||||||
|
<Check className="text-green-400" size={20} />
|
||||||
|
<p className="text-green-400">Wallet imported successfully!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Export Wallet */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-synor-600/20 rounded-lg">
|
||||||
|
<Download className="text-synor-400" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Export Wallet</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Create an encrypted backup of your wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Encryption Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={exportPassword}
|
||||||
|
onChange={(e) => setExportPassword(e.target.value)}
|
||||||
|
placeholder="Enter a strong password"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
className={`w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500 focus:outline-none ${
|
||||||
|
confirmPassword && !passwordsMatch
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{confirmPassword && !passwordsMatch && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">Passwords do not match</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportWallet}
|
||||||
|
disabled={!passwordsMatch || isExporting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Exporting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download size={18} />
|
||||||
|
Export Encrypted Backup
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lastExport && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Clock size={14} />
|
||||||
|
Last export: {new Date(lastExport.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Wallet */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-purple-600/20 rounded-lg">
|
||||||
|
<Upload className="text-purple-400" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Import Wallet</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Restore from an encrypted backup file
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Backup File</label>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFile}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-left hover:border-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={selectedFile ? 'text-white' : 'text-gray-500'}>
|
||||||
|
{selectedFile
|
||||||
|
? selectedFile.split('/').pop()
|
||||||
|
: 'Select backup file...'}
|
||||||
|
</span>
|
||||||
|
<HardDrive size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Decryption Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={importPassword}
|
||||||
|
onChange={(e) => setImportPassword(e.target.value)}
|
||||||
|
placeholder="Enter backup password"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImportWallet}
|
||||||
|
disabled={!selectedFile || !importPassword || isImporting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={18} />
|
||||||
|
Import Backup
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export History */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-green-600/20 rounded-lg">
|
||||||
|
<FileJson className="text-green-400" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Export History</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Export your transaction history for records
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Export your complete transaction history for tax purposes, accounting, or
|
||||||
|
personal records.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExportHistory('csv')}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExportHistory('json')}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastHistoryExport && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Clock size={14} />
|
||||||
|
Last export: {lastHistoryExport.transactionCount} transactions on{' '}
|
||||||
|
{new Date(lastHistoryExport.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Info */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-yellow-600/20 rounded-lg">
|
||||||
|
<Shield className="text-yellow-400" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Security Tips</h2>
|
||||||
|
<p className="text-sm text-gray-400">Keep your backup safe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={16} className="text-synor-400 mt-0.5" />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
Use a strong, unique password for your backup
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={16} className="text-synor-400 mt-0.5" />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
Store backups in multiple secure locations
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={16} className="text-synor-400 mt-0.5" />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
Never share your backup file or password
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={16} className="text-synor-400 mt-0.5" />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
Consider using cold storage for large amounts
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Lock size={16} className="text-synor-400 mt-0.5" />
|
||||||
|
<span className="text-gray-300">
|
||||||
|
Create a new backup after important changes
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
572
apps/desktop-wallet/src/pages/BatchSend/BatchSendDashboard.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
AlertTriangle,
|
||||||
|
Send,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
FileSpreadsheet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useBatchSendStore,
|
||||||
|
useValidRecipientCount,
|
||||||
|
useIsBatchReady,
|
||||||
|
BatchRecipient,
|
||||||
|
} from '../../store/batchSend';
|
||||||
|
|
||||||
|
export default function BatchSendDashboard() {
|
||||||
|
const {
|
||||||
|
recipients,
|
||||||
|
summary,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastTxId,
|
||||||
|
addRecipient,
|
||||||
|
removeRecipient,
|
||||||
|
updateRecipient,
|
||||||
|
clearRecipients,
|
||||||
|
importFromCsv,
|
||||||
|
createBatchTransaction,
|
||||||
|
signAndBroadcast,
|
||||||
|
} = useBatchSendStore();
|
||||||
|
|
||||||
|
const validCount = useValidRecipientCount();
|
||||||
|
const isReady = useIsBatchReady();
|
||||||
|
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [txHex, setTxHex] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateTransaction = async () => {
|
||||||
|
try {
|
||||||
|
const hex = await createBatchTransaction();
|
||||||
|
setTxHex(hex);
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSend = async () => {
|
||||||
|
if (!txHex) return;
|
||||||
|
try {
|
||||||
|
await signAndBroadcast(txHex);
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
setTxHex(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTxId = async () => {
|
||||||
|
if (!lastTxId) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lastTxId);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTemplate = () => {
|
||||||
|
const template = 'address,amount,label\nsynor1...,10.5,Payment 1\nsynor1...,25.0,Payment 2';
|
||||||
|
const blob = new Blob([template], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'batch_template.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Layers className="text-synor-400" />
|
||||||
|
Batch Send
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Send to multiple addresses in a single transaction
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExportTemplate}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Import CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearRecipients}
|
||||||
|
disabled={recipients.length === 1 && !recipients[0].address}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => useBatchSendStore.setState({ error: null })}
|
||||||
|
className="text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipients List */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-800 text-sm text-gray-500">
|
||||||
|
<div className="col-span-1">#</div>
|
||||||
|
<div className="col-span-5">Recipient Address</div>
|
||||||
|
<div className="col-span-2">Amount (SYN)</div>
|
||||||
|
<div className="col-span-3">Label (optional)</div>
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients */}
|
||||||
|
<div className="divide-y divide-gray-800/50">
|
||||||
|
{recipients.map((recipient, index) => (
|
||||||
|
<RecipientRow
|
||||||
|
key={recipient.id}
|
||||||
|
recipient={recipient}
|
||||||
|
index={index}
|
||||||
|
onUpdate={(updates) => updateRecipient(recipient.id, updates)}
|
||||||
|
onRemove={() => removeRecipient(recipient.id)}
|
||||||
|
canRemove={recipients.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<div className="p-3 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={addRecipient}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors w-full justify-center"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Recipient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{summary && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Transaction Summary</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Recipients</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.recipientCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Amount</p>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{summary.totalAmountHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Estimated Fee</p>
|
||||||
|
<p className="text-xl font-bold text-yellow-400">{summary.estimatedFeeHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total (incl. fee)</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.totalWithFeeHuman}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateTransaction}
|
||||||
|
disabled={!isReady || isLoading}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
{isLoading ? 'Creating Transaction...' : `Send to ${validCount} Recipients`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||||
|
<h4 className="text-blue-300 font-medium mb-2">About Batch Transactions</h4>
|
||||||
|
<ul className="text-blue-300/70 text-sm space-y-1">
|
||||||
|
<li>• Batch transactions combine multiple sends into a single transaction</li>
|
||||||
|
<li>• This saves on fees compared to sending individually</li>
|
||||||
|
<li>• All recipients receive their funds when the transaction confirms</li>
|
||||||
|
<li>• You can import recipients from a CSV file</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Modal */}
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportCsvModal
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImport={(csv) => {
|
||||||
|
importFromCsv(csv);
|
||||||
|
setShowImportModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{showConfirmModal && summary && (
|
||||||
|
<ConfirmModal
|
||||||
|
summary={summary}
|
||||||
|
recipients={recipients.filter((r) => r.isValid)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onConfirm={handleConfirmSend}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setTxHex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Modal */}
|
||||||
|
{showSuccessModal && lastTxId && (
|
||||||
|
<SuccessModal
|
||||||
|
txId={lastTxId}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
clearRecipients();
|
||||||
|
}}
|
||||||
|
onCopy={handleCopyTxId}
|
||||||
|
copied={copied}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient row component
|
||||||
|
function RecipientRow({
|
||||||
|
recipient,
|
||||||
|
index,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
canRemove,
|
||||||
|
}: {
|
||||||
|
recipient: BatchRecipient;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (updates: Partial<BatchRecipient>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-12 gap-4 p-4 ${recipient.error ? 'bg-red-500/5' : ''}`}>
|
||||||
|
<div className="col-span-1 flex items-center">
|
||||||
|
<span className="text-gray-500">{index + 1}</span>
|
||||||
|
{recipient.isValid && <Check size={14} className="text-green-400 ml-2" />}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient.address}
|
||||||
|
onChange={(e) => onUpdate({ address: e.target.value })}
|
||||||
|
placeholder="synor1... or tsynor1..."
|
||||||
|
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-white placeholder-gray-500 font-mono text-sm focus:outline-none ${
|
||||||
|
recipient.error && recipient.address
|
||||||
|
? 'border-red-500/50'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={recipient.amount || ''}
|
||||||
|
onChange={(e) => onUpdate({ amount: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="0.00"
|
||||||
|
min="0"
|
||||||
|
step="0.00000001"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient.label || ''}
|
||||||
|
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||||
|
placeholder="Label"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={!canRemove}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recipient.error && (
|
||||||
|
<div className="col-span-12 -mt-2">
|
||||||
|
<p className="text-red-400 text-xs">{recipient.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import CSV modal
|
||||||
|
function ImportCsvModal({
|
||||||
|
onClose,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onImport: (csv: string) => void;
|
||||||
|
}) {
|
||||||
|
const [csvContent, setCsvContent] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
setCsvContent(content);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-lg shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<FileSpreadsheet size={20} />
|
||||||
|
Import from CSV
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
Upload a CSV file or paste content below. Format: address,amount,label
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full py-8 border-2 border-dashed border-gray-700 rounded-lg text-gray-400 hover:border-synor-500 hover:text-synor-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto mb-2" size={24} />
|
||||||
|
Click to upload CSV file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-gray-500 text-sm">or paste content</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={csvContent}
|
||||||
|
onChange={(e) => setCsvContent(e.target.value)}
|
||||||
|
placeholder="address,amount,label synor1abc...,10.5,Payment 1 synor1def...,25.0,Payment 2"
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onImport(csvContent)}
|
||||||
|
disabled={!csvContent.trim()}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm transaction modal
|
||||||
|
function ConfirmModal({
|
||||||
|
summary,
|
||||||
|
recipients,
|
||||||
|
isLoading,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
summary: { totalAmountHuman: string; estimatedFeeHuman: string; totalWithFeeHuman: string };
|
||||||
|
recipients: BatchRecipient[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Confirm Batch Transaction</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Total Amount</span>
|
||||||
|
<span className="text-white font-medium">{summary.totalAmountHuman}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Network Fee</span>
|
||||||
|
<span className="text-yellow-400">{summary.estimatedFeeHuman}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-700 pt-2 flex justify-between">
|
||||||
|
<span className="text-gray-300 font-medium">Total</span>
|
||||||
|
<span className="text-synor-400 font-bold">{summary.totalWithFeeHuman}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients list */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Sending to {recipients.length} recipients:</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{recipients.slice(0, 5).map((r, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400 font-mono truncate max-w-[200px]">
|
||||||
|
{r.address.slice(0, 12)}...{r.address.slice(-6)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white">{r.amount} SYN</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{recipients.length > 5 && (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
... and {recipients.length - 5} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<p className="text-yellow-300 text-sm">
|
||||||
|
Please verify all recipients and amounts. This transaction cannot be reversed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Confirm & Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success modal
|
||||||
|
function SuccessModal({
|
||||||
|
txId,
|
||||||
|
onClose,
|
||||||
|
onCopy,
|
||||||
|
copied,
|
||||||
|
}: {
|
||||||
|
txId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl text-center">
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Check size={32} className="text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">Transaction Sent!</h2>
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Your batch transaction has been broadcast to the network.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Transaction ID</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<code className="text-synor-400 text-sm font-mono break-all">
|
||||||
|
{txId.slice(0, 16)}...{txId.slice(-16)}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className="text-gray-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
399
apps/desktop-wallet/src/pages/Bridge/BridgeDashboard.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Loader,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useBridgeStore, getChainIcon, getStatusColor } from '../../store/bridge';
|
||||||
|
|
||||||
|
export default function BridgeDashboard() {
|
||||||
|
const {
|
||||||
|
chains,
|
||||||
|
transfers,
|
||||||
|
wrappedBalances,
|
||||||
|
isLoading,
|
||||||
|
isTransferring,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchChains,
|
||||||
|
fetchTransfers,
|
||||||
|
getWrappedBalance,
|
||||||
|
deposit,
|
||||||
|
withdraw,
|
||||||
|
} = useBridgeStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'bridge' | 'history'>('bridge');
|
||||||
|
const [direction, setDirection] = useState<'deposit' | 'withdraw'>('deposit');
|
||||||
|
const [selectedChain, setSelectedChain] = useState('');
|
||||||
|
const [selectedToken, setSelectedToken] = useState('SYN');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [destAddress, setDestAddress] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChains();
|
||||||
|
fetchTransfers();
|
||||||
|
}, [fetchChains, fetchTransfers]);
|
||||||
|
|
||||||
|
// Fetch wrapped balances for supported tokens
|
||||||
|
useEffect(() => {
|
||||||
|
chains.forEach((chain) => {
|
||||||
|
chain.supportedTokens.forEach((token) => {
|
||||||
|
getWrappedBalance(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [chains, getWrappedBalance]);
|
||||||
|
|
||||||
|
const handleTransfer = async () => {
|
||||||
|
if (!selectedChain || !amount) return;
|
||||||
|
try {
|
||||||
|
if (direction === 'deposit') {
|
||||||
|
await deposit(selectedChain, selectedToken, amount);
|
||||||
|
} else {
|
||||||
|
if (!destAddress) return;
|
||||||
|
await withdraw(selectedChain, destAddress, selectedToken, amount);
|
||||||
|
}
|
||||||
|
setAmount('');
|
||||||
|
setDestAddress('');
|
||||||
|
fetchTransfers();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock size={16} className="text-yellow-400" />;
|
||||||
|
case 'confirming':
|
||||||
|
case 'relaying':
|
||||||
|
return <Loader size={16} className="text-blue-400 animate-spin" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle size={16} className="text-green-400" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle size={16} className="text-red-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock size={16} className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedChainInfo = chains.find((c) => c.chainId === selectedChain);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Cross-Chain Bridge</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Transfer assets between Synor and other blockchains</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchChains();
|
||||||
|
fetchTransfers();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wrapped Balances */}
|
||||||
|
{Object.keys(wrappedBalances).length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(wrappedBalances).map(([token, balance]) => (
|
||||||
|
<div
|
||||||
|
key={token}
|
||||||
|
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🪙</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Wrapped {token}</p>
|
||||||
|
<p className="text-lg font-bold text-white">{balance} w{token}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('bridge')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'bridge'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Bridge
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('history')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'history'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Transfer History ({transfers.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bridge Tab */}
|
||||||
|
{activeTab === 'bridge' && (
|
||||||
|
<div className="max-w-lg mx-auto">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
{/* Direction Toggle */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setDirection('deposit')}
|
||||||
|
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
direction === 'deposit'
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Deposit to Synor
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDirection('withdraw')}
|
||||||
|
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
direction === 'withdraw'
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Withdraw from Synor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chain Selection */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
{direction === 'deposit' ? 'Source Chain' : 'Destination Chain'}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{chains.map((chain) => (
|
||||||
|
<button
|
||||||
|
key={chain.chainId}
|
||||||
|
onClick={() => setSelectedChain(chain.chainId)}
|
||||||
|
disabled={!chain.isActive}
|
||||||
|
className={`flex flex-col items-center gap-2 p-4 rounded-lg border transition-colors ${
|
||||||
|
selectedChain === chain.chainId
|
||||||
|
? 'border-synor-500 bg-synor-600/20'
|
||||||
|
: chain.isActive
|
||||||
|
? 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||||
|
: 'border-gray-800 bg-gray-800/50 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{getChainIcon(chain.chainId)}</span>
|
||||||
|
<span className="text-sm text-white">{chain.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token Selection */}
|
||||||
|
{selectedChainInfo && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Token</label>
|
||||||
|
<select
|
||||||
|
value={selectedToken}
|
||||||
|
onChange={(e) => setSelectedToken(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
{selectedChainInfo.supportedTokens.map((token) => (
|
||||||
|
<option key={token} value={token}>
|
||||||
|
{token}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-xl placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination Address (for withdraw) */}
|
||||||
|
{direction === 'withdraw' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Destination Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={destAddress}
|
||||||
|
onChange={(e) => setDestAddress(e.target.value)}
|
||||||
|
placeholder="Enter destination address on target chain"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer Info */}
|
||||||
|
{selectedChainInfo && amount && (
|
||||||
|
<div className="mb-4 p-4 bg-gray-800/50 rounded-lg space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Required Confirmations</span>
|
||||||
|
<span className="text-white">{selectedChainInfo.confirmations}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">You Will Receive</span>
|
||||||
|
<span className="text-synor-400 font-medium">
|
||||||
|
~{amount} {direction === 'deposit' ? `w${selectedToken}` : selectedToken}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer Visualization */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-6 p-4 bg-gray-800/30 rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-2xl block mb-1">
|
||||||
|
{direction === 'deposit' ? getChainIcon(selectedChain) || '🔗' : '🟣'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{direction === 'deposit' ? selectedChainInfo?.name || 'Select Chain' : 'Synor'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ArrowLeftRight size={24} className="text-synor-400" />
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-2xl block mb-1">
|
||||||
|
{direction === 'deposit' ? '🟣' : getChainIcon(selectedChain) || '🔗'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{direction === 'deposit' ? 'Synor' : selectedChainInfo?.name || 'Select Chain'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transfer Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleTransfer}
|
||||||
|
disabled={!selectedChain || !amount || isTransferring || (direction === 'withdraw' && !destAddress)}
|
||||||
|
className="w-full px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isTransferring ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : !selectedChain ? (
|
||||||
|
'Select a chain'
|
||||||
|
) : !amount ? (
|
||||||
|
'Enter an amount'
|
||||||
|
) : direction === 'withdraw' && !destAddress ? (
|
||||||
|
'Enter destination address'
|
||||||
|
) : direction === 'deposit' ? (
|
||||||
|
'Deposit'
|
||||||
|
) : (
|
||||||
|
'Withdraw'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History Tab */}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{transfers.map((transfer) => {
|
||||||
|
const srcChain = chains.find((c) => c.chainId === transfer.sourceChain);
|
||||||
|
const dstChain = chains.find((c) => c.chainId === transfer.destChain);
|
||||||
|
const isDeposit = transfer.destChain === 'synor';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={transfer.transferId}
|
||||||
|
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusIcon(transfer.status)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">
|
||||||
|
{isDeposit ? 'Deposit' : 'Withdrawal'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{srcChain?.name || transfer.sourceChain} → {dstChain?.name || transfer.destChain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-medium ${getStatusColor(transfer.status)}`}>
|
||||||
|
{transfer.status.charAt(0).toUpperCase() + transfer.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Amount</p>
|
||||||
|
<p className="text-white font-medium">{transfer.amount} {transfer.token}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Date</p>
|
||||||
|
<p className="text-white">{new Date(transfer.createdAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
{transfer.sourceTxHash && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Source TX</p>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-synor-400 hover:text-synor-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{transfer.sourceTxHash.slice(0, 8)}...
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transfer.destTxHash && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Dest TX</p>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-synor-400 hover:text-synor-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{transfer.destTxHash.slice(0, 8)}...
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{transfers.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No bridge transfers yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
apps/desktop-wallet/src/pages/CLI/CliDashboard.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Terminal, Info, Send, Loader2 } from 'lucide-react';
|
||||||
|
import { useCliStore } from '../../store/cli';
|
||||||
|
|
||||||
|
export default function CliDashboard() {
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
isExecuting,
|
||||||
|
execute,
|
||||||
|
loadHistory,
|
||||||
|
clearOutput,
|
||||||
|
navigateHistory,
|
||||||
|
} = useCliStore();
|
||||||
|
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
}, [loadHistory]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when history changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (outputRef.current) {
|
||||||
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleCommand = async () => {
|
||||||
|
if (!input.trim() || isExecuting) return;
|
||||||
|
const cmd = input.trim();
|
||||||
|
setInput('');
|
||||||
|
await execute(cmd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCommand();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = navigateHistory('up');
|
||||||
|
if (prev !== null) setInput(prev);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = navigateHistory('down');
|
||||||
|
if (next !== null) setInput(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Terminal className="text-synor-400" />
|
||||||
|
CLI Mode
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Terminal interface for power users</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearOutput}
|
||||||
|
className="px-3 py-1 bg-gray-800 rounded text-sm hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={outputRef}
|
||||||
|
className="p-4 font-mono text-sm bg-black/50 h-96 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{/* Welcome message if no history */}
|
||||||
|
{history.length === 0 && (
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<p className="text-synor-400">{'>'} Welcome to Synor CLI Mode</p>
|
||||||
|
<p className="text-synor-400">{'>'} Type "help" for available commands</p>
|
||||||
|
<p className="text-synor-400">{'>'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Command history */}
|
||||||
|
{history.map((result, i) => (
|
||||||
|
<div key={i} className="mb-2">
|
||||||
|
<div className="text-synor-400">
|
||||||
|
{'>'} {result.command}
|
||||||
|
</div>
|
||||||
|
<div className={`whitespace-pre-wrap ${result.isError ? 'text-red-400' : 'text-gray-300'}`}>
|
||||||
|
{result.output}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isExecuting && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>Executing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 p-3 flex gap-2">
|
||||||
|
<span className="text-synor-400 font-mono">{'>'}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter command..."
|
||||||
|
className="flex-1 bg-transparent text-white font-mono outline-none"
|
||||||
|
autoFocus
|
||||||
|
disabled={isExecuting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCommand}
|
||||||
|
disabled={isExecuting || !input.trim()}
|
||||||
|
className="p-2 bg-synor-600 rounded-lg hover:bg-synor-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isExecuting ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Commands */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Quick Commands</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{['help', 'balance', 'address', 'status', 'utxos', 'peers'].map((cmd) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
onClick={() => { setInput(cmd); }}
|
||||||
|
className="px-3 py-1 bg-gray-800 rounded font-mono text-sm hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{cmd}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command Reference */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Command Reference</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="font-mono text-synor-400">help</div>
|
||||||
|
<div className="text-gray-400">Show all available commands</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">balance</div>
|
||||||
|
<div className="text-gray-400">Show wallet balance</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">address</div>
|
||||||
|
<div className="text-gray-400">Show wallet addresses</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">send <addr> <amount></div>
|
||||||
|
<div className="text-gray-400">Send SYN to address</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">utxos</div>
|
||||||
|
<div className="text-gray-400">List unspent outputs</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">history</div>
|
||||||
|
<div className="text-gray-400">Transaction history</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">status</div>
|
||||||
|
<div className="text-gray-400">Network status</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">peers</div>
|
||||||
|
<div className="text-gray-400">Connected peers</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-synor-400">clear</div>
|
||||||
|
<div className="text-gray-400">Clear output</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
CLI mode provides a terminal interface for advanced users who prefer keyboard-driven
|
||||||
|
interaction. Use ↑/↓ arrows to navigate command history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
apps/desktop-wallet/src/pages/Compute/ComputeDashboard.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Cpu,
|
||||||
|
Server,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
Loader,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useComputeStore, formatPrice } from '../../store/compute';
|
||||||
|
|
||||||
|
export default function ComputeDashboard() {
|
||||||
|
const {
|
||||||
|
providers,
|
||||||
|
jobs,
|
||||||
|
isLoading,
|
||||||
|
isSubmitting,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchProviders,
|
||||||
|
fetchJobs,
|
||||||
|
submitJob,
|
||||||
|
cancelJob,
|
||||||
|
} = useComputeStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'providers' | 'jobs'>('providers');
|
||||||
|
const [showSubmitForm, setShowSubmitForm] = useState(false);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState('');
|
||||||
|
const [dockerImage, setDockerImage] = useState('');
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [inputCid, setInputCid] = useState('');
|
||||||
|
const [cpuCores, setCpuCores] = useState(4);
|
||||||
|
const [memoryGb, setMemoryGb] = useState(8);
|
||||||
|
const [maxHours, setMaxHours] = useState(1);
|
||||||
|
const [gpuType, setGpuType] = useState('');
|
||||||
|
const [expandedJob, setExpandedJob] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProviders();
|
||||||
|
fetchJobs();
|
||||||
|
}, [fetchProviders, fetchJobs]);
|
||||||
|
|
||||||
|
const handleSubmitJob = async () => {
|
||||||
|
if (!selectedProvider || !dockerImage || !command) return;
|
||||||
|
try {
|
||||||
|
await submitJob({
|
||||||
|
provider: selectedProvider,
|
||||||
|
inputCid,
|
||||||
|
dockerImage,
|
||||||
|
command: command.split(' '),
|
||||||
|
gpuType: gpuType || undefined,
|
||||||
|
cpuCores,
|
||||||
|
memoryGb,
|
||||||
|
maxHours,
|
||||||
|
});
|
||||||
|
setShowSubmitForm(false);
|
||||||
|
setSelectedProvider('');
|
||||||
|
setDockerImage('');
|
||||||
|
setCommand('');
|
||||||
|
setInputCid('');
|
||||||
|
fetchJobs();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelJob = async (jobId: string) => {
|
||||||
|
await cancelJob(jobId);
|
||||||
|
fetchJobs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock size={16} className="text-yellow-400" />;
|
||||||
|
case 'running':
|
||||||
|
return <Loader size={16} className="text-blue-400 animate-spin" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle size={16} className="text-green-400" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle size={16} className="text-red-400" />;
|
||||||
|
case 'cancelled':
|
||||||
|
return <Pause size={16} className="text-gray-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock size={16} className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'running':
|
||||||
|
return 'text-blue-400';
|
||||||
|
case 'completed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-red-400';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'text-gray-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique GPU types from all providers
|
||||||
|
const availableGpuTypes = [...new Set(providers.flatMap((p) => p.gpuTypes))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Compute Marketplace</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Decentralized GPU and CPU compute resources</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchProviders();
|
||||||
|
fetchJobs();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSubmitForm(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
Submit Job
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Job Form Modal */}
|
||||||
|
{showSubmitForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Submit Compute Job</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Provider</label>
|
||||||
|
<select
|
||||||
|
value={selectedProvider}
|
||||||
|
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a provider</option>
|
||||||
|
{providers
|
||||||
|
.filter((p) => p.isAvailable)
|
||||||
|
.map((provider) => (
|
||||||
|
<option key={provider.address} value={provider.address}>
|
||||||
|
{provider.name} - {formatPrice(provider.pricePerHour)}/hr ({provider.gpuTypes.join(', ') || `${provider.cpuCores} cores`})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Docker Image</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dockerImage}
|
||||||
|
onChange={(e) => setDockerImage(e.target.value)}
|
||||||
|
placeholder="pytorch/pytorch:latest"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
placeholder="python train.py --epochs 10"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Input Data CID (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputCid}
|
||||||
|
onChange={(e) => setInputCid(e.target.value)}
|
||||||
|
placeholder="Qm..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">CPU Cores</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cpuCores}
|
||||||
|
onChange={(e) => setCpuCores(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Memory (GB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={memoryGb}
|
||||||
|
onChange={(e) => setMemoryGb(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={512}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">GPU Type (optional)</label>
|
||||||
|
<select
|
||||||
|
value={gpuType}
|
||||||
|
onChange={(e) => setGpuType(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{availableGpuTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Max Hours</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxHours}
|
||||||
|
onChange={(e) => setMaxHours(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSubmitForm(false)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitJob}
|
||||||
|
disabled={!selectedProvider || !dockerImage || !command || isSubmitting}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Submit Job'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('providers')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'providers'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Server size={16} className="inline mr-2" />
|
||||||
|
Providers ({providers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('jobs')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'jobs'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cpu size={16} className="inline mr-2" />
|
||||||
|
My Jobs ({jobs.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Providers Tab */}
|
||||||
|
{activeTab === 'providers' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.address}
|
||||||
|
className={`bg-gray-900 rounded-xl p-6 border ${
|
||||||
|
provider.isAvailable ? 'border-gray-800' : 'border-gray-800/50 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">{provider.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400 font-mono">{provider.address.slice(0, 12)}...</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
provider.isAvailable
|
||||||
|
? 'bg-green-900/50 text-green-400'
|
||||||
|
: 'bg-gray-800 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{provider.isAvailable ? 'Available' : 'Busy'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{provider.gpuTypes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">GPUs</p>
|
||||||
|
<p className="text-white font-medium">{provider.gpuTypes.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">CPU Cores</p>
|
||||||
|
<p className="text-white font-medium">{provider.cpuCores}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Memory</p>
|
||||||
|
<p className="text-white font-medium">{provider.memoryGb} GB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Price</p>
|
||||||
|
<p className="text-synor-400 font-medium">{formatPrice(provider.pricePerHour)}/hr</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Reputation</p>
|
||||||
|
<p className="text-white font-medium">{provider.reputation}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{provider.isAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvider(provider.address);
|
||||||
|
setShowSubmitForm(true);
|
||||||
|
}}
|
||||||
|
className="w-full mt-4 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Use This Provider
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{providers.length === 0 && (
|
||||||
|
<div className="col-span-2 text-center py-12 text-gray-500">
|
||||||
|
No compute providers available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jobs Tab */}
|
||||||
|
{activeTab === 'jobs' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.jobId}
|
||||||
|
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||||||
|
onClick={() => setExpandedJob(expandedJob === job.jobId ? null : job.jobId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getStatusIcon(job.status)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">Job {job.jobId.slice(0, 8)}</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{job.gpuType || `${job.cpuCores} cores`} • {job.provider.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||||||
|
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
{expandedJob === job.jobId ? (
|
||||||
|
<ChevronUp size={20} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={20} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedJob === job.jobId && (
|
||||||
|
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||||||
|
{job.startedAt && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Started</p>
|
||||||
|
<p className="text-white">{new Date(job.startedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.endedAt && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Ended</p>
|
||||||
|
<p className="text-white">{new Date(job.endedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Cost</p>
|
||||||
|
<p className="text-synor-400">{formatPrice(job.totalCost)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Memory</p>
|
||||||
|
<p className="text-white">{job.memoryGb} GB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{job.resultCid && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-gray-500 text-sm mb-1">Result CID</p>
|
||||||
|
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-gray-300 overflow-x-auto font-mono">
|
||||||
|
{job.resultCid}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(job.status === 'pending' || job.status === 'running') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelJob(job.jobId);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel Job
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{jobs.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No compute jobs. Submit a job to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
apps/desktop-wallet/src/pages/DApps/DAppBrowser.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Link2,
|
||||||
|
Link2Off,
|
||||||
|
ExternalLink,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
AlertCircle,
|
||||||
|
Zap,
|
||||||
|
Image,
|
||||||
|
Coins,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDAppsStore, POPULAR_DAPPS } from '../../store/dapps';
|
||||||
|
import { useWalletStore } from '../../store/wallet';
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
DeFi: <Coins size={16} />,
|
||||||
|
NFT: <Image size={16} />,
|
||||||
|
Gaming: <Zap size={16} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DAppBrowser() {
|
||||||
|
const { addresses } = useWalletStore();
|
||||||
|
const {
|
||||||
|
connectedDApps,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchConnected,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
} = useDAppsStore();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'discover' | 'connected'>('discover');
|
||||||
|
const [connectingDApp, setConnectingDApp] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const userAddress = addresses[0]?.address;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConnected();
|
||||||
|
}, [fetchConnected]);
|
||||||
|
|
||||||
|
const filteredDApps = POPULAR_DAPPS.filter(
|
||||||
|
(dapp) =>
|
||||||
|
dapp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
dapp.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
dapp.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConnect = async (dapp: (typeof POPULAR_DAPPS)[0]) => {
|
||||||
|
if (!userAddress) return;
|
||||||
|
|
||||||
|
setConnectingDApp(dapp.url);
|
||||||
|
try {
|
||||||
|
await connect(
|
||||||
|
dapp.url,
|
||||||
|
dapp.name,
|
||||||
|
userAddress,
|
||||||
|
['eth_accounts', 'eth_sendTransaction', 'personal_sign']
|
||||||
|
);
|
||||||
|
setActiveTab('connected');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} finally {
|
||||||
|
setConnectingDApp(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (origin: string) => {
|
||||||
|
try {
|
||||||
|
await disconnect(origin);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConnected = (url: string) =>
|
||||||
|
connectedDApps.some((d) => d.origin === url);
|
||||||
|
|
||||||
|
const categories = [...new Set(POPULAR_DAPPS.map((d) => d.category))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">DApp Browser</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Discover and connect to decentralized apps</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchConnected}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search DApps..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('discover')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'discover'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Discover
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('connected')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'connected'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Connected ({connectedDApps.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discover Tab */}
|
||||||
|
{activeTab === 'discover' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const categoryDApps = filteredDApps.filter((d) => d.category === category);
|
||||||
|
if (categoryDApps.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
{CATEGORY_ICONS[category] || <Globe size={16} />}
|
||||||
|
{category}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categoryDApps.map((dapp) => (
|
||||||
|
<div
|
||||||
|
key={dapp.url}
|
||||||
|
className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gray-800 rounded-xl flex items-center justify-center text-2xl">
|
||||||
|
{dapp.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-white">{dapp.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400 line-clamp-2">
|
||||||
|
{dapp.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{isConnected(dapp.url) ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.open(dapp.url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect(dapp.url)}
|
||||||
|
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Link2Off size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleConnect(dapp)}
|
||||||
|
disabled={connectingDApp === dapp.url || !userAddress}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{connectingDApp === dapp.url ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={14} className="animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2 size={14} />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredDApps.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No DApps found matching your search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connected Tab */}
|
||||||
|
{activeTab === 'connected' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{connectedDApps.map((dapp) => (
|
||||||
|
<div
|
||||||
|
key={dapp.origin}
|
||||||
|
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-600/20 rounded-lg">
|
||||||
|
<Shield className="text-green-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{dapp.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">{dapp.origin}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.open(dapp.origin, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect(dapp.origin)}
|
||||||
|
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-800">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Connected Address</p>
|
||||||
|
<code className="text-white text-xs">
|
||||||
|
{dapp.connectedAddress.slice(0, 12)}...
|
||||||
|
{dapp.connectedAddress.slice(-8)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Connected Since</p>
|
||||||
|
<p className="text-white">
|
||||||
|
{new Date(dapp.connectedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-gray-500 text-sm mb-1">Permissions</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{dapp.permissions.map((perm) => (
|
||||||
|
<span
|
||||||
|
key={perm}
|
||||||
|
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
|
||||||
|
>
|
||||||
|
{perm}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{connectedDApps.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Globe className="mx-auto mb-4 text-gray-600" size={48} />
|
||||||
|
<p className="text-gray-500">No connected DApps</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Connect to a DApp from the Discover tab
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
350
apps/desktop-wallet/src/pages/Database/DatabaseDashboard.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Search,
|
||||||
|
Play,
|
||||||
|
FileJson,
|
||||||
|
Key,
|
||||||
|
Clock,
|
||||||
|
GitBranch,
|
||||||
|
Table,
|
||||||
|
Braces,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDatabaseStore, DATABASE_TYPES, REGIONS, DatabaseType } from '../../store/database';
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<DatabaseType, React.ReactNode> = {
|
||||||
|
kv: <Key size={20} className="text-blue-400" />,
|
||||||
|
document: <FileJson size={20} className="text-green-400" />,
|
||||||
|
vector: <Braces size={20} className="text-purple-400" />,
|
||||||
|
timeseries: <Clock size={20} className="text-yellow-400" />,
|
||||||
|
graph: <GitBranch size={20} className="text-pink-400" />,
|
||||||
|
sql: <Table size={20} className="text-cyan-400" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_DESCRIPTIONS: Record<DatabaseType, string> = {
|
||||||
|
kv: 'Fast key-value storage for caching and simple data',
|
||||||
|
document: 'JSON document storage with flexible schemas',
|
||||||
|
vector: 'Vector embeddings for AI/ML and semantic search',
|
||||||
|
timeseries: 'Time-indexed data for metrics and analytics',
|
||||||
|
graph: 'Connected data with relationships and traversals',
|
||||||
|
sql: 'Traditional relational database with ACID compliance',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DatabaseDashboard() {
|
||||||
|
const {
|
||||||
|
instances,
|
||||||
|
isLoading,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchInstances,
|
||||||
|
createDatabase,
|
||||||
|
deleteDatabase,
|
||||||
|
executeQuery,
|
||||||
|
} = useDatabaseStore();
|
||||||
|
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newDbName, setNewDbName] = useState('');
|
||||||
|
const [newDbType, setNewDbType] = useState<DatabaseType>('document');
|
||||||
|
const [newDbRegion, setNewDbRegion] = useState('us-east');
|
||||||
|
const [selectedDb, setSelectedDb] = useState<string | null>(null);
|
||||||
|
const [queryInput, setQueryInput] = useState('');
|
||||||
|
const [isQuerying, setIsQuerying] = useState(false);
|
||||||
|
const [queryResult, setQueryResult] = useState<unknown>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInstances();
|
||||||
|
}, [fetchInstances]);
|
||||||
|
|
||||||
|
const handleCreateDatabase = async () => {
|
||||||
|
if (!newDbName) return;
|
||||||
|
try {
|
||||||
|
await createDatabase(newDbName, newDbType, newDbRegion);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewDbName('');
|
||||||
|
fetchInstances();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDatabase = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this database? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteDatabase(id);
|
||||||
|
if (selectedDb === id) {
|
||||||
|
setSelectedDb(null);
|
||||||
|
}
|
||||||
|
fetchInstances();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
if (!selectedDb || !queryInput) return;
|
||||||
|
setIsQuerying(true);
|
||||||
|
try {
|
||||||
|
const result = await executeQuery(selectedDb, queryInput);
|
||||||
|
setQueryResult(result);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} finally {
|
||||||
|
setIsQuerying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
|
||||||
|
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
|
||||||
|
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(2)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDatabase = instances.find((db) => db.id === selectedDb);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Database Services</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Multi-model decentralized databases</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchInstances}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create Database
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Database Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Create Database</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Database Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newDbName}
|
||||||
|
onChange={(e) => setNewDbName(e.target.value)}
|
||||||
|
placeholder="my-database"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Database Type</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{DATABASE_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
onClick={() => setNewDbType(type.value)}
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
|
||||||
|
newDbType === type.value
|
||||||
|
? 'border-synor-500 bg-synor-600/20'
|
||||||
|
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{TYPE_ICONS[type.value]}
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">{type.label}</p>
|
||||||
|
<p className="text-xs text-gray-500">{type.description.split(' ').slice(0, 3).join(' ')}...</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Region</label>
|
||||||
|
<select
|
||||||
|
value={newDbRegion}
|
||||||
|
onChange={(e) => setNewDbRegion(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
{REGIONS.map((region) => (
|
||||||
|
<option key={region.value} value={region.value}>
|
||||||
|
{region.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateDatabase}
|
||||||
|
disabled={!newDbName || isCreating}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Database List */}
|
||||||
|
<div className="lg:col-span-1 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Your Databases</h2>
|
||||||
|
{instances.map((db) => (
|
||||||
|
<div
|
||||||
|
key={db.id}
|
||||||
|
onClick={() => setSelectedDb(db.id)}
|
||||||
|
className={`bg-gray-900 rounded-xl p-4 border cursor-pointer transition-colors ${
|
||||||
|
selectedDb === db.id
|
||||||
|
? 'border-synor-500 bg-synor-600/10'
|
||||||
|
: 'border-gray-800 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{TYPE_ICONS[db.dbType]}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{db.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 capitalize">{db.dbType} • {db.region}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteDatabase(db.id);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="text-gray-500 hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Size</p>
|
||||||
|
<p className="text-white">{formatSize(db.storageUsed)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Status</p>
|
||||||
|
<p className="text-white capitalize">{db.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{instances.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Database size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No databases yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Query Panel */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{selectedDatabase ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{TYPE_ICONS[selectedDatabase.dbType]}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{selectedDatabase.name}</h2>
|
||||||
|
<p className="text-sm text-gray-400">{TYPE_DESCRIPTIONS[selectedDatabase.dbType]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
<Search size={14} className="inline mr-1" />
|
||||||
|
Query
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={queryInput}
|
||||||
|
onChange={(e) => setQueryInput(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
selectedDatabase.dbType === 'sql'
|
||||||
|
? 'SELECT * FROM users WHERE active = true'
|
||||||
|
: selectedDatabase.dbType === 'document'
|
||||||
|
? '{"filter": {"status": "active"}, "limit": 10}'
|
||||||
|
: selectedDatabase.dbType === 'kv'
|
||||||
|
? 'GET user:123'
|
||||||
|
: selectedDatabase.dbType === 'vector'
|
||||||
|
? '{"vector": [0.1, 0.2, ...], "topK": 10}'
|
||||||
|
: selectedDatabase.dbType === 'graph'
|
||||||
|
? 'MATCH (n:User)-[:FOLLOWS]->(m) RETURN m'
|
||||||
|
: '{"start": "2024-01-01", "end": "2024-01-31"}'
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={!queryInput || isQuerying}
|
||||||
|
className="mt-2 flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{isQuerying ? 'Executing...' : 'Execute Query'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm text-gray-400">Result</label>
|
||||||
|
{queryResult !== null && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQueryResult(null)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-400"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 min-h-[200px] max-h-[400px] overflow-auto">
|
||||||
|
{queryResult ? (
|
||||||
|
<pre className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(queryResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">Execute a query to see results</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-12 text-center">
|
||||||
|
<Database size={48} className="mx-auto mb-4 text-gray-600" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Select a Database</h3>
|
||||||
|
<p className="text-gray-500">Choose a database from the list to query it</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
apps/desktop-wallet/src/pages/Decoy/DecoyDashboard.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
EyeOff,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
ShieldAlert,
|
||||||
|
Info,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDecoyStore, DecoyWallet } from '../../store/decoy';
|
||||||
|
|
||||||
|
function SetupDecoyModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { setup, isLoading } = useDecoyStore();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setup(password);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Setup failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<ShieldAlert className="text-synor-400" />
|
||||||
|
Setup Decoy Wallets
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Create a "duress password" that opens decoy wallets instead of your real wallet.
|
||||||
|
This provides plausible deniability if forced to unlock your wallet.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Duress Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
|
||||||
|
{isLoading ? 'Setting up...' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateDecoyModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { createDecoy, isLoading } = useDecoyStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [balance, setBalance] = useState('0.1');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await createDecoy(name, parseFloat(balance));
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Create Decoy Wallet</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Wallet Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Savings"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Fake Balance (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={balance}
|
||||||
|
onChange={(e) => setBalance(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecoyCard({ decoy }: { decoy: DecoyWallet }) {
|
||||||
|
const { deleteDecoy } = useDecoyStore();
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">{decoy.name}</h4>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{decoy.address.slice(0, 20)}...</p>
|
||||||
|
</div>
|
||||||
|
{!showConfirm ? (
|
||||||
|
<button onClick={() => setShowConfirm(true)} className="text-gray-500 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setShowConfirm(false)} className="px-2 py-1 text-xs bg-gray-700 rounded">
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteDecoy(decoy.id)} className="px-2 py-1 text-xs bg-red-600 rounded">
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{decoy.balanceHuman}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecoyDashboard() {
|
||||||
|
const { isEnabled, decoys, isLoading, error, checkEnabled, fetchDecoys } = useDecoyStore();
|
||||||
|
const [showSetup, setShowSetup] = useState(false);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkEnabled();
|
||||||
|
fetchDecoys();
|
||||||
|
}, [checkEnabled, fetchDecoys]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<EyeOff className="text-synor-400" />
|
||||||
|
Decoy Wallets
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Plausible deniability for your crypto</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={fetchDecoys} className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
{isEnabled && (
|
||||||
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Decoy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEnabled ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<ShieldAlert size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">Decoy Wallets Not Enabled</h3>
|
||||||
|
<p className="text-gray-500 mb-4 max-w-md mx-auto">
|
||||||
|
Set up a duress password that opens fake wallets to protect your real funds
|
||||||
|
under coercion.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setShowSetup(true)} className="px-6 py-2 bg-synor-600 rounded-lg">
|
||||||
|
Enable Decoy Wallets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<ShieldAlert className="text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Decoy Protection Active</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Using your duress password will show decoy wallets instead of real funds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decoys.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<Wallet size={32} className="mx-auto text-gray-600 mb-2" />
|
||||||
|
<p className="text-gray-500">No decoy wallets created yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{decoys.map((d) => (
|
||||||
|
<DecoyCard key={d.id} decoy={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">How Decoy Wallets Work</p>
|
||||||
|
<p>
|
||||||
|
When unlocking with your duress password, decoy wallets are shown instead of your
|
||||||
|
real wallet. The decoys appear legitimate but contain minimal funds, protecting your
|
||||||
|
actual holdings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSetup && <SetupDecoyModal onClose={() => setShowSetup(false)} />}
|
||||||
|
{showCreate && <CreateDecoyModal onClose={() => setShowCreate(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
RefreshCw,
|
||||||
|
Gauge,
|
||||||
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFeeAnalyticsStore,
|
||||||
|
getCongestionColor,
|
||||||
|
getCongestionBgColor,
|
||||||
|
formatDuration,
|
||||||
|
FeeRecommendation,
|
||||||
|
} from '../../store/feeAnalytics';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee tier selection card
|
||||||
|
*/
|
||||||
|
function FeeTierCard({
|
||||||
|
recommendation,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
recommendation: FeeRecommendation;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const tierIcons = {
|
||||||
|
economy: Clock,
|
||||||
|
standard: CheckCircle,
|
||||||
|
priority: TrendingUp,
|
||||||
|
instant: Zap,
|
||||||
|
};
|
||||||
|
const tierColors = {
|
||||||
|
economy: 'text-blue-400 border-blue-500/30',
|
||||||
|
standard: 'text-green-400 border-green-500/30',
|
||||||
|
priority: 'text-yellow-400 border-yellow-500/30',
|
||||||
|
instant: 'text-red-400 border-red-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = tierIcons[recommendation.tier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`
|
||||||
|
p-4 rounded-xl border-2 transition-all text-left w-full
|
||||||
|
${isSelected
|
||||||
|
? `${tierColors[recommendation.tier]} bg-gray-800`
|
||||||
|
: 'border-gray-700 hover:border-gray-600 bg-gray-900'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon size={18} className={tierColors[recommendation.tier].split(' ')[0]} />
|
||||||
|
<span className="font-semibold capitalize">{recommendation.tier}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-xs bg-synor-600 px-2 py-0.5 rounded">Selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-2xl font-bold text-white mb-1">
|
||||||
|
{recommendation.feeRate.toFixed(2)}
|
||||||
|
<span className="text-sm text-gray-400 ml-1">sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400 mt-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
~{recommendation.estimatedBlocks} blocks
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{formatDuration(recommendation.estimatedTimeSecs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 mt-2">{recommendation.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool visualization bar
|
||||||
|
*/
|
||||||
|
function MempoolVisualization({
|
||||||
|
txCount,
|
||||||
|
percentile10,
|
||||||
|
percentile50,
|
||||||
|
percentile90,
|
||||||
|
}: {
|
||||||
|
txCount: number;
|
||||||
|
percentile10: number;
|
||||||
|
percentile50: number;
|
||||||
|
percentile90: number;
|
||||||
|
}) {
|
||||||
|
const maxTx = 300; // Visualization max
|
||||||
|
const fillPercent = Math.min((txCount / maxTx) * 100, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gray-400">Mempool Transactions</span>
|
||||||
|
<span className="text-lg font-bold">{txCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mempool fill bar */}
|
||||||
|
<div className="h-6 bg-gray-800 rounded-lg overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-green-500 via-yellow-500 to-red-500 transition-all duration-500"
|
||||||
|
style={{ width: `${fillPercent}%` }}
|
||||||
|
/>
|
||||||
|
{/* Fee distribution markers */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-between px-2">
|
||||||
|
<span className="text-xs text-white/70">Low</span>
|
||||||
|
<span className="text-xs text-white/70">High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fee distribution */}
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">10th %ile</p>
|
||||||
|
<p className="text-sm font-mono">{percentile10.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">Median</p>
|
||||||
|
<p className="text-sm font-mono">{percentile50.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">90th %ile</p>
|
||||||
|
<p className="text-sm font-mono">{percentile90.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple fee history chart (text-based)
|
||||||
|
*/
|
||||||
|
function FeeHistoryChart() {
|
||||||
|
const { analytics } = useFeeAnalyticsStore();
|
||||||
|
const history = analytics?.feeHistory || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-gray-500 text-center">No fee history available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find min/max for scaling
|
||||||
|
const maxFee = Math.max(...history.map((h) => h.maxFeeRate));
|
||||||
|
const minFee = Math.min(...history.map((h) => h.minFeeRate));
|
||||||
|
const range = maxFee - minFee || 1;
|
||||||
|
|
||||||
|
// Take last 12 hours for display
|
||||||
|
const recentHistory = history.slice(-12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gray-400">Fee Rate History (24h)</span>
|
||||||
|
<span className="text-xs text-gray-500">sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple bar chart */}
|
||||||
|
<div className="flex items-end gap-1 h-24">
|
||||||
|
{recentHistory.map((point, i) => {
|
||||||
|
const height = ((point.avgFeeRate - minFee) / range) * 100;
|
||||||
|
const date = new Date(point.timestamp * 1000);
|
||||||
|
const hour = date.getHours();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-synor-500 rounded-t transition-all hover:bg-synor-400"
|
||||||
|
style={{ height: `${Math.max(height, 5)}%` }}
|
||||||
|
title={`${point.avgFeeRate.toFixed(2)} sompi/byte at ${date.toLocaleTimeString()}`}
|
||||||
|
/>
|
||||||
|
{i % 3 === 0 && (
|
||||||
|
<span className="text-xs text-gray-600 mt-1">{hour}:00</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mt-2">
|
||||||
|
<span>{minFee.toFixed(1)}</span>
|
||||||
|
<span>{((maxFee + minFee) / 2).toFixed(1)}</span>
|
||||||
|
<span>{maxFee.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee calculator component
|
||||||
|
*/
|
||||||
|
function FeeCalculator() {
|
||||||
|
const { analytics, selectedTier } = useFeeAnalyticsStore();
|
||||||
|
const [txSize, setTxSize] = useState(250); // Default tx size
|
||||||
|
const [calculatedFee, setCalculatedFee] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const selectedRec = useMemo(() => {
|
||||||
|
return analytics?.recommendations.find((r) => r.tier === selectedTier);
|
||||||
|
}, [analytics, selectedTier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRec) {
|
||||||
|
setCalculatedFee(Math.ceil(txSize * selectedRec.feeRate));
|
||||||
|
}
|
||||||
|
}, [txSize, selectedRec]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<DollarSign size={16} />
|
||||||
|
Fee Calculator
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 block mb-1">
|
||||||
|
Transaction Size (bytes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={txSize}
|
||||||
|
onChange={(e) => setTxSize(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Tier:</span>
|
||||||
|
<span className="capitalize font-medium">{selectedTier}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Rate:</span>
|
||||||
|
<span className="font-mono">{selectedRec?.feeRate.toFixed(2) || '0'} sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Estimated Fee:</span>
|
||||||
|
<span className="text-xl font-bold text-synor-400">
|
||||||
|
{calculatedFee?.toLocaleString() || '0'} sompi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
= {((calculatedFee || 0) / 100_000_000).toFixed(8)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Fee Analytics Dashboard
|
||||||
|
*/
|
||||||
|
export default function FeeAnalyticsDashboard() {
|
||||||
|
const {
|
||||||
|
analytics,
|
||||||
|
selectedTier,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
autoRefresh,
|
||||||
|
fetchAnalytics,
|
||||||
|
setSelectedTier,
|
||||||
|
setAutoRefresh,
|
||||||
|
} = useFeeAnalyticsStore();
|
||||||
|
|
||||||
|
// Fetch analytics on mount and set up auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnalytics();
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(fetchAnalytics, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoRefresh, fetchAnalytics]);
|
||||||
|
|
||||||
|
if (isLoading && !analytics) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Activity className="text-synor-400" />
|
||||||
|
Fee Market Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Monitor network fees and choose optimal transaction costs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="rounded bg-gray-800 border-gray-600"
|
||||||
|
/>
|
||||||
|
Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={fetchAnalytics}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analytics && (
|
||||||
|
<>
|
||||||
|
{/* Network Status Bar */}
|
||||||
|
<div className={`rounded-xl p-4 border ${getCongestionBgColor(analytics.networkCongestion)}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Gauge className={getCongestionColor(analytics.networkCongestion)} size={24} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Network Congestion</p>
|
||||||
|
<p className={`text-lg font-bold capitalize ${getCongestionColor(analytics.networkCongestion)}`}>
|
||||||
|
{analytics.networkCongestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Block Time</p>
|
||||||
|
<p className="text-lg font-mono">{analytics.blockTargetTimeSecs}s</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Avg Fee Rate</p>
|
||||||
|
<p className="text-lg font-mono">{analytics.mempool.avgFeeRate.toFixed(2)} sompi/b</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Mempool Size</p>
|
||||||
|
<p className="text-lg font-mono">
|
||||||
|
{(analytics.mempool.totalSizeBytes / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fee Tier Selection */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Zap className="text-synor-400" size={20} />
|
||||||
|
Select Fee Tier
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{analytics.recommendations.map((rec) => (
|
||||||
|
<FeeTierCard
|
||||||
|
key={rec.tier}
|
||||||
|
recommendation={rec}
|
||||||
|
isSelected={selectedTier === rec.tier}
|
||||||
|
onSelect={() => setSelectedTier(rec.tier)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Mempool Visualization */}
|
||||||
|
<MempoolVisualization
|
||||||
|
txCount={analytics.mempool.txCount}
|
||||||
|
percentile10={analytics.mempool.percentile10}
|
||||||
|
percentile50={analytics.mempool.percentile50}
|
||||||
|
percentile90={analytics.mempool.percentile90}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fee History Chart */}
|
||||||
|
<FeeHistoryChart />
|
||||||
|
|
||||||
|
{/* Fee Calculator */}
|
||||||
|
<FeeCalculator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">About Fee Selection</p>
|
||||||
|
<p>
|
||||||
|
Fee rates are measured in sompi per byte. Higher fees generally result in faster
|
||||||
|
confirmation times, especially during network congestion. The recommendations above
|
||||||
|
are based on current mempool conditions and adjust automatically as network activity
|
||||||
|
changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
501
apps/desktop-wallet/src/pages/Governance/GovernanceDashboard.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Vote,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useGovernanceStore,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusColor,
|
||||||
|
calculateVotePercentage,
|
||||||
|
} from '../../store/governance';
|
||||||
|
|
||||||
|
export default function GovernanceDashboard() {
|
||||||
|
const {
|
||||||
|
proposals,
|
||||||
|
votingPower,
|
||||||
|
isLoading,
|
||||||
|
isVoting,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchProposals,
|
||||||
|
fetchVotingPower,
|
||||||
|
createProposal,
|
||||||
|
vote,
|
||||||
|
delegate,
|
||||||
|
} = useGovernanceStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'proposals' | 'voting-power'>('proposals');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [showDelegateForm, setShowDelegateForm] = useState(false);
|
||||||
|
const [expandedProposal, setExpandedProposal] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newDescription, setNewDescription] = useState('');
|
||||||
|
const [newActions, setNewActions] = useState('');
|
||||||
|
|
||||||
|
// Delegate form state
|
||||||
|
const [delegateAddress, setDelegateAddress] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProposals();
|
||||||
|
fetchVotingPower();
|
||||||
|
}, [fetchProposals, fetchVotingPower]);
|
||||||
|
|
||||||
|
const handleCreateProposal = async () => {
|
||||||
|
if (!newTitle || !newDescription) return;
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
// Parse actions as string array
|
||||||
|
let actions: string[] = [];
|
||||||
|
if (newActions) {
|
||||||
|
try {
|
||||||
|
actions = JSON.parse(newActions);
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, treat as single action
|
||||||
|
actions = [newActions];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await createProposal(newTitle, newDescription, actions);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewTitle('');
|
||||||
|
setNewDescription('');
|
||||||
|
setNewActions('');
|
||||||
|
fetchProposals();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVote = async (proposalId: string, support: 'for' | 'against' | 'abstain') => {
|
||||||
|
await vote(proposalId, support);
|
||||||
|
fetchProposals();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelegate = async () => {
|
||||||
|
if (!delegateAddress) return;
|
||||||
|
await delegate(delegateAddress);
|
||||||
|
setShowDelegateForm(false);
|
||||||
|
setDelegateAddress('');
|
||||||
|
fetchVotingPower();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock size={16} className="text-yellow-400" />;
|
||||||
|
case 'active':
|
||||||
|
return <Vote size={16} className="text-blue-400" />;
|
||||||
|
case 'passed':
|
||||||
|
return <CheckCircle size={16} className="text-green-400" />;
|
||||||
|
case 'rejected':
|
||||||
|
return <XCircle size={16} className="text-red-400" />;
|
||||||
|
case 'executed':
|
||||||
|
return <Zap size={16} className="text-purple-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock size={16} className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVotes = (votes: string) => {
|
||||||
|
const num = parseFloat(votes);
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
return votes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Governance</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Participate in Synor DAO decisions</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchProposals();
|
||||||
|
fetchVotingPower();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create Proposal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Proposal Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Create Proposal</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
placeholder="Proposal title"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={newDescription}
|
||||||
|
onChange={(e) => setNewDescription(e.target.value)}
|
||||||
|
placeholder="Describe your proposal in detail..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Actions (optional JSON array)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={newActions}
|
||||||
|
onChange={(e) => setNewActions(e.target.value)}
|
||||||
|
placeholder='["action1", "action2"]'
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateProposal}
|
||||||
|
disabled={!newTitle || !newDescription || isCreating}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delegate Modal */}
|
||||||
|
{showDelegateForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Delegate Voting Power</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Delegate your voting power to another address. They will be able to vote on your
|
||||||
|
behalf, but you retain ownership of your tokens.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Delegate Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={delegateAddress}
|
||||||
|
onChange={(e) => setDelegateAddress(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDelegateForm(false)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelegate}
|
||||||
|
disabled={!delegateAddress}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delegate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voting Power Card */}
|
||||||
|
{votingPower && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-synor-600/20 rounded-lg">
|
||||||
|
<TrendingUp size={24} className="text-synor-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Your Voting Power</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{formatVotes(votingPower.votingPower)} SYN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Delegated Out</p>
|
||||||
|
<p className="text-white">{formatVotes(votingPower.delegatedOut)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Delegated In</p>
|
||||||
|
<p className="text-white">{formatVotes(votingPower.delegatedIn)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDelegateForm(true)}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Users size={16} className="inline mr-2" />
|
||||||
|
Delegate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('proposals')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'proposals'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Proposals ({proposals.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('voting-power')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'voting-power'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Voting Power
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Proposals Tab */}
|
||||||
|
{activeTab === 'proposals' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{proposals.map((proposal) => {
|
||||||
|
const votePercentages = calculateVotePercentage(
|
||||||
|
proposal.forVotes,
|
||||||
|
proposal.againstVotes,
|
||||||
|
proposal.abstainVotes
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={proposal.id}
|
||||||
|
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||||||
|
onClick={() => setExpandedProposal(expandedProposal === proposal.id ? null : proposal.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{getStatusIcon(proposal.status)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{proposal.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
#{proposal.id.slice(0, 8)} • by {proposal.proposer.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded ${getStatusColor(proposal.status)}`}>
|
||||||
|
{getStatusLabel(proposal.status)}
|
||||||
|
</span>
|
||||||
|
{expandedProposal === proposal.id ? (
|
||||||
|
<ChevronUp size={20} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={20} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedProposal === proposal.id && (
|
||||||
|
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
|
||||||
|
<p className="text-gray-300 mb-4">{proposal.description}</p>
|
||||||
|
|
||||||
|
{/* Vote Progress */}
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-green-400">For</span>
|
||||||
|
<span className="text-white">{formatVotes(proposal.forVotes)} ({votePercentages.for.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-green-500" style={{ width: `${votePercentages.for}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-red-400">Against</span>
|
||||||
|
<span className="text-white">{formatVotes(proposal.againstVotes)} ({votePercentages.against.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-red-500" style={{ width: `${votePercentages.against}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-400">Abstain</span>
|
||||||
|
<span className="text-white">{formatVotes(proposal.abstainVotes)} ({votePercentages.abstain.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gray-500" style={{ width: `${votePercentages.abstain}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Start Block</p>
|
||||||
|
<p className="text-white">#{proposal.startBlock.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">End Block</p>
|
||||||
|
<p className="text-white">#{proposal.endBlock.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voting Buttons */}
|
||||||
|
{proposal.status === 'active' && !proposal.userVoted && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleVote(proposal.id, 'for');
|
||||||
|
}}
|
||||||
|
disabled={isVoting}
|
||||||
|
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Vote For
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleVote(proposal.id, 'against');
|
||||||
|
}}
|
||||||
|
disabled={isVoting}
|
||||||
|
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Vote Against
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleVote(proposal.id, 'abstain');
|
||||||
|
}}
|
||||||
|
disabled={isVoting}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Abstain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{proposal.userVoted && (
|
||||||
|
<div className="text-center py-2 text-gray-400">
|
||||||
|
You have already voted on this proposal {proposal.userVote && `(${proposal.userVote})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{proposals.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No governance proposals yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voting Power Tab */}
|
||||||
|
{activeTab === 'voting-power' && votingPower && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Voting Power Breakdown</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||||||
|
<span className="text-gray-400">Your Voting Power</span>
|
||||||
|
<span className="text-white font-medium">{formatVotes(votingPower.votingPower)} SYN</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||||||
|
<span className="text-gray-400">Delegated Out</span>
|
||||||
|
<span className="text-white font-medium">{formatVotes(votingPower.delegatedOut)} SYN</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||||||
|
<span className="text-gray-400">Delegated In</span>
|
||||||
|
<span className="text-white font-medium">{formatVotes(votingPower.delegatedIn)} SYN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{votingPower.delegate && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Delegation</h3>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-800 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">Your votes are delegated to</p>
|
||||||
|
<code className="text-white font-mono">{votingPower.delegate}</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => delegate('')}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
apps/desktop-wallet/src/pages/Hardware/HardwareWalletPage.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Usb,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Shield,
|
||||||
|
Cpu,
|
||||||
|
Key,
|
||||||
|
Fingerprint,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useHardwareStore,
|
||||||
|
HardwareDevice,
|
||||||
|
HardwareAddress,
|
||||||
|
} from '../../store/hardware';
|
||||||
|
|
||||||
|
export default function HardwareWalletPage() {
|
||||||
|
const {
|
||||||
|
devices,
|
||||||
|
selectedDevice,
|
||||||
|
isScanning,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
detectDevices,
|
||||||
|
selectDevice,
|
||||||
|
getAddress,
|
||||||
|
} = useHardwareStore();
|
||||||
|
|
||||||
|
const [addresses, setAddresses] = useState<HardwareAddress[]>([]);
|
||||||
|
const [loadingAddress, setLoadingAddress] = useState(false);
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||||
|
const [accountIndex, setAccountIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
detectDevices();
|
||||||
|
}, [detectDevices]);
|
||||||
|
|
||||||
|
const handleSelectDevice = (device: HardwareDevice) => {
|
||||||
|
selectDevice(device);
|
||||||
|
setAddresses([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGetAddress = async () => {
|
||||||
|
if (!selectedDevice) return;
|
||||||
|
|
||||||
|
setLoadingAddress(true);
|
||||||
|
try {
|
||||||
|
const address = await getAddress(selectedDevice.id, accountIndex);
|
||||||
|
setAddresses((prev) => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (prev.some((a) => a.path === address.path)) return prev;
|
||||||
|
return [...prev, address];
|
||||||
|
});
|
||||||
|
setAccountIndex((prev) => prev + 1);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} finally {
|
||||||
|
setLoadingAddress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAddress = (address: string) => {
|
||||||
|
navigator.clipboard.writeText(address);
|
||||||
|
setCopiedAddress(address);
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeviceIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'ledger':
|
||||||
|
return <Cpu className="text-blue-400" size={24} />;
|
||||||
|
case 'trezor':
|
||||||
|
return <Shield className="text-green-400" size={24} />;
|
||||||
|
default:
|
||||||
|
return <Usb className="text-gray-400" size={24} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Hardware Wallet</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Connect Ledger or Trezor devices</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => detectDevices()}
|
||||||
|
disabled={isScanning}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isScanning ? 'animate-spin' : ''} />
|
||||||
|
Scan Devices
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="bg-blue-900/20 border border-blue-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Fingerprint className="text-blue-400 mt-0.5" size={20} />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-blue-400">Secure Hardware Signing</h3>
|
||||||
|
<p className="text-sm text-blue-300/70 mt-1">
|
||||||
|
Your private keys never leave the hardware device. All transactions are
|
||||||
|
signed directly on the device for maximum security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Device List */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Available Devices</h2>
|
||||||
|
|
||||||
|
{isScanning ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="animate-spin text-synor-400" size={32} />
|
||||||
|
</div>
|
||||||
|
) : devices.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
onClick={() => handleSelectDevice(device)}
|
||||||
|
className={`w-full flex items-center gap-4 p-4 rounded-lg border transition-colors ${
|
||||||
|
selectedDevice?.id === device.id
|
||||||
|
? 'bg-synor-600/20 border-synor-500'
|
||||||
|
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-gray-900 rounded-lg">
|
||||||
|
{getDeviceIcon(device.deviceType)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<h3 className="font-medium text-white">{device.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400 capitalize">
|
||||||
|
{device.deviceType}
|
||||||
|
{device.firmwareVersion && ` • v${device.firmwareVersion}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{device.connected && (
|
||||||
|
<span className="px-2 py-1 bg-green-600/20 text-green-400 text-xs rounded-full">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedDevice?.id === device.id && (
|
||||||
|
<Check className="text-synor-400" size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Usb className="mx-auto mb-4 text-gray-600" size={48} />
|
||||||
|
<p className="text-gray-500">No devices found</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Connect your hardware wallet and click "Scan Devices"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Actions */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">
|
||||||
|
{selectedDevice ? selectedDevice.name : 'Select a Device'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{selectedDevice ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Get Address */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||||
|
Derive Addresses
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleGetAddress}
|
||||||
|
disabled={loadingAddress}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingAddress ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
Deriving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key size={16} />
|
||||||
|
Get Next Address
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Confirm on your device to reveal the address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address List */}
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||||
|
Derived Addresses
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{addresses.map((addr, index) => (
|
||||||
|
<div
|
||||||
|
key={addr.path}
|
||||||
|
className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-500 w-8">#{index}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<code className="text-sm text-white truncate block">
|
||||||
|
{addr.address}
|
||||||
|
</code>
|
||||||
|
<span className="text-xs text-gray-500">{addr.path}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => copyAddress(addr.address)}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{copiedAddress === addr.address ? (
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Device Info */}
|
||||||
|
<div className="pt-4 border-t border-gray-800">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||||
|
Device Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Type</p>
|
||||||
|
<p className="text-white capitalize">{selectedDevice.deviceType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Status</p>
|
||||||
|
<p className={selectedDevice.connected ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{selectedDevice.connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedDevice.firmwareVersion && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Firmware</p>
|
||||||
|
<p className="text-white">v{selectedDevice.firmwareVersion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Select a device from the list to manage addresses and sign transactions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Setup Instructions</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">Connect Device</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Plug in your Ledger or Trezor via USB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">Unlock Device</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Enter your PIN on the hardware wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">Open App</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Open the Synor app on your device
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '../lib/tauri';
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
ArrowDownLeft,
|
ArrowDownLeft,
|
||||||
|
|
|
||||||
264
apps/desktop-wallet/src/pages/Hosting/HostingDashboard.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Upload,
|
||||||
|
Shield,
|
||||||
|
Link2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useHostingStore } from '../../store/hosting';
|
||||||
|
|
||||||
|
export default function HostingDashboard() {
|
||||||
|
const {
|
||||||
|
sites,
|
||||||
|
isLoading,
|
||||||
|
isDeploying,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchSites,
|
||||||
|
registerName,
|
||||||
|
deploySite,
|
||||||
|
deleteSite,
|
||||||
|
} = useHostingStore();
|
||||||
|
|
||||||
|
const [showRegisterModal, setShowRegisterModal] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [deployModal, setDeployModal] = useState<string | null>(null);
|
||||||
|
const [contentCid, setContentCid] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSites();
|
||||||
|
}, [fetchSites]);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!newName) return;
|
||||||
|
await registerName(newName);
|
||||||
|
setShowRegisterModal(false);
|
||||||
|
setNewName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
if (!deployModal || !contentCid) return;
|
||||||
|
await deploySite(deployModal, contentCid);
|
||||||
|
setDeployModal(null);
|
||||||
|
setContentCid('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Decentralized Hosting</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Host websites on the Synor network</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchSites}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRegisterModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Register Name
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sites Grid */}
|
||||||
|
{sites.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<div
|
||||||
|
key={site.name}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-synor-600/20 rounded-lg">
|
||||||
|
<Globe size={24} className="text-synor-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{site.name}</h3>
|
||||||
|
<a
|
||||||
|
href={`https://${site.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-synor-400 hover:text-synor-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{site.domain}
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSite(site.name)}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{site.customDomain && (
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||||
|
<Link2 size={14} className="text-gray-500" />
|
||||||
|
<span className="text-gray-400">{site.customDomain}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{site.sslEnabled && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-full">
|
||||||
|
<Shield size={12} />
|
||||||
|
SSL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{site.contentCid && (
|
||||||
|
<span className="px-2 py-1 bg-gray-800 text-gray-400 text-xs rounded-full">
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Bandwidth Used</p>
|
||||||
|
<p className="text-white">{(site.bandwidthUsed / 1_073_741_824).toFixed(2)} GB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Monthly Cost</p>
|
||||||
|
<p className="text-white">{site.monthlyCost} SYN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDeployModal(site.name)}
|
||||||
|
className="w-full mt-4 flex items-center justify-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
Deploy Content
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
|
||||||
|
<Globe size={48} className="mx-auto mb-4 text-gray-600" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">No sites yet</h3>
|
||||||
|
<p className="text-gray-500 mb-4">Register a name to start hosting your website</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRegisterModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Register Name
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Register Modal */}
|
||||||
|
{showRegisterModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Register Name</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Site Name</label>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||||
|
placeholder="mysite"
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-l-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="px-4 py-2 bg-gray-700 border border-gray-700 rounded-r-lg text-gray-400">
|
||||||
|
.synor.site
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
3-63 characters, lowercase letters, numbers, and hyphens only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowRegisterModal(false); setNewName(''); }}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={!newName || newName.length < 3 || isDeploying}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeploying ? 'Registering...' : 'Register'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deploy Modal */}
|
||||||
|
{deployModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Deploy to {deployModal}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Content CID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={contentCid}
|
||||||
|
onChange={(e) => setContentCid(e.target.value)}
|
||||||
|
placeholder="bafybeig..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Upload your site files to Storage first, then paste the CID here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setDeployModal(null); setContentCid(''); }}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={!contentCid || isDeploying}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeploying ? 'Deploying...' : 'Deploy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ArrowUpDown, Info, AlertCircle, Plus, RefreshCw, Loader2, X, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { useLimitOrdersStore, formatAmount } from '../../store/limitOrders';
|
||||||
|
|
||||||
|
export default function LimitOrdersDashboard() {
|
||||||
|
const {
|
||||||
|
orders,
|
||||||
|
orderBook,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
listOrders,
|
||||||
|
getOrderBook,
|
||||||
|
createOrder,
|
||||||
|
cancelOrder,
|
||||||
|
clearError,
|
||||||
|
} = useLimitOrdersStore();
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [orderType, setOrderType] = useState<'buy' | 'sell'>('buy');
|
||||||
|
const [tradingPair, setTradingPair] = useState('SYN/USDT');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listOrders();
|
||||||
|
getOrderBook('SYN/USDT');
|
||||||
|
}, [listOrders, getOrderBook]);
|
||||||
|
|
||||||
|
const handleCreateOrder = async () => {
|
||||||
|
if (!amount || !price) return;
|
||||||
|
try {
|
||||||
|
await createOrder(
|
||||||
|
orderType,
|
||||||
|
tradingPair,
|
||||||
|
parseFloat(amount) * 100_000_000,
|
||||||
|
parseFloat(price)
|
||||||
|
);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setAmount('');
|
||||||
|
setPrice('');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeOrders = orders.filter(o => o.status === 'open');
|
||||||
|
const filledOrders = orders.filter(o => o.status === 'filled');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<ArrowUpDown className="text-synor-400" />
|
||||||
|
Limit Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Set buy/sell orders at specific prices</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { listOrders(); getOrderBook('SYN/USDT'); }}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 hover:bg-synor-700"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
New Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-200/70">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Book */}
|
||||||
|
{orderBook && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3 text-green-400 flex items-center gap-2">
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
Buy Orders ({orderBook.bids.length})
|
||||||
|
</h3>
|
||||||
|
{orderBook.bids.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No buy orders</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{orderBook.bids.slice(0, 5).map((bid, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-sm p-2 bg-green-500/10 rounded">
|
||||||
|
<span className="text-green-400">${bid.price.toFixed(4)}</span>
|
||||||
|
<span className="text-gray-400">{formatAmount(bid.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3 text-red-400 flex items-center gap-2">
|
||||||
|
<TrendingDown size={16} />
|
||||||
|
Sell Orders ({orderBook.asks.length})
|
||||||
|
</h3>
|
||||||
|
{orderBook.asks.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No sell orders</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{orderBook.asks.slice(0, 5).map((ask, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-sm p-2 bg-red-500/10 rounded">
|
||||||
|
<span className="text-red-400">${ask.price.toFixed(4)}</span>
|
||||||
|
<span className="text-gray-400">{formatAmount(ask.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Orders */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Active Orders ({activeOrders.length})</h3>
|
||||||
|
{activeOrders.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No active orders</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeOrders.map((order) => (
|
||||||
|
<div key={order.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
|
order.orderType === 'buy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{order.orderType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{order.pair}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatAmount(order.amount)} @ ${order.price.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{((order.filledAmount / order.amount) * 100).toFixed(0)}% filled
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => cancelOrder(order.id)}
|
||||||
|
className="p-1 text-red-400 hover:bg-red-500/20 rounded"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order History */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Filled Orders ({filledOrders.length})</h3>
|
||||||
|
{filledOrders.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No filled orders</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filledOrders.slice(0, 10).map((order) => (
|
||||||
|
<div key={order.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
|
order.orderType === 'buy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{order.orderType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{order.pair}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatAmount(order.amount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-400 text-sm">Filled</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Order Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium">Create Limit Order</h3>
|
||||||
|
<button onClick={() => setShowCreateModal(false)} className="text-gray-400 hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setOrderType('buy')}
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium ${
|
||||||
|
orderType === 'buy' ? 'bg-green-600 text-white' : 'bg-gray-800 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOrderType('sell')}
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium ${
|
||||||
|
orderType === 'sell' ? 'bg-red-600 text-white' : 'bg-gray-800 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Trading Pair</label>
|
||||||
|
<select
|
||||||
|
value={tradingPair}
|
||||||
|
onChange={(e) => setTradingPair(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="SYN/USDT">SYN/USDT</option>
|
||||||
|
<option value="SYN/BTC">SYN/BTC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Price (USD)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
placeholder="0.0000"
|
||||||
|
step="0.0001"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateOrder}
|
||||||
|
disabled={isLoading || !amount || !price}
|
||||||
|
className={`w-full py-3 rounded-lg font-medium disabled:opacity-50 ${
|
||||||
|
orderType === 'buy' ? 'bg-green-600 hover:bg-green-700' : 'bg-red-600 hover:bg-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : `Place ${orderType.toUpperCase()} Order`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Limit orders are executed on-chain through smart contracts, ensuring trustless
|
||||||
|
and decentralized trading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
apps/desktop-wallet/src/pages/Market/MarketDashboard.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useMarketStore, formatPrice, formatChange } from '../../store/market';
|
||||||
|
|
||||||
|
const TIME_RANGES = [
|
||||||
|
{ label: '1H', value: '1h' },
|
||||||
|
{ label: '24H', value: '24h' },
|
||||||
|
{ label: '7D', value: '7d' },
|
||||||
|
{ label: '30D', value: '30d' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LIMIT_MAP: Record<string, number> = {
|
||||||
|
'1h': 60,
|
||||||
|
'24h': 96,
|
||||||
|
'7d': 168,
|
||||||
|
'30d': 720,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketDashboard() {
|
||||||
|
const {
|
||||||
|
prices,
|
||||||
|
history,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchPrices,
|
||||||
|
fetchHistory,
|
||||||
|
} = useMarketStore();
|
||||||
|
|
||||||
|
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPrices(['SYN', 'BTC', 'ETH']);
|
||||||
|
const interval = setInterval(() => fetchPrices(['SYN', 'BTC', 'ETH']), 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchPrices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSymbol) {
|
||||||
|
fetchHistory(selectedSymbol, timeRange, LIMIT_MAP[timeRange]);
|
||||||
|
}
|
||||||
|
}, [selectedSymbol, timeRange, fetchHistory]);
|
||||||
|
|
||||||
|
const selectedPrice = prices.find((p) => p.symbol === selectedSymbol);
|
||||||
|
const priceHistory = selectedSymbol ? history[selectedSymbol] || [] : [];
|
||||||
|
|
||||||
|
// Simple chart rendering
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!priceHistory || priceHistory.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||||
|
No price data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minPrice = Math.min(...priceHistory.map((p) => p.price));
|
||||||
|
const maxPrice = Math.max(...priceHistory.map((p) => p.price));
|
||||||
|
const priceRange = maxPrice - minPrice || 1;
|
||||||
|
|
||||||
|
const isPositive =
|
||||||
|
priceHistory[priceHistory.length - 1]?.price >= priceHistory[0]?.price;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64 relative">
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between text-xs text-gray-500">
|
||||||
|
<span>${maxPrice.toFixed(4)}</span>
|
||||||
|
<span>${((maxPrice + minPrice) / 2).toFixed(4)}</span>
|
||||||
|
<span>${minPrice.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart area */}
|
||||||
|
<div className="ml-16 h-full relative">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${priceHistory.length} 100`}
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{/* Grid lines */}
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="25"
|
||||||
|
x2={priceHistory.length}
|
||||||
|
y2="25"
|
||||||
|
stroke="#374151"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="50"
|
||||||
|
x2={priceHistory.length}
|
||||||
|
y2="50"
|
||||||
|
stroke="#374151"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="75"
|
||||||
|
x2={priceHistory.length}
|
||||||
|
y2="75"
|
||||||
|
stroke="#374151"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Price line */}
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke={isPositive ? '#10b981' : '#ef4444'}
|
||||||
|
strokeWidth="2"
|
||||||
|
points={priceHistory
|
||||||
|
.map((p, i) => {
|
||||||
|
const y = 100 - ((p.price - minPrice) / priceRange) * 100;
|
||||||
|
return `${i},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Area fill */}
|
||||||
|
<polygon
|
||||||
|
fill={isPositive ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)'}
|
||||||
|
points={`0,100 ${priceHistory
|
||||||
|
.map((p, i) => {
|
||||||
|
const y = 100 - ((p.price - minPrice) / priceRange) * 100;
|
||||||
|
return `${i},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ')} ${priceHistory.length - 1},100`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Market</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Price charts and market data</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchPrices(['SYN', 'BTC', 'ETH'])}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Price List */}
|
||||||
|
<div className="lg:col-span-1 bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Prices</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{prices.map((price) => (
|
||||||
|
<button
|
||||||
|
key={price.symbol}
|
||||||
|
onClick={() => setSelectedSymbol(price.symbol)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded-lg transition-colors ${
|
||||||
|
selectedSymbol === price.symbol
|
||||||
|
? 'bg-synor-600/20 border border-synor-500'
|
||||||
|
: 'bg-gray-800 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gray-700 rounded-full flex items-center justify-center text-sm font-bold text-white">
|
||||||
|
{price.symbol.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-white">{price.symbol}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-white">{formatPrice(price.priceUsd)}</p>
|
||||||
|
<p
|
||||||
|
className={`text-xs flex items-center gap-1 ${
|
||||||
|
price.change24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{price.change24h >= 0 ? (
|
||||||
|
<TrendingUp size={12} />
|
||||||
|
) : (
|
||||||
|
<TrendingDown size={12} />
|
||||||
|
)}
|
||||||
|
{formatChange(price.change24h)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{prices.length === 0 && !isLoading && (
|
||||||
|
<div className="text-center py-8 text-gray-500">No price data available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
{selectedPrice ? (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">{selectedPrice.symbol}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-3 mt-1">
|
||||||
|
<span className="text-3xl font-bold text-white">
|
||||||
|
{formatPrice(selectedPrice.priceUsd)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1 text-lg ${
|
||||||
|
selectedPrice.change24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedPrice.change24h >= 0 ? (
|
||||||
|
<TrendingUp size={20} />
|
||||||
|
) : (
|
||||||
|
<TrendingDown size={20} />
|
||||||
|
)}
|
||||||
|
{formatChange(selectedPrice.change24h)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time range selector */}
|
||||||
|
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
|
||||||
|
{TIME_RANGES.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
onClick={() => setTimeRange(range.value)}
|
||||||
|
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||||
|
timeRange === range.value
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{renderChart()}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-6 pt-6 border-t border-gray-800">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-1">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
24h Volume
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium text-white">
|
||||||
|
{formatPrice(selectedPrice.volume24h)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-1">
|
||||||
|
<DollarSign size={14} />
|
||||||
|
Market Cap
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-medium text-white">
|
||||||
|
{formatPrice(selectedPrice.marketCap)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-96 flex items-center justify-center text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3 size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Select a token to view chart</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Updated */}
|
||||||
|
{prices.length > 0 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||||
|
<Clock size={14} />
|
||||||
|
Last updated: {new Date().toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
apps/desktop-wallet/src/pages/Mixer/MixerDashboard.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Shuffle, Info, AlertCircle, RefreshCw, Loader2, X } from 'lucide-react';
|
||||||
|
import { useMixerStore, formatAmount, formatDenomination } from '../../store/mixer';
|
||||||
|
|
||||||
|
export default function MixerDashboard() {
|
||||||
|
const {
|
||||||
|
denominations,
|
||||||
|
poolStatus,
|
||||||
|
requests,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadDenominations,
|
||||||
|
getPoolStatus,
|
||||||
|
createRequest,
|
||||||
|
listRequests,
|
||||||
|
cancelRequest,
|
||||||
|
clearError,
|
||||||
|
} = useMixerStore();
|
||||||
|
|
||||||
|
const [selectedDenom, setSelectedDenom] = useState<number | null>(null);
|
||||||
|
const [outputAddress, setOutputAddress] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDenominations();
|
||||||
|
listRequests();
|
||||||
|
}, [loadDenominations, listRequests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load pool status for first denomination when available
|
||||||
|
if (denominations.length > 0 && !selectedDenom) {
|
||||||
|
setSelectedDenom(denominations[0]);
|
||||||
|
getPoolStatus(denominations[0]);
|
||||||
|
}
|
||||||
|
}, [denominations, selectedDenom, getPoolStatus]);
|
||||||
|
|
||||||
|
const handleSelectDenom = (denom: number) => {
|
||||||
|
setSelectedDenom(denom);
|
||||||
|
getPoolStatus(denom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRequest = async () => {
|
||||||
|
if (!selectedDenom || !outputAddress) return;
|
||||||
|
try {
|
||||||
|
await createRequest(selectedDenom, selectedDenom, outputAddress);
|
||||||
|
setOutputAddress('');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = requests.filter(r => r.status === 'pending' || r.status === 'mixing');
|
||||||
|
const completedRequests = requests.filter(r => r.status === 'completed');
|
||||||
|
const currentPool = selectedDenom ? poolStatus[selectedDenom] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Shuffle className="text-synor-400" />
|
||||||
|
Transaction Mixer
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Enhanced privacy through coin mixing</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { loadDenominations(); listRequests(); }}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-200/70">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Denomination Selection */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Select Mixing Denomination</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{denominations.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">Loading denominations...</p>
|
||||||
|
) : (
|
||||||
|
denominations.map((denom) => (
|
||||||
|
<button
|
||||||
|
key={denom}
|
||||||
|
onClick={() => handleSelectDenom(denom)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||||
|
selectedDenom === denom
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatDenomination(denom)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pool Status */}
|
||||||
|
{currentPool && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Denomination</p>
|
||||||
|
<p className="text-xl font-bold">{formatDenomination(currentPool.denomination)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Participants</p>
|
||||||
|
<p className="text-xl font-bold">{currentPool.participants}/{currentPool.requiredParticipants}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Status</p>
|
||||||
|
<p className={`text-xl font-bold ${
|
||||||
|
currentPool.status === 'mixing' ? 'text-yellow-400' :
|
||||||
|
currentPool.status === 'completed' ? 'text-green-400' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{currentPool.status.charAt(0).toUpperCase() + currentPool.status.slice(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Est. Time</p>
|
||||||
|
<p className="text-xl font-bold text-synor-400">
|
||||||
|
{currentPool.estimatedTimeSecs ? `${Math.ceil(currentPool.estimatedTimeSecs / 60)}m` : '--'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Mix Request */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Join Mixing Pool</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Selected Amount</label>
|
||||||
|
<div className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-lg font-bold">
|
||||||
|
{selectedDenom ? formatDenomination(selectedDenom) : 'Select a denomination above'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Output Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={outputAddress}
|
||||||
|
onChange={(e) => setOutputAddress(e.target.value)}
|
||||||
|
placeholder="synor1... (fresh address for privacy)"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:border-synor-500 outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Use a new address that hasn't received funds before</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateRequest}
|
||||||
|
disabled={isLoading || !selectedDenom || !outputAddress}
|
||||||
|
className="w-full py-3 bg-synor-600 rounded-lg font-medium hover:bg-synor-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <Shuffle size={18} />}
|
||||||
|
Join Pool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Requests */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4 text-yellow-400">Pending Mixes ({pendingRequests.length})</h3>
|
||||||
|
{pendingRequests.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No pending mix requests</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingRequests.map((req) => (
|
||||||
|
<div key={req.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm">{formatAmount(req.amount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Status: {req.status}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
|
req.status === 'mixing' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-gray-700 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{req.status}
|
||||||
|
</span>
|
||||||
|
{req.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelRequest(req.id)}
|
||||||
|
className="p-1 text-red-400 hover:bg-red-500/20 rounded"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Requests */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4 text-green-400">Completed Mixes ({completedRequests.length})</h3>
|
||||||
|
{completedRequests.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No completed mixes</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<div key={req.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm">{formatAmount(req.amount)}</p>
|
||||||
|
<p className="text-xs text-gray-500 font-mono truncate max-w-xs">→ {req.outputAddress}</p>
|
||||||
|
{req.txId && (
|
||||||
|
<p className="text-xs text-synor-400 font-mono truncate max-w-xs">TX: {req.txId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">How Mixing Works</h3>
|
||||||
|
<ol className="space-y-3 text-sm text-gray-400">
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">1</span>
|
||||||
|
<span>Select a denomination and provide an output address</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">2</span>
|
||||||
|
<span>Wait for enough participants to join the pool</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">3</span>
|
||||||
|
<span>All participants' coins are mixed together cryptographically</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">4</span>
|
||||||
|
<span>Receive coins at your output address with broken transaction history</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Mixing uses cryptographic techniques to make transactions untraceable while remaining
|
||||||
|
fully on-chain and trustless. Fixed denominations ensure all outputs look identical.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
541
apps/desktop-wallet/src/pages/Multisig/MultisigDashboard.tsx
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
Copy,
|
||||||
|
Send,
|
||||||
|
Key,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useMultisigStore,
|
||||||
|
MultisigWalletInfo,
|
||||||
|
PendingMultisigTx,
|
||||||
|
} from '../../store/multisig';
|
||||||
|
import { useWalletStore } from '../../store/wallet';
|
||||||
|
|
||||||
|
export default function MultisigDashboard() {
|
||||||
|
const { addresses } = useWalletStore();
|
||||||
|
const {
|
||||||
|
wallets,
|
||||||
|
pendingTxs,
|
||||||
|
isCreating,
|
||||||
|
isProposing,
|
||||||
|
isSigning,
|
||||||
|
isExecuting,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
createWallet,
|
||||||
|
proposeTx,
|
||||||
|
signTx,
|
||||||
|
executeTx,
|
||||||
|
fetchPendingTxs,
|
||||||
|
} = useMultisigStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'wallets' | 'pending'>('wallets');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [selectedWallet, setSelectedWallet] = useState<MultisigWalletInfo | null>(null);
|
||||||
|
const [showProposeModal, setShowProposeModal] = useState(false);
|
||||||
|
|
||||||
|
// Create wallet form
|
||||||
|
const [walletName, setWalletName] = useState('');
|
||||||
|
const [threshold, setThreshold] = useState(2);
|
||||||
|
const [owners, setOwners] = useState<string[]>(['', '']);
|
||||||
|
|
||||||
|
// Propose tx form
|
||||||
|
const [txTo, setTxTo] = useState('');
|
||||||
|
const [txAmount, setTxAmount] = useState('');
|
||||||
|
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const userAddress = addresses[0]?.address;
|
||||||
|
|
||||||
|
const pendingTransactions = selectedWallet
|
||||||
|
? pendingTxs[selectedWallet.address] || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedWallet) {
|
||||||
|
fetchPendingTxs(selectedWallet.address);
|
||||||
|
}
|
||||||
|
}, [fetchPendingTxs, selectedWallet]);
|
||||||
|
|
||||||
|
const handleCreateWallet = async () => {
|
||||||
|
if (!walletName || owners.filter(Boolean).length < 2) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wallet = await createWallet(walletName, owners.filter(Boolean), threshold);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setWalletName('');
|
||||||
|
setThreshold(2);
|
||||||
|
setOwners(['', '']);
|
||||||
|
setSelectedWallet(wallet);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProposeTx = async () => {
|
||||||
|
if (!selectedWallet || !txTo || !txAmount) return;
|
||||||
|
|
||||||
|
const valueSats = (parseFloat(txAmount) * 100_000_000).toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await proposeTx(selectedWallet.address, txTo, valueSats);
|
||||||
|
setShowProposeModal(false);
|
||||||
|
setTxTo('');
|
||||||
|
setTxAmount('');
|
||||||
|
fetchPendingTxs(selectedWallet.address);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSign = async (tx: PendingMultisigTx) => {
|
||||||
|
if (!selectedWallet) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signTx(selectedWallet.address, tx.txId);
|
||||||
|
fetchPendingTxs(selectedWallet.address);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async (tx: PendingMultisigTx) => {
|
||||||
|
if (!selectedWallet) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeTx(selectedWallet.address, tx.txId);
|
||||||
|
fetchPendingTxs(selectedWallet.address);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAddress = (address: string) => {
|
||||||
|
navigator.clipboard.writeText(address);
|
||||||
|
setCopiedAddress(address);
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOwner = () => {
|
||||||
|
if (owners.length < 10) {
|
||||||
|
setOwners([...owners, '']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOwner = (index: number, value: string) => {
|
||||||
|
const newOwners = [...owners];
|
||||||
|
newOwners[index] = value;
|
||||||
|
setOwners(newOwners);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOwner = (index: number) => {
|
||||||
|
if (owners.length > 2) {
|
||||||
|
setOwners(owners.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = isCreating || isProposing || isSigning || isExecuting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Multi-Signature Wallets</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage wallets requiring multiple signatures</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Multisig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('wallets')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'wallets'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Wallets ({wallets.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pending')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'pending'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pending Transactions ({pendingTransactions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wallets Tab */}
|
||||||
|
{activeTab === 'wallets' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{wallets.map((wallet) => (
|
||||||
|
<div
|
||||||
|
key={wallet.address}
|
||||||
|
className={`bg-gray-900 rounded-xl p-6 border cursor-pointer transition-colors ${
|
||||||
|
selectedWallet?.address === wallet.address
|
||||||
|
? 'border-synor-500'
|
||||||
|
: 'border-gray-800 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedWallet(wallet);
|
||||||
|
setActiveTab('pending');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-synor-600/20 rounded-lg">
|
||||||
|
<Users className="text-synor-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{wallet.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{wallet.threshold} of {wallet.owners.length} signatures required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyAddress(wallet.address);
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{copiedAddress === wallet.address ? (
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm mb-4">
|
||||||
|
<p className="text-gray-500">Address</p>
|
||||||
|
<code className="text-white text-xs">
|
||||||
|
{wallet.address.slice(0, 20)}...{wallet.address.slice(-12)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{wallet.owners.slice(0, 3).map((owner, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
|
||||||
|
title={owner}
|
||||||
|
>
|
||||||
|
{owner.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{wallet.owners.length > 3 && (
|
||||||
|
<span className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded">
|
||||||
|
+{wallet.owners.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedWallet(wallet);
|
||||||
|
setShowProposeModal(true);
|
||||||
|
}}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={14} />
|
||||||
|
Propose TX
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{wallets.length === 0 && !isLoading && (
|
||||||
|
<div className="col-span-2 text-center py-12 text-gray-500">
|
||||||
|
No multisig wallets yet. Create one to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Tab */}
|
||||||
|
{activeTab === 'pending' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedWallet && (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="text-synor-400" size={20} />
|
||||||
|
<span className="text-white font-medium">{selectedWallet.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchPendingTxs(selectedWallet.address)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
size={16}
|
||||||
|
className={`text-gray-400 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingTransactions.map((tx) => (
|
||||||
|
<div
|
||||||
|
key={tx.txId}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">
|
||||||
|
Send {(parseFloat(tx.value) / 100_000_000).toFixed(4)} SYN
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 bg-yellow-600/20 text-yellow-400 text-xs rounded-full flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{tx.signatures.length} / {tx.threshold}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">To</p>
|
||||||
|
<code className="text-white text-xs">
|
||||||
|
{tx.to.slice(0, 12)}...{tx.to.slice(-8)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Proposed By</p>
|
||||||
|
<code className="text-white text-xs">{tx.proposer.slice(0, 12)}...</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-gray-500 text-sm mb-2">Signatures</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tx.signatures.map((sig, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 bg-green-600/20 text-green-400 text-xs rounded flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Check size={10} />
|
||||||
|
{sig.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!tx.signatures.includes(userAddress || '') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSign(tx)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Key size={16} />
|
||||||
|
Sign
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{tx.signatures.length >= tx.threshold && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecute(tx)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
Execute
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pendingTransactions.length === 0 && !isLoading && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{selectedWallet
|
||||||
|
? 'No pending transactions'
|
||||||
|
: 'Select a wallet to view pending transactions'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-800 max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Create Multisig Wallet</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Wallet Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={walletName}
|
||||||
|
onChange={(e) => setWalletName(e.target.value)}
|
||||||
|
placeholder="e.g., Company Treasury"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Threshold ({threshold} of {owners.filter(Boolean).length})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(parseInt(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={Math.max(2, owners.filter(Boolean).length)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Number of signatures required to execute a transaction
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-sm text-gray-400">Owners</label>
|
||||||
|
<button
|
||||||
|
onClick={addOwner}
|
||||||
|
className="text-sm text-synor-400 hover:text-synor-300"
|
||||||
|
>
|
||||||
|
+ Add Owner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{owners.map((owner, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={owner}
|
||||||
|
onChange={(e) => updateOwner(index, e.target.value)}
|
||||||
|
placeholder={`Owner ${index + 1} address`}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
{owners.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeOwner(index)}
|
||||||
|
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setWalletName('');
|
||||||
|
setThreshold(2);
|
||||||
|
setOwners(['', '']);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateWallet}
|
||||||
|
disabled={!walletName || owners.filter(Boolean).length < 2 || isCreating}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create Wallet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Propose TX Modal */}
|
||||||
|
{showProposeModal && selectedWallet && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Propose Transaction</h2>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">From: {selectedWallet.name}</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Recipient</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={txTo}
|
||||||
|
onChange={(e) => setTxTo(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={txAmount}
|
||||||
|
onChange={(e) => setTxAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowProposeModal(false);
|
||||||
|
setTxTo('');
|
||||||
|
setTxAmount('');
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleProposeTx}
|
||||||
|
disabled={!txTo || !txAmount || isProposing}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isProposing ? 'Proposing...' : 'Propose'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/desktop-wallet/src/pages/Plugins/PluginsDashboard.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Puzzle, Info, AlertTriangle, Download, Trash2, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginsDashboard() {
|
||||||
|
const plugins: Plugin[] = [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Puzzle className="text-synor-400" />
|
||||||
|
Plugin System
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Third-party extensions and integrations</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<Download size={18} />
|
||||||
|
Browse Plugins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
The plugin system will allow third-party developers to extend wallet
|
||||||
|
functionality with custom features and integrations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Installed Plugins</h3>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Puzzle size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No plugins installed</p>
|
||||||
|
<p className="text-sm mt-1">Plugin marketplace coming soon</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<div key={plugin.id} className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{plugin.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{plugin.description}</p>
|
||||||
|
<p className="text-xs text-gray-600">v{plugin.version} by {plugin.author}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="p-2 bg-gray-700 rounded-lg">
|
||||||
|
<Settings size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-gray-700 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Plugin Categories</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">DeFi</p>
|
||||||
|
<p className="text-xs text-gray-500">Yield, lending, swaps</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">NFT</p>
|
||||||
|
<p className="text-xs text-gray-500">Galleries, marketplaces</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Analytics</p>
|
||||||
|
<p className="text-xs text-gray-500">Charts, tracking</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Privacy</p>
|
||||||
|
<p className="text-xs text-gray-500">Mixing, stealth</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Gaming</p>
|
||||||
|
<p className="text-xs text-gray-500">Web3 games</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Social</p>
|
||||||
|
<p className="text-xs text-gray-500">Messaging, identity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Plugins run in a sandboxed environment with limited permissions. Always verify
|
||||||
|
plugin sources before installation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/desktop-wallet/src/pages/Portfolio/PortfolioDashboard.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { PieChart, Info, AlertCircle, Download, RefreshCw, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { usePortfolioStore, formatUSD, formatAmount } from '../../store/portfolio';
|
||||||
|
|
||||||
|
export default function PortfolioDashboard() {
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
holdings,
|
||||||
|
taxReport,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadSummary,
|
||||||
|
loadHoldings,
|
||||||
|
loadTaxReport,
|
||||||
|
exportTaxReport,
|
||||||
|
clearError,
|
||||||
|
} = usePortfolioStore();
|
||||||
|
|
||||||
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSummary();
|
||||||
|
loadHoldings();
|
||||||
|
}, [loadSummary, loadHoldings]);
|
||||||
|
|
||||||
|
const handleLoadTaxReport = async () => {
|
||||||
|
await loadTaxReport(selectedYear);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTaxReport = async () => {
|
||||||
|
try {
|
||||||
|
const data = await exportTaxReport(selectedYear, 'csv');
|
||||||
|
// Create a download link
|
||||||
|
const blob = new Blob([data], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tax-report-${selectedYear}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<PieChart className="text-synor-400" />
|
||||||
|
Portfolio Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">P&L tracking and tax reports</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { loadSummary(); loadHoldings(); }}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportTaxReport}
|
||||||
|
className="px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-200/70">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold">{formatUSD(summary.totalValueUsd)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">24h Change</p>
|
||||||
|
<p className={`text-2xl font-bold flex items-center gap-1 ${
|
||||||
|
summary.dayChangePercent >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{summary.dayChangePercent >= 0 ? <TrendingUp size={20} /> : <TrendingDown size={20} />}
|
||||||
|
{summary.dayChangePercent >= 0 ? '+' : ''}{summary.dayChangePercent.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${summary.dayChangeUsd >= 0 ? 'text-green-400/70' : 'text-red-400/70'}`}>
|
||||||
|
{formatUSD(summary.dayChangeUsd)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total P&L</p>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
summary.totalPnlUsd >= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{summary.totalPnlUsd >= 0 ? '+' : ''}{formatUSD(summary.totalPnlUsd)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${summary.totalPnlPercent >= 0 ? 'text-green-400/70' : 'text-red-400/70'}`}>
|
||||||
|
{summary.totalPnlPercent >= 0 ? '+' : ''}{summary.totalPnlPercent.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Cost Basis</p>
|
||||||
|
<p className="text-2xl font-bold">{formatUSD(summary.totalCostBasisUsd)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Holdings */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Asset Holdings ({holdings.length})</h3>
|
||||||
|
{holdings.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No holdings found</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{holdings.map((holding) => (
|
||||||
|
<div key={holding.asset} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-synor-600 rounded-full flex items-center justify-center font-bold">
|
||||||
|
{holding.symbol.substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{holding.asset}</p>
|
||||||
|
<p className="text-sm text-gray-500">{holding.balanceFormatted}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">{formatUSD(holding.valueUsd)}</p>
|
||||||
|
<p className={`text-sm ${holding.pnlPercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{holding.pnlPercent >= 0 ? '+' : ''}{holding.pnlPercent.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Allocation</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-synor-500 rounded-full"
|
||||||
|
style={{ width: `${holding.allocationPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{holding.allocationPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tax Report Section */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-medium">Tax Report</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
|
||||||
|
className="px-3 py-1 bg-gray-800 border border-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
{[2026, 2025, 2024, 2023].map(year => (
|
||||||
|
<option key={year} value={year}>{year}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadTaxReport}
|
||||||
|
className="px-3 py-1 bg-synor-600 rounded-lg text-sm hover:bg-synor-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="animate-spin" size={14} /> : 'Load'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{taxReport.length === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Select a year and click Load to generate your tax report</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-6 text-xs text-gray-500 p-2">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Asset</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Total</span>
|
||||||
|
<span>Gain/Loss</span>
|
||||||
|
</div>
|
||||||
|
{taxReport.slice(0, 10).map((tx) => (
|
||||||
|
<div key={tx.id} className="grid grid-cols-6 text-sm p-2 bg-gray-800 rounded items-center">
|
||||||
|
<span>{new Date(tx.timestamp * 1000).toLocaleDateString()}</span>
|
||||||
|
<span className={
|
||||||
|
tx.txType === 'sell' ? 'text-red-400' :
|
||||||
|
tx.txType === 'buy' ? 'text-green-400' :
|
||||||
|
'text-yellow-400'
|
||||||
|
}>
|
||||||
|
{tx.txType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>{tx.asset}</span>
|
||||||
|
<span>{formatAmount(tx.amount)}</span>
|
||||||
|
<span>{formatUSD(tx.totalUsd)}</span>
|
||||||
|
<span className={tx.gainLossUsd && tx.gainLossUsd >= 0 ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{tx.gainLossUsd !== undefined ? formatUSD(tx.gainLossUsd) : '--'}
|
||||||
|
{tx.isLongTerm && <span className="text-xs ml-1">(LT)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{taxReport.length > 10 && (
|
||||||
|
<p className="text-center text-sm text-gray-500 pt-2">
|
||||||
|
Showing 10 of {taxReport.length} transactions. Export to see all.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Portfolio analytics helps you understand your investment performance and simplifies
|
||||||
|
tax reporting with exportable transaction history. LT = Long-term capital gains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
apps/desktop-wallet/src/pages/Privacy/PrivacyDashboard.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
EyeOff,
|
||||||
|
Send,
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Plus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { usePrivacyStore, RING_SIZES } from '../../store/privacy';
|
||||||
|
|
||||||
|
export default function PrivacyDashboard() {
|
||||||
|
const {
|
||||||
|
confidentialBalance,
|
||||||
|
isLoading,
|
||||||
|
isSending,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchBalance,
|
||||||
|
sendPrivate,
|
||||||
|
generateStealthAddress,
|
||||||
|
shield,
|
||||||
|
unshield,
|
||||||
|
} = usePrivacyStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'send' | 'shield' | 'unshield'>('send');
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [useStealthAddress, setUseStealthAddress] = useState(true);
|
||||||
|
const [useRingSignature, setUseRingSignature] = useState(true);
|
||||||
|
const [ringSize, setRingSize] = useState(5);
|
||||||
|
const [stealthAddress, setStealthAddress] = useState('');
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBalance();
|
||||||
|
}, [fetchBalance]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!recipient || !amount) return;
|
||||||
|
await sendPrivate({
|
||||||
|
to: recipient,
|
||||||
|
amount,
|
||||||
|
useStealthAddress,
|
||||||
|
useRingSignature,
|
||||||
|
ringSize: useRingSignature ? ringSize : undefined,
|
||||||
|
});
|
||||||
|
setRecipient('');
|
||||||
|
setAmount('');
|
||||||
|
fetchBalance();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShield = async () => {
|
||||||
|
if (!amount) return;
|
||||||
|
await shield(amount);
|
||||||
|
setAmount('');
|
||||||
|
fetchBalance();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnshield = async () => {
|
||||||
|
if (!amount) return;
|
||||||
|
await unshield(amount);
|
||||||
|
setAmount('');
|
||||||
|
fetchBalance();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateStealth = async () => {
|
||||||
|
const address = await generateStealthAddress();
|
||||||
|
setStealthAddress(address);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAddress = () => {
|
||||||
|
navigator.clipboard.writeText(stealthAddress);
|
||||||
|
setCopiedAddress(true);
|
||||||
|
setTimeout(() => setCopiedAddress(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Privacy Features</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Confidential transactions and privacy tools</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchBalance}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Confidential Balance */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Shield size={20} className="text-synor-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Confidential Balance</h2>
|
||||||
|
</div>
|
||||||
|
{confidentialBalance ? (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-bold text-white mb-2">
|
||||||
|
{confidentialBalance.balance} SYN
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{confidentialBalance.utxoCount} confidential UTXOs
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Commitment</p>
|
||||||
|
<code className="text-xs text-gray-400 break-all">
|
||||||
|
{confidentialBalance.commitment || 'N/A'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No confidential balance</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stealth Address Generator */}
|
||||||
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<EyeOff size={20} className="text-synor-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Stealth Address</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
Generate a one-time address for receiving private payments. Each stealth address
|
||||||
|
can only be linked to your wallet by you.
|
||||||
|
</p>
|
||||||
|
{stealthAddress ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 px-4 py-2 bg-gray-800 rounded-lg text-sm text-white font-mono truncate">
|
||||||
|
{stealthAddress}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyAddress}
|
||||||
|
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{copiedAddress ? (
|
||||||
|
<Check size={18} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={18} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateStealth}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Generate Stealth Address
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
<div className="flex border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('send')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'send'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send size={16} className="inline mr-2" />
|
||||||
|
Private Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('shield')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'shield'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={16} className="inline mr-2" />
|
||||||
|
Shield
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('unshield')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'unshield'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine size={16} className="inline mr-2" />
|
||||||
|
Unshield
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'send' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Recipient Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
placeholder="synor1... or stealth address"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useStealthAddress}
|
||||||
|
onChange={(e) => setUseStealthAddress(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">Use stealth address (unlinkable recipient)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useRingSignature}
|
||||||
|
onChange={(e) => setUseRingSignature(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">Use ring signature (unlinkable sender)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{useRingSignature && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Ring Size</label>
|
||||||
|
<select
|
||||||
|
value={ringSize}
|
||||||
|
onChange={(e) => setRingSize(Number(e.target.value))}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
{RING_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size} (hides among {size} possible senders)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!recipient || !amount || isSending}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSending ? 'Sending...' : 'Send Privately'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'shield' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Shield your regular SYN tokens to convert them into confidential tokens.
|
||||||
|
The amount will be hidden on the blockchain using Pedersen commitments.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount to Shield (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleShield}
|
||||||
|
disabled={!amount || isSending}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={18} />
|
||||||
|
{isSending ? 'Shielding...' : 'Shield Tokens'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'unshield' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Unshield your confidential tokens to convert them back to regular SYN tokens.
|
||||||
|
The amount will become visible on the blockchain.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount to Unshield (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleUnshield}
|
||||||
|
disabled={!amount || isSending}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine size={18} />
|
||||||
|
{isSending ? 'Unshielding...' : 'Unshield Tokens'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
apps/desktop-wallet/src/pages/QRScanner/QRScannerPage.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
QrCode,
|
||||||
|
Camera,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { invoke } from '../../lib/tauri';
|
||||||
|
|
||||||
|
interface PaymentRequest {
|
||||||
|
address: string;
|
||||||
|
amount?: number;
|
||||||
|
label?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRScannerPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'generate' | 'scan'>('generate');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [copiedUri, setCopiedUri] = useState(false);
|
||||||
|
const [scannedData, setScannedData] = useState<PaymentRequest | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generatePaymentUri = () => {
|
||||||
|
let uri = `synor:${address}`;
|
||||||
|
const params: string[] = [];
|
||||||
|
|
||||||
|
if (amount) params.push(`amount=${amount}`);
|
||||||
|
if (label) params.push(`label=${encodeURIComponent(label)}`);
|
||||||
|
if (message) params.push(`message=${encodeURIComponent(message)}`);
|
||||||
|
|
||||||
|
if (params.length > 0) {
|
||||||
|
uri += '?' + params.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateQR = async () => {
|
||||||
|
if (!address) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uri = generatePaymentUri();
|
||||||
|
const qrData = await invoke<string>('qr_generate', {
|
||||||
|
data: uri,
|
||||||
|
size: 256,
|
||||||
|
});
|
||||||
|
setQrCodeData(qrData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to generate QR code');
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasteUri = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
parsePaymentUri(text);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to read clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePaymentUri = async (uri: string) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await invoke<PaymentRequest>('qr_parse_payment', { uri });
|
||||||
|
setScannedData(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Invalid payment URI');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUri = () => {
|
||||||
|
const uri = generatePaymentUri();
|
||||||
|
navigator.clipboard.writeText(uri);
|
||||||
|
setCopiedUri(true);
|
||||||
|
setTimeout(() => setCopiedUri(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadQR = () => {
|
||||||
|
if (!qrCodeData) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `synor-payment-${Date.now()}.png`;
|
||||||
|
link.href = qrCodeData;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">QR Code</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Generate and scan payment QR codes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('generate')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'generate'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Generate QR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('scan')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'scan'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Parse Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Tab */}
|
||||||
|
{activeTab === 'generate' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Form */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Payment Details</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Receiving Address *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Amount (SYN) - Optional
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Label - Optional
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g., Coffee Shop"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Message - Optional
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="e.g., Payment for order #123"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateQR}
|
||||||
|
disabled={!address || isGenerating}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<QrCode size={18} />
|
||||||
|
Generate QR Code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Display */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">QR Code</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{qrCodeData ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-white rounded-xl">
|
||||||
|
<img
|
||||||
|
src={qrCodeData}
|
||||||
|
alt="Payment QR Code"
|
||||||
|
className="w-64 h-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Payment URI
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={generatePaymentUri()}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={copyUri}
|
||||||
|
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{copiedUri ? (
|
||||||
|
<Check size={18} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={18} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={downloadQR}
|
||||||
|
className="mt-4 flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Download QR Code
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-64 h-64 bg-gray-800 rounded-xl flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<QrCode size={48} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Enter details to generate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan Tab */}
|
||||||
|
{activeTab === 'scan' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Input */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Parse Payment URI</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Paste a Synor payment URI to decode its contents. The URI format is:
|
||||||
|
</p>
|
||||||
|
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-gray-300 break-all">
|
||||||
|
synor:{'<address>'}?amount={'<amount>'}&label={'<label>'}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Payment URI
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Paste synor:... URI here"
|
||||||
|
rows={3}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
parsePaymentUri(e.target.value);
|
||||||
|
} else {
|
||||||
|
setScannedData(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePasteUri}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={18} />
|
||||||
|
Paste from Clipboard
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Note: Camera scanning is not available in desktop apps. You can use
|
||||||
|
your phone's camera to scan QR codes and copy the resulting URI here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parsed Result */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Payment Request</h2>
|
||||||
|
|
||||||
|
{scannedData ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-green-900/20 border border-green-800 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||||||
|
<Check size={18} />
|
||||||
|
<span className="font-medium">Valid Payment Request</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 mb-1">Address</label>
|
||||||
|
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-white break-all">
|
||||||
|
{scannedData.address}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scannedData.amount !== undefined && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 mb-1">Amount</label>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{scannedData.amount} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scannedData.label && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 mb-1">Label</label>
|
||||||
|
<p className="text-white">{scannedData.label}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scannedData.message && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 mb-1">Message</label>
|
||||||
|
<p className="text-white">{scannedData.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to send page with pre-filled data
|
||||||
|
window.location.href = `/send?to=${scannedData.address}${
|
||||||
|
scannedData.amount ? `&amount=${scannedData.amount}` : ''
|
||||||
|
}`;
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Send Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<Camera size={48} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Paste a payment URI to decode</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { Copy, Check, Plus, QrCode } from 'lucide-react';
|
import { Copy, Check, Plus, QrCode } from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
export default function Receive() {
|
export default function Receive() {
|
||||||
|
|
@ -51,12 +52,16 @@ export default function Receive() {
|
||||||
<span className="text-sm text-gray-400">Primary Address</span>
|
<span className="text-sm text-gray-400">Primary Address</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code placeholder */}
|
{/* QR Code */}
|
||||||
<div className="w-48 h-48 mx-auto mb-4 bg-white rounded-lg flex items-center justify-center">
|
<div className="w-48 h-48 mx-auto mb-4 bg-white rounded-lg p-3 flex items-center justify-center">
|
||||||
<div className="text-gray-400 text-center p-4">
|
<QRCodeSVG
|
||||||
<QrCode size={64} className="mx-auto mb-2 text-gray-300" />
|
value={primaryAddress}
|
||||||
<span className="text-xs">QR Code</span>
|
size={168}
|
||||||
</div>
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#000000"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address with copy */}
|
{/* Address with copy */}
|
||||||
|
|
|
||||||
546
apps/desktop-wallet/src/pages/Recovery/RecoveryDashboard.tsx
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
|
UserMinus,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Mail,
|
||||||
|
Wallet,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useRecoveryStore,
|
||||||
|
Guardian,
|
||||||
|
RecoveryRequest,
|
||||||
|
getGuardianStatusColor,
|
||||||
|
getRequestStatusColor,
|
||||||
|
} from '../../store/recovery';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Recovery Modal
|
||||||
|
*/
|
||||||
|
function SetupRecoveryModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { setupRecovery, isLoading } = useRecoveryStore();
|
||||||
|
const [threshold, setThreshold] = useState(2);
|
||||||
|
const [delaySecs, setDelaySecs] = useState(86400); // 24 hours
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setupRecovery(threshold, delaySecs);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to setup recovery');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="text-synor-400" />
|
||||||
|
Setup Social Recovery
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Required Approvals (Threshold)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Number of guardians required to approve recovery
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Recovery Delay
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={delaySecs}
|
||||||
|
onChange={(e) => setDelaySecs(parseInt(e.target.value))}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value={3600}>1 hour</option>
|
||||||
|
<option value={86400}>24 hours</option>
|
||||||
|
<option value={259200}>3 days</option>
|
||||||
|
<option value={604800}>1 week</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Delay before recovery completes (gives time to cancel if fraudulent)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner size={18} /> : <ShieldCheck size={18} />}
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Guardian Modal
|
||||||
|
*/
|
||||||
|
function AddGuardianModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { addGuardian, isLoading } = useRecoveryStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Guardian name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addGuardian(name.trim(), email.trim() || undefined, address.trim() || undefined);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add guardian');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<UserPlus className="text-synor-400" />
|
||||||
|
Add Guardian
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Mom, Best Friend"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Email (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="guardian@example.com"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
For sending recovery notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Synor Address (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
If they have a Synor wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner size={18} /> : <UserPlus size={18} />}
|
||||||
|
Add Guardian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardian Card
|
||||||
|
*/
|
||||||
|
function GuardianCard({ guardian }: { guardian: Guardian }) {
|
||||||
|
const { removeGuardian, isLoading } = useRecoveryStore();
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeGuardian(guardian.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-white">{guardian.name}</h4>
|
||||||
|
<span className={`text-sm ${getGuardianStatusColor(guardian.status)}`}>
|
||||||
|
{guardian.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!showConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<UserMinus size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="px-2 py-1 text-xs bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-2 py-1 text-xs bg-red-600 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guardian.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-2">
|
||||||
|
<Mail size={14} />
|
||||||
|
{guardian.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{guardian.address && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
|
||||||
|
<Wallet size={14} />
|
||||||
|
<span className="font-mono text-xs">{guardian.address.slice(0, 20)}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
Added {new Date(guardian.addedAt * 1000).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery Request Card
|
||||||
|
*/
|
||||||
|
function RecoveryRequestCard({ request }: { request: RecoveryRequest }) {
|
||||||
|
const { cancelRecovery, isLoading } = useRecoveryStore();
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
await cancelRecovery(request.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = (request.approvals.length / request.requiredApprovals) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span className={`text-sm font-medium ${getRequestStatusColor(request.status)}`}>
|
||||||
|
{request.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">ID: {request.id}</p>
|
||||||
|
</div>
|
||||||
|
{request.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval progress */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-400">Approvals</span>
|
||||||
|
<span>
|
||||||
|
{request.approvals.length} / {request.requiredApprovals}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-synor-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-gray-500">
|
||||||
|
<p>Expires: {new Date(request.expiresAt * 1000).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Recovery Dashboard
|
||||||
|
*/
|
||||||
|
export default function RecoveryDashboard() {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
requests,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchConfig,
|
||||||
|
fetchRequests,
|
||||||
|
disableRecovery,
|
||||||
|
} = useRecoveryStore();
|
||||||
|
|
||||||
|
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||||
|
const [showAddGuardian, setShowAddGuardian] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRequests();
|
||||||
|
}, [fetchConfig, fetchRequests]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<ShieldCheck className="text-synor-400" />
|
||||||
|
Social Recovery
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Recover your wallet using trusted guardians
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRequests();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not Setup State */}
|
||||||
|
{!config && !isLoading && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<ShieldCheck size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-400 mb-2">Social Recovery Not Configured</h3>
|
||||||
|
<p className="text-gray-500 mb-4 max-w-md mx-auto">
|
||||||
|
Set up social recovery to allow trusted friends or family members to help you
|
||||||
|
recover access to your wallet if you lose your keys.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSetupModal(true)}
|
||||||
|
className="px-6 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
Setup Social Recovery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configured State */}
|
||||||
|
{config && (
|
||||||
|
<>
|
||||||
|
{/* Status Card */}
|
||||||
|
<div className={`rounded-xl p-4 border ${config.enabled ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{config.enabled ? (
|
||||||
|
<CheckCircle className="text-green-400" size={24} />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="text-red-400" size={24} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
{config.enabled ? 'Recovery Enabled' : 'Recovery Disabled'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{config.threshold} of {config.totalGuardians} guardians required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Recovery Delay</p>
|
||||||
|
<p className="font-mono">
|
||||||
|
{config.recoveryDelaySecs >= 86400
|
||||||
|
? `${Math.floor(config.recoveryDelaySecs / 86400)} days`
|
||||||
|
: `${Math.floor(config.recoveryDelaySecs / 3600)} hours`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{config.enabled && (
|
||||||
|
<button
|
||||||
|
onClick={disableRecovery}
|
||||||
|
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guardians Section */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Users className="text-synor-400" size={20} />
|
||||||
|
Guardians ({config.guardians.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddGuardian(true)}
|
||||||
|
className="px-3 py-1.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UserPlus size={16} />
|
||||||
|
Add Guardian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.guardians.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-gray-500">
|
||||||
|
<Users size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No guardians added yet</p>
|
||||||
|
<p className="text-sm">Add trusted contacts to enable recovery</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{config.guardians.map((guardian) => (
|
||||||
|
<GuardianCard key={guardian.id} guardian={guardian} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.guardians.length > 0 && config.guardians.length < config.threshold && (
|
||||||
|
<div className="mt-4 bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-yellow-200">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
You need at least {config.threshold} guardians for recovery to work
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recovery Requests Section */}
|
||||||
|
{requests.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="text-synor-400" size={20} />
|
||||||
|
Recovery Requests
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<RecoveryRequestCard key={request.id} request={request} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">How Social Recovery Works</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>Add trusted friends or family as guardians</li>
|
||||||
|
<li>If you lose access, initiate a recovery request</li>
|
||||||
|
<li>Guardians approve the request (threshold required)</li>
|
||||||
|
<li>After the delay period, recovery completes</li>
|
||||||
|
<li>You can cancel fraudulent requests during the delay</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showSetupModal && <SetupRecoveryModal onClose={() => setShowSetupModal(false)} />}
|
||||||
|
{showAddGuardian && <AddGuardianModal onClose={() => setShowAddGuardian(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Server, Plus, Trash2, CheckCircle, RefreshCw, Info, Wifi } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RpcProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive: boolean;
|
||||||
|
latency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RpcProfilesDashboard() {
|
||||||
|
const [profiles] = useState<RpcProfile[]>([
|
||||||
|
{ id: '1', name: 'Default Mainnet', url: 'https://rpc.synor.io', isActive: true, latency: 45 },
|
||||||
|
{ id: '2', name: 'Testnet', url: 'https://testnet.synor.io', isActive: false, latency: 52 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Server className="text-synor-400" />
|
||||||
|
Custom RPC Profiles
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Multiple endpoints with automatic failover</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className={`bg-gray-900 rounded-xl p-4 border ${
|
||||||
|
profile.isActive ? 'border-synor-500' : 'border-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{profile.isActive ? (
|
||||||
|
<CheckCircle className="text-green-400" size={20} />
|
||||||
|
) : (
|
||||||
|
<Wifi className="text-gray-500" size={20} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{profile.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 font-mono">{profile.url}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{profile.latency && (
|
||||||
|
<span className={`text-sm ${profile.latency < 100 ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||||
|
{profile.latency}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700">
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Failover Settings</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Auto-failover on disconnect</span>
|
||||||
|
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Retry failed requests</span>
|
||||||
|
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Latency-based routing</span>
|
||||||
|
<input type="checkbox" className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Multiple RPC profiles ensure reliability. If one endpoint fails, the wallet
|
||||||
|
automatically switches to the next available endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '../lib/tauri';
|
||||||
import { Send as SendIcon, AlertTriangle, Check } from 'lucide-react';
|
import { Send as SendIcon, AlertTriangle, Check } from 'lucide-react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '../lib/tauri';
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
|
|
|
||||||
348
apps/desktop-wallet/src/pages/Staking/StakingDashboard.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Coins,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
RefreshCw,
|
||||||
|
Gift,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useStakingStore, formatApy, formatLockPeriod } from '../../store/staking';
|
||||||
|
import { useWalletStore } from '../../store/wallet';
|
||||||
|
|
||||||
|
export default function StakingDashboard() {
|
||||||
|
const { addresses } = useWalletStore();
|
||||||
|
const {
|
||||||
|
pools,
|
||||||
|
userStakes,
|
||||||
|
isLoading,
|
||||||
|
isStaking,
|
||||||
|
isUnstaking,
|
||||||
|
isClaiming,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchPools,
|
||||||
|
fetchUserStakes,
|
||||||
|
stake,
|
||||||
|
unstake,
|
||||||
|
claimRewards,
|
||||||
|
} = useStakingStore();
|
||||||
|
|
||||||
|
const [selectedPool, setSelectedPool] = useState<string | null>(null);
|
||||||
|
const [stakeAmount, setStakeAmount] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'pools' | 'stakes'>('pools');
|
||||||
|
|
||||||
|
const userAddress = addresses[0]?.address;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPools();
|
||||||
|
if (userAddress) {
|
||||||
|
fetchUserStakes(userAddress);
|
||||||
|
}
|
||||||
|
}, [fetchPools, fetchUserStakes, userAddress]);
|
||||||
|
|
||||||
|
const handleStake = async (poolAddress: string) => {
|
||||||
|
if (!stakeAmount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stake(poolAddress, stakeAmount);
|
||||||
|
setStakeAmount('');
|
||||||
|
setSelectedPool(null);
|
||||||
|
if (userAddress) fetchUserStakes(userAddress);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnstake = async (poolAddress: string) => {
|
||||||
|
try {
|
||||||
|
await unstake(poolAddress, '0');
|
||||||
|
if (userAddress) fetchUserStakes(userAddress);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaimRewards = async (poolAddress: string) => {
|
||||||
|
try {
|
||||||
|
await claimRewards(poolAddress);
|
||||||
|
if (userAddress) fetchUserStakes(userAddress);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalStaked = userStakes.reduce(
|
||||||
|
(sum, s) => sum + parseFloat(s.stakedAmount || '0'),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalRewards = userStakes.reduce(
|
||||||
|
(sum, s) => sum + parseFloat(s.pendingRewards || '0'),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Staking</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Stake SYN tokens to earn rewards</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchPools();
|
||||||
|
if (userAddress) fetchUserStakes(userAddress);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-synor-600/20 rounded-lg">
|
||||||
|
<Lock className="text-synor-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">Total Staked</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{(totalStaked / 100_000_000).toFixed(4)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-green-600/20 rounded-lg">
|
||||||
|
<Gift className="text-green-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">Pending Rewards</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{(totalRewards / 100_000_000).toFixed(4)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-purple-600/20 rounded-lg">
|
||||||
|
<Coins className="text-purple-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">Active Stakes</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{userStakes.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pools')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'pools'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Staking Pools
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('stakes')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'stakes'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
My Stakes ({userStakes.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pools Tab */}
|
||||||
|
{activeTab === 'pools' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{pools.map((pool) => (
|
||||||
|
<div
|
||||||
|
key={pool.poolAddress}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">{pool.name}</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{pool.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-bold text-green-400">
|
||||||
|
{formatApy(pool.apyBps)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">APY</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Lock Period</p>
|
||||||
|
<p className="text-white flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{formatLockPeriod(pool.lockPeriod)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Min Stake</p>
|
||||||
|
<p className="text-white">
|
||||||
|
{(parseFloat(pool.minStake) / 100_000_000).toFixed(2)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Total Staked</p>
|
||||||
|
<p className="text-white">
|
||||||
|
{(parseFloat(pool.totalStaked) / 100_000_000).toLocaleString()} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPool === pool.poolAddress ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={stakeAmount}
|
||||||
|
onChange={(e) => setStakeAmount(e.target.value)}
|
||||||
|
placeholder={`Min: ${(parseFloat(pool.minStake) / 100_000_000).toFixed(2)} SYN`}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStake(pool.poolAddress)}
|
||||||
|
disabled={isStaking || !stakeAmount}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isStaking ? 'Staking...' : 'Confirm Stake'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPool(null)}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPool(pool.poolAddress)}
|
||||||
|
disabled={!pool.isActive}
|
||||||
|
className="w-full px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Stake Now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pools.length === 0 && !isLoading && (
|
||||||
|
<div className="col-span-2 text-center py-12 text-gray-500">
|
||||||
|
No staking pools available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stakes Tab */}
|
||||||
|
{activeTab === 'stakes' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{userStakes.map((userStake) => {
|
||||||
|
const pool = pools.find((p) => p.poolAddress === userStake.poolAddress);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={userStake.poolAddress}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
{pool?.name || 'Staking Pool'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Staked: {new Date(userStake.stakedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{(parseFloat(userStake.stakedAmount) / 100_000_000).toFixed(4)} SYN
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-400 flex items-center gap-1 justify-end">
|
||||||
|
<TrendingUp size={14} />+
|
||||||
|
{(parseFloat(userStake.pendingRewards) / 100_000_000).toFixed(6)}{' '}
|
||||||
|
rewards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-800">
|
||||||
|
<div className="text-sm">
|
||||||
|
{userStake.unlockAt > Date.now() ? (
|
||||||
|
<span className="text-yellow-400 flex items-center gap-1">
|
||||||
|
<Lock size={14} />
|
||||||
|
Unlocks {new Date(userStake.unlockAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-400 flex items-center gap-1">
|
||||||
|
<Unlock size={14} />
|
||||||
|
Ready to unstake
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{parseFloat(userStake.pendingRewards) > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleClaimRewards(userStake.poolAddress)}
|
||||||
|
disabled={isClaiming}
|
||||||
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isClaiming ? 'Claiming...' : 'Claim Rewards'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{userStake.unlockAt <= Date.now() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnstake(userStake.poolAddress)}
|
||||||
|
disabled={isUnstaking}
|
||||||
|
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isUnstaking ? 'Unstaking...' : 'Unstake'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{userStakes.length === 0 && !isLoading && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
You don't have any active stakes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
apps/desktop-wallet/src/pages/Storage/StorageDashboard.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Upload,
|
||||||
|
Trash2,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
File,
|
||||||
|
Lock,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useStorageStore, formatFileSize } from '../../store/storage';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
|
||||||
|
export default function StorageDashboard() {
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
usage,
|
||||||
|
isLoading,
|
||||||
|
isUploading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchFiles,
|
||||||
|
fetchUsage,
|
||||||
|
uploadFile,
|
||||||
|
pinFile,
|
||||||
|
unpinFile,
|
||||||
|
deleteFile,
|
||||||
|
} = useStorageStore();
|
||||||
|
|
||||||
|
const [encrypt, setEncrypt] = useState(true);
|
||||||
|
const [pin, setPin] = useState(true);
|
||||||
|
const [copiedCid, setCopiedCid] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiles();
|
||||||
|
fetchUsage();
|
||||||
|
}, [fetchFiles, fetchUsage]);
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
await uploadFile(selected as string, encrypt, pin);
|
||||||
|
fetchUsage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCid = (cid: string) => {
|
||||||
|
navigator.clipboard.writeText(cid);
|
||||||
|
setCopiedCid(cid);
|
||||||
|
setTimeout(() => setCopiedCid(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const usagePercent = usage ? (usage.usedBytes / usage.limitBytes) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Decentralized Storage</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Store files on the Synor network</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { fetchFiles(); fetchUsage(); }}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload File'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Stats */}
|
||||||
|
{usage && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<HardDrive size={20} className="text-synor-400" />
|
||||||
|
Storage Usage
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{formatFileSize(usage.usedBytes)} / {formatFileSize(usage.limitBytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-800 rounded-full h-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-synor-600 h-3 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-white">{usage.fileCount}</p>
|
||||||
|
<p className="text-sm text-gray-500">Files</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-white">{usage.pinnedCount}</p>
|
||||||
|
<p className="text-sm text-gray-500">Pinned</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-white">{formatFileSize(usage.usedBytes)}</p>
|
||||||
|
<p className="text-sm text-gray-500">Used</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-white">{usage.monthlyCost} SYN</p>
|
||||||
|
<p className="text-sm text-gray-500">Monthly Cost</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Options */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={encrypt}
|
||||||
|
onChange={(e) => setEncrypt(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<Lock size={16} className="text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-300">Encrypt files</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pin}
|
||||||
|
onChange={(e) => setPin(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<Pin size={16} className="text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-300">Pin for persistence</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files List */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Your Files</h2>
|
||||||
|
</div>
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={file.cid} className="p-4 hover:bg-gray-800/50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
<File size={20} className="text-synor-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{file.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<code className="text-xs text-gray-500 truncate max-w-[200px]">
|
||||||
|
{file.cid}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyCid(file.cid)}
|
||||||
|
className="p-1 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
{copiedCid === file.cid ? (
|
||||||
|
<Check size={12} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={12} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-white">{formatFileSize(file.size)}</p>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
{file.isEncrypted && (
|
||||||
|
<Lock size={12} className="text-green-400" />
|
||||||
|
)}
|
||||||
|
{file.isPinned && (
|
||||||
|
<Pin size={12} className="text-yellow-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => file.isPinned ? unpinFile(file.cid) : pinFile(file.cid)}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title={file.isPinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
{file.isPinned ? (
|
||||||
|
<PinOff size={16} className="text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<Pin size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteFile(file.cid)}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
<HardDrive size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No files uploaded yet</p>
|
||||||
|
<p className="text-sm mt-1">Upload your first file to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
apps/desktop-wallet/src/pages/Swap/SwapDashboard.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDownUp,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
AlertCircle,
|
||||||
|
Droplets,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useSwapStore, formatPriceImpact } from '../../store/swap';
|
||||||
|
import { useWalletStore } from '../../store/wallet';
|
||||||
|
import { useTokensStore } from '../../store/tokens';
|
||||||
|
|
||||||
|
export default function SwapDashboard() {
|
||||||
|
const { balance } = useWalletStore();
|
||||||
|
const { trackedTokens } = useTokensStore();
|
||||||
|
const {
|
||||||
|
quote,
|
||||||
|
pools,
|
||||||
|
isLoadingQuote,
|
||||||
|
isSwapping,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
getQuote,
|
||||||
|
executeSwap,
|
||||||
|
fetchPools,
|
||||||
|
} = useSwapStore();
|
||||||
|
|
||||||
|
const [fromToken, setFromToken] = useState('SYN');
|
||||||
|
const [toToken, setToToken] = useState('');
|
||||||
|
const [fromAmount, setFromAmount] = useState('');
|
||||||
|
const [slippage, setSlippage] = useState(50); // 0.5% in bps
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'swap' | 'pools'>('swap');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPools();
|
||||||
|
}, [fetchPools]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fromToken && toToken && fromAmount && parseFloat(fromAmount) > 0) {
|
||||||
|
const debounce = setTimeout(() => {
|
||||||
|
const amountSats = (parseFloat(fromAmount) * 100_000_000).toString();
|
||||||
|
getQuote(fromToken, toToken, amountSats, slippage);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(debounce);
|
||||||
|
}
|
||||||
|
}, [fromToken, toToken, fromAmount, slippage, getQuote]);
|
||||||
|
|
||||||
|
const handleSwap = async () => {
|
||||||
|
if (!quote) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeSwap(quote.tokenIn, quote.tokenOut, quote.amountIn, quote.amountOutMin);
|
||||||
|
setFromAmount('');
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapTokens = () => {
|
||||||
|
setFromToken(toToken);
|
||||||
|
setToToken(fromToken);
|
||||||
|
setFromAmount('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSlippage = (bps: number) => `${(bps / 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
const availableTokens = [
|
||||||
|
{
|
||||||
|
symbol: 'SYN',
|
||||||
|
name: 'Synor',
|
||||||
|
balance: balance?.balanceHuman ? parseFloat(balance.balanceHuman) : 0,
|
||||||
|
},
|
||||||
|
...trackedTokens.map((t) => ({
|
||||||
|
symbol: t.symbol,
|
||||||
|
name: t.name,
|
||||||
|
balance: 0,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Swap</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Exchange tokens instantly</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="text-sm font-medium text-white mb-3">Slippage Tolerance</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[10, 50, 100].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSlippage(s)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
slippage === s
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatSlippage(s)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={slippage / 100}
|
||||||
|
onChange={(e) => setSlippage(Math.round(parseFloat(e.target.value || '0.5') * 100))}
|
||||||
|
className="w-20 px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm text-center"
|
||||||
|
step="0.1"
|
||||||
|
min="0.1"
|
||||||
|
max="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('swap')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'swap'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Swap
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pools')}
|
||||||
|
className={`px-4 py-2 font-medium transition-colors ${
|
||||||
|
activeTab === 'pools'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Liquidity Pools
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Swap Tab */}
|
||||||
|
{activeTab === 'swap' && (
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
{/* From Token */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="text-sm text-gray-400">From</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<select
|
||||||
|
value={fromToken}
|
||||||
|
onChange={(e) => setFromToken(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
{availableTokens.map((token) => (
|
||||||
|
<option key={token.symbol} value={token.symbol}>
|
||||||
|
{token.symbol} - {token.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fromAmount}
|
||||||
|
onChange={(e) => setFromAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full mt-2 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-xl placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Balance:{' '}
|
||||||
|
{availableTokens.find((t) => t.symbol === fromToken)?.balance.toFixed(4) ||
|
||||||
|
'0'}{' '}
|
||||||
|
{fromToken}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Swap Button */}
|
||||||
|
<div className="flex justify-center -my-2 relative z-10">
|
||||||
|
<button
|
||||||
|
onClick={swapTokens}
|
||||||
|
className="p-2 bg-gray-800 border border-gray-700 rounded-full hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDownUp size={20} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* To Token */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-sm text-gray-400">To</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<select
|
||||||
|
value={toToken}
|
||||||
|
onChange={(e) => setToToken(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">Select token</option>
|
||||||
|
{availableTokens
|
||||||
|
.filter((t) => t.symbol !== fromToken)
|
||||||
|
.map((token) => (
|
||||||
|
<option key={token.symbol} value={token.symbol}>
|
||||||
|
{token.symbol} - {token.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-full mt-2 px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg">
|
||||||
|
<p className="text-xl text-white">
|
||||||
|
{quote
|
||||||
|
? (parseFloat(quote.amountOut) / 100_000_000).toFixed(6)
|
||||||
|
: '0.0'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quote Details */}
|
||||||
|
{quote && (
|
||||||
|
<div className="mb-4 p-3 bg-gray-800/50 rounded-lg space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Price Impact</span>
|
||||||
|
<span
|
||||||
|
className={quote.priceImpactBps > 300 ? 'text-red-400' : 'text-gray-300'}
|
||||||
|
>
|
||||||
|
{formatPriceImpact(quote.priceImpactBps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Min Received</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{(parseFloat(quote.amountOutMin) / 100_000_000).toFixed(6)} {toToken}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Route</span>
|
||||||
|
<span className="text-white">{quote.route.join(' → ')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Swap Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSwap}
|
||||||
|
disabled={!quote || isSwapping || !fromAmount || !toToken}
|
||||||
|
className="w-full px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoadingQuote ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Getting quote...
|
||||||
|
</span>
|
||||||
|
) : isSwapping ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Swapping...
|
||||||
|
</span>
|
||||||
|
) : !toToken ? (
|
||||||
|
'Select a token'
|
||||||
|
) : !fromAmount ? (
|
||||||
|
'Enter an amount'
|
||||||
|
) : (
|
||||||
|
'Swap'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pools Tab */}
|
||||||
|
{activeTab === 'pools' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pools.map((pool) => (
|
||||||
|
<div
|
||||||
|
key={pool.poolAddress}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-synor-600/20 rounded-lg">
|
||||||
|
<Droplets className="text-synor-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
{pool.symbolA} / {pool.symbolB}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">Liquidity Pool</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-green-400 flex items-center gap-1">
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
{(pool.feeBps / 100).toFixed(2)}% Fee
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{pool.symbolA} Reserve</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{(parseFloat(pool.reserveA) / 100_000_000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{pool.symbolB} Reserve</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{(parseFloat(pool.reserveB) / 100_000_000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-800">
|
||||||
|
<button className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors">
|
||||||
|
Add Liquidity
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pools.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No liquidity pools available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/desktop-wallet/src/pages/TxBuilder/TxBuilderDashboard.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Wrench, Info, AlertTriangle, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function TxBuilderDashboard() {
|
||||||
|
const [outputs, setOutputs] = useState([{ address: '', amount: '' }]);
|
||||||
|
|
||||||
|
const addOutput = () => {
|
||||||
|
setOutputs([...outputs, { address: '', amount: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOutput = (index: number) => {
|
||||||
|
if (outputs.length > 1) {
|
||||||
|
setOutputs(outputs.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Wrench className="text-synor-400" />
|
||||||
|
Transaction Builder
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Advanced custom transaction crafting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Advanced Feature</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Transaction builder is for advanced users. Incorrect transactions may result in
|
||||||
|
lost funds. Use with caution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Outputs</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{outputs.map((output, index) => (
|
||||||
|
<div key={index} className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Address"
|
||||||
|
value={output.address}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOutputs = [...outputs];
|
||||||
|
newOutputs[index].address = e.target.value;
|
||||||
|
setOutputs(newOutputs);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Amount (SYN)"
|
||||||
|
value={output.amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOutputs = [...outputs];
|
||||||
|
newOutputs[index].amount = e.target.value;
|
||||||
|
setOutputs(newOutputs);
|
||||||
|
}}
|
||||||
|
className="w-40 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeOutput(index)}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400"
|
||||||
|
disabled={outputs.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addOutput}
|
||||||
|
className="mt-3 px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Output
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Advanced Options</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Fee Rate (sompi/byte)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue="1"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Locktime</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue="0"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button className="flex-1 px-4 py-3 bg-gray-800 rounded-lg">
|
||||||
|
Preview Transaction
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-4 py-3 bg-synor-600 rounded-lg">
|
||||||
|
Create & Sign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
The transaction builder allows you to craft custom transactions with multiple
|
||||||
|
outputs, specific fee rates, and advanced options like timelocks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { AlertTriangle, Lock } from 'lucide-react';
|
import { AlertTriangle, Lock, KeyRound, Download, Trash2 } from 'lucide-react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
export default function Unlock() {
|
export default function Unlock() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { unlockWallet } = useWalletStore();
|
const { unlockWallet, setInitialized } = useWalletStore();
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
const handleUnlock = async (e: React.FormEvent) => {
|
const handleUnlock = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -31,6 +32,16 @@ export default function Unlock() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetWallet = () => {
|
||||||
|
// Clear wallet state and localStorage
|
||||||
|
setInitialized(false);
|
||||||
|
localStorage.removeItem('synor-wallet-storage');
|
||||||
|
localStorage.removeItem('synor-node-storage');
|
||||||
|
localStorage.removeItem('synor-mining-storage');
|
||||||
|
// Navigate to welcome page
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center p-8">
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|
@ -76,8 +87,73 @@ export default function Unlock() {
|
||||||
{loading ? 'Unlocking...' : 'Unlock'}
|
{loading ? 'Unlocking...' : 'Unlock'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-6 border-t border-gray-800" />
|
||||||
|
|
||||||
|
{/* Alternative options */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
to="/import"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
Import Different Wallet
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-400 hover:border-red-500/50 hover:text-red-400 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
Forgot Password?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Confirmation Modal */}
|
||||||
|
{showResetConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full border border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||||
|
<Trash2 className="text-red-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Reset Wallet?</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<p className="text-gray-300 text-sm">
|
||||||
|
This will permanently delete your wallet data from this device.
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
|
||||||
|
<p className="text-red-300 text-sm font-medium">
|
||||||
|
Warning: If you don't have your 24-word recovery phrase, you will lose access to your funds forever.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
After reset, you can create a new wallet or import an existing one using your recovery phrase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetWallet}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Reset Wallet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
517
apps/desktop-wallet/src/pages/Vaults/VaultsDashboard.tsx
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
|
Timer,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Using Timer as VaultIcon since lucide-react doesn't export Vault
|
||||||
|
const VaultIcon = Timer;
|
||||||
|
import {
|
||||||
|
useVaultsStore,
|
||||||
|
Vault,
|
||||||
|
formatTimeRemaining,
|
||||||
|
getVaultStatusColor,
|
||||||
|
LOCK_DURATION_PRESETS,
|
||||||
|
} from '../../store/vaults';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Vault Modal
|
||||||
|
*/
|
||||||
|
function CreateVaultModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { createVault, isLoading } = useVaultsStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [duration, setDuration] = useState(86400); // 24 hours default
|
||||||
|
const [customDuration, setCustomDuration] = useState('');
|
||||||
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const amountNum = parseFloat(amount);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Vault name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(amountNum) || amountNum <= 0) {
|
||||||
|
setError('Invalid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockDuration = useCustom
|
||||||
|
? parseInt(customDuration) * 3600 // Custom is in hours
|
||||||
|
: duration;
|
||||||
|
|
||||||
|
if (lockDuration < 60) {
|
||||||
|
setError('Lock duration must be at least 1 minute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createVault({
|
||||||
|
name: name.trim(),
|
||||||
|
amount: Math.floor(amountNum * 100_000_000), // Convert SYN to sompi
|
||||||
|
lockDurationSecs: lockDuration,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create vault');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<VaultIcon className="text-synor-400" size={24} />
|
||||||
|
Create Time-Locked Vault
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Vault Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Savings Goal, Emergency Fund"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.00000001"
|
||||||
|
min="0"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00000000"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Lock Duration</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-2">
|
||||||
|
{LOCK_DURATION_PRESETS.slice(0, 4).map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(preset.value);
|
||||||
|
setUseCustom(false);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
!useCustom && duration === preset.value
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{LOCK_DURATION_PRESETS.slice(4).map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(preset.value);
|
||||||
|
setUseCustom(false);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
!useCustom && duration === preset.value
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseCustom(true)}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
useCustom
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{useCustom && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={customDuration}
|
||||||
|
onChange={(e) => setCustomDuration(e.target.value)}
|
||||||
|
placeholder="Duration in hours"
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">hours</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Description (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What is this vault for?"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-200">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-start gap-2 text-sm text-yellow-200">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<p>
|
||||||
|
<strong>Warning:</strong> Funds locked in a vault cannot be accessed until the lock
|
||||||
|
period expires. Make sure you don't need these funds during the lock period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner size={18} />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock size={18} />
|
||||||
|
Lock Funds
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Vault Card
|
||||||
|
*/
|
||||||
|
function VaultCard({ vault }: { vault: Vault }) {
|
||||||
|
const { withdrawVault, deleteVault, isLoading } = useVaultsStore();
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(vault.remainingSecs);
|
||||||
|
const [showConfirm, setShowConfirm] = useState<'withdraw' | 'delete' | null>(null);
|
||||||
|
|
||||||
|
// Update countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (vault.status !== 'locked') return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeRemaining((prev) => Math.max(0, prev - 1));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [vault.status]);
|
||||||
|
|
||||||
|
const handleWithdraw = async () => {
|
||||||
|
try {
|
||||||
|
await withdrawVault(vault.id);
|
||||||
|
setShowConfirm(null);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteVault(vault.id);
|
||||||
|
setShowConfirm(null);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIcon = vault.status === 'locked' ? Lock : vault.status === 'unlocked' ? Unlock : CheckCircle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{vault.name}</h3>
|
||||||
|
{vault.description && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{vault.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${getVaultStatusColor(vault.status)}`}>
|
||||||
|
<StatusIcon size={14} />
|
||||||
|
<span className="capitalize">{vault.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{vault.amountHuman}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vault.status === 'locked' && (
|
||||||
|
<>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-synor-500 transition-all duration-1000"
|
||||||
|
style={{ width: `${vault.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400 flex items-center gap-1">
|
||||||
|
<Timer size={14} />
|
||||||
|
Unlocks in:
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-synor-400">
|
||||||
|
{formatTimeRemaining(timeRemaining)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vault.status === 'unlocked' && !showConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm('withdraw')}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Withdraw Funds
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vault.status === 'withdrawn' && !showConfirm && (
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Withdrawn {vault.txId && `(${vault.txId.slice(0, 12)}...)`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm('delete')}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="mt-3 bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-gray-300 mb-3">
|
||||||
|
{showConfirm === 'withdraw'
|
||||||
|
? 'Are you sure you want to withdraw funds from this vault?'
|
||||||
|
: 'Are you sure you want to delete this vault record?'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(null)}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={showConfirm === 'withdraw' ? handleWithdraw : handleDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded text-sm ${
|
||||||
|
showConfirm === 'withdraw'
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Processing...' : 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-800 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>Created: {new Date(vault.createdAt * 1000).toLocaleDateString()}</span>
|
||||||
|
<span>Unlock: {new Date(vault.unlockAt * 1000).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Vaults Dashboard
|
||||||
|
*/
|
||||||
|
export default function VaultsDashboard() {
|
||||||
|
const { vaults, summary, isLoading, error, fetchVaults, fetchSummary } = useVaultsStore();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch vaults on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
|
||||||
|
// Refresh every minute
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchVaults, fetchSummary]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<VaultIcon className="text-synor-400" />
|
||||||
|
Time-Locked Vaults
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Lock funds for a period to enforce saving goals or vesting schedules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Vault
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Locked</p>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{summary.totalLockedHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Active Vaults</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.lockedVaults}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Ready to Withdraw</p>
|
||||||
|
<p className="text-xl font-bold text-green-400">{summary.unlockedVaults}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Next Unlock</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{summary.nextUnlock
|
||||||
|
? new Date(summary.nextUnlock * 1000).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vaults List */}
|
||||||
|
{isLoading && vaults.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<LoadingSpinner size={32} />
|
||||||
|
</div>
|
||||||
|
) : vaults.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<VaultIcon size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-400 mb-2">No Vaults Yet</h3>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
Create a time-locked vault to start saving with enforced holding periods.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Your First Vault
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{vaults.map((vault) => (
|
||||||
|
<VaultCard key={vault.id} vault={vault} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">About Time-Locked Vaults</p>
|
||||||
|
<p>
|
||||||
|
Vaults use time-locked transactions to enforce a holding period. Once funds are
|
||||||
|
deposited, they cannot be withdrawn until the lock period expires. This is useful
|
||||||
|
for savings goals, vesting schedules, or preventing impulsive spending. The lock
|
||||||
|
is enforced at the protocol level, so even you cannot access the funds early.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && <CreateVaultModal onClose={() => setShowCreateModal(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
714
apps/desktop-wallet/src/pages/WatchOnly/WatchOnlyDashboard.tsx
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
AlertTriangle,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useWatchOnlyStore,
|
||||||
|
useFilteredWatchOnlyAddresses,
|
||||||
|
formatWatchOnlyBalance,
|
||||||
|
WatchOnlyAddress,
|
||||||
|
} from '../../store/watchOnly';
|
||||||
|
|
||||||
|
export default function WatchOnlyDashboard() {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
selectedTag,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAddresses,
|
||||||
|
loadTags,
|
||||||
|
addAddress,
|
||||||
|
updateAddress,
|
||||||
|
removeAddress,
|
||||||
|
refreshBalance,
|
||||||
|
refreshAllBalances,
|
||||||
|
setSelectedTag,
|
||||||
|
setError,
|
||||||
|
} = useWatchOnlyStore();
|
||||||
|
|
||||||
|
const addresses = useFilteredWatchOnlyAddresses();
|
||||||
|
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingAddress, setEditingAddress] = useState<WatchOnlyAddress | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddresses();
|
||||||
|
loadTags();
|
||||||
|
}, [loadAddresses, loadTags]);
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
const filteredAddresses = addresses.filter(
|
||||||
|
(a) =>
|
||||||
|
a.address.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
a.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
a.notes?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = async (address: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(address);
|
||||||
|
setCopiedAddress(address);
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (address: string) => {
|
||||||
|
if (confirm('Are you sure you want to remove this watch-only address?')) {
|
||||||
|
try {
|
||||||
|
await removeAddress(address);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total balance
|
||||||
|
const totalBalance = addresses.reduce(
|
||||||
|
(sum, a) => sum + (a.cachedBalance || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Eye className="text-synor-400" />
|
||||||
|
Watch-Only Addresses
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Monitor addresses without private keys
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => refreshAllBalances()}
|
||||||
|
disabled={isLoading || addresses.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Addresses</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{addresses.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Balance</p>
|
||||||
|
<p className="text-2xl font-bold text-synor-400">
|
||||||
|
{formatWatchOnlyBalance(totalBalance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Tags</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{tags.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search addresses, labels, or notes..."
|
||||||
|
className="w-full bg-gray-900 border border-gray-800 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filter */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTag(null)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||||
|
!selectedTag
|
||||||
|
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||||
|
selectedTag === tag
|
||||||
|
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Tag size={12} />
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address List */}
|
||||||
|
{filteredAddresses.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
<Eye className="mx-auto text-gray-600 mb-4\" size={48} />
|
||||||
|
<p className="text-gray-400 mb-2">No watch-only addresses yet</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
Add addresses to monitor their balances without exposing private keys
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Your First Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAddresses.map((addr) => (
|
||||||
|
<WatchOnlyCard
|
||||||
|
key={addr.address}
|
||||||
|
address={addr}
|
||||||
|
onCopy={() => handleCopy(addr.address)}
|
||||||
|
onEdit={() => setEditingAddress(addr)}
|
||||||
|
onDelete={() => handleDelete(addr.address)}
|
||||||
|
onRefresh={() => refreshBalance(addr.address)}
|
||||||
|
copied={copiedAddress === addr.address}
|
||||||
|
isRefreshing={isLoading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<AddWatchOnlyModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onAdd={addAddress}
|
||||||
|
existingTags={tags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingAddress && (
|
||||||
|
<EditWatchOnlyModal
|
||||||
|
address={editingAddress}
|
||||||
|
onClose={() => setEditingAddress(null)}
|
||||||
|
onSave={updateAddress}
|
||||||
|
existingTags={tags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch-only address card component
|
||||||
|
function WatchOnlyCard({
|
||||||
|
address,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onRefresh,
|
||||||
|
copied,
|
||||||
|
isRefreshing,
|
||||||
|
}: {
|
||||||
|
address: WatchOnlyAddress;
|
||||||
|
onCopy: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}) {
|
||||||
|
const truncateAddress = (addr: string) => {
|
||||||
|
return `${addr.slice(0, 12)}...${addr.slice(-8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Label and network */}
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-white truncate">{address.label}</h3>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
address.network === 'testnet'
|
||||||
|
? 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
: 'bg-green-500/20 text-green-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{address.network}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<code className="text-gray-400 text-sm font-mono">
|
||||||
|
{truncateAddress(address.address)}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className="text-gray-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`https://explorer.synor.io/address/${address.address}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-synor-400 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{address.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{address.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-0.5 bg-gray-800 text-gray-400 rounded text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{address.notes && (
|
||||||
|
<p className="text-gray-500 text-sm truncate">{address.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance and actions */}
|
||||||
|
<div className="flex flex-col items-end gap-2 ml-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{formatWatchOnlyBalance(address.cachedBalance)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Updated: {formatTime(address.balanceUpdatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh balance"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add watch-only address modal
|
||||||
|
function AddWatchOnlyModal({
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
existingTags,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (address: string, label: string, notes?: string, tags?: string[]) => Promise<WatchOnlyAddress>;
|
||||||
|
existingTags: string[];
|
||||||
|
}) {
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!address.trim()) {
|
||||||
|
setError('Address is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Label is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAdd(
|
||||||
|
address.trim(),
|
||||||
|
label.trim(),
|
||||||
|
notes.trim() || undefined,
|
||||||
|
selectedTags.length > 0 ? selectedTags : undefined
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add address');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewTag = () => {
|
||||||
|
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
|
||||||
|
setSelectedTags((prev) => [...prev, newTag.trim()]);
|
||||||
|
setNewTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Watch-Only Address</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="synor1... or tsynor1..."
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g., Cold Storage, Exchange"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{existingTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder="Add new tag..."
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addNewTag}
|
||||||
|
disabled={!newTag.trim()}
|
||||||
|
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding...' : 'Add Address'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit watch-only address modal
|
||||||
|
function EditWatchOnlyModal({
|
||||||
|
address,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
existingTags,
|
||||||
|
}: {
|
||||||
|
address: WatchOnlyAddress;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (
|
||||||
|
address: string,
|
||||||
|
label?: string,
|
||||||
|
notes?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<WatchOnlyAddress>;
|
||||||
|
existingTags: string[];
|
||||||
|
}) {
|
||||||
|
const [label, setLabel] = useState(address.label);
|
||||||
|
const [notes, setNotes] = useState(address.notes || '');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>(address.tags);
|
||||||
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Label is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(
|
||||||
|
address.address,
|
||||||
|
label.trim(),
|
||||||
|
notes.trim(),
|
||||||
|
selectedTags
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update address');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewTag = () => {
|
||||||
|
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
|
||||||
|
setSelectedTags((prev) => [...prev, newTag.trim()]);
|
||||||
|
setNewTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTags = Array.from(new Set([...existingTags, ...address.tags]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Edit Watch-Only Address</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
|
||||||
|
<code className="block w-full bg-gray-800/50 rounded-lg px-4 py-2.5 text-gray-400 font-mono text-sm break-all">
|
||||||
|
{address.address}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder="Add new tag..."
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addNewTag}
|
||||||
|
disabled={!newTag.trim()}
|
||||||
|
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
apps/desktop-wallet/src/pages/Yield/YieldDashboard.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { TrendingUp, Info, AlertCircle, RefreshCw, Loader2, Plus, Percent, X } from 'lucide-react';
|
||||||
|
import { useYieldStore, formatAmount } from '../../store/yield';
|
||||||
|
|
||||||
|
export default function YieldDashboard() {
|
||||||
|
const {
|
||||||
|
opportunities,
|
||||||
|
positions,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadOpportunities,
|
||||||
|
listPositions,
|
||||||
|
deposit,
|
||||||
|
withdraw,
|
||||||
|
clearError,
|
||||||
|
} = useYieldStore();
|
||||||
|
|
||||||
|
const [selectedOpportunity, setSelectedOpportunity] = useState<string | null>(null);
|
||||||
|
const [depositAmount, setDepositAmount] = useState('');
|
||||||
|
const [autoCompound, setAutoCompound] = useState(true);
|
||||||
|
const [showDepositModal, setShowDepositModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOpportunities();
|
||||||
|
listPositions();
|
||||||
|
}, [loadOpportunities, listPositions]);
|
||||||
|
|
||||||
|
const handleDeposit = async () => {
|
||||||
|
if (!selectedOpportunity || !depositAmount) return;
|
||||||
|
try {
|
||||||
|
await deposit(selectedOpportunity, parseFloat(depositAmount) * 100_000_000, autoCompound);
|
||||||
|
setShowDepositModal(false);
|
||||||
|
setDepositAmount('');
|
||||||
|
setSelectedOpportunity(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalDeposited = positions.reduce((sum, p) => sum + p.depositedAmount, 0);
|
||||||
|
const totalEarned = positions.reduce((sum, p) => sum + p.rewardsEarned, 0);
|
||||||
|
const bestApy = opportunities.length > 0
|
||||||
|
? Math.max(...opportunities.map(o => o.apy))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Helper to get opportunity details for a position
|
||||||
|
const getOpportunity = (oppId: string) => opportunities.find(o => o.id === oppId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<TrendingUp className="text-synor-400" />
|
||||||
|
Yield Aggregator
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Auto-compound and find the best APY</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { loadOpportunities(); listPositions(); }}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="text-red-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-200/70">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Deposited</p>
|
||||||
|
<p className="text-2xl font-bold">{formatAmount(totalDeposited)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Earned</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">{formatAmount(totalEarned)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Best APY</p>
|
||||||
|
<p className="text-2xl font-bold text-synor-400">{bestApy.toFixed(2)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Positions */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Your Positions ({positions.length})</h3>
|
||||||
|
{positions.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No active positions</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{positions.map((position) => {
|
||||||
|
const opp = getOpportunity(position.opportunityId);
|
||||||
|
return (
|
||||||
|
<div key={position.id} className="p-4 bg-gray-800 rounded-lg">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{opp?.protocol || 'Unknown Protocol'}</p>
|
||||||
|
<p className="text-xs text-gray-500">{opp?.asset || 'Unknown Asset'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{position.autoCompound && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">Auto</span>
|
||||||
|
)}
|
||||||
|
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs flex items-center gap-1">
|
||||||
|
<Percent size={12} />
|
||||||
|
{opp?.apy.toFixed(2) || '--'}% APY
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Deposited</p>
|
||||||
|
<p className="font-mono">{formatAmount(position.depositedAmount)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Current Value</p>
|
||||||
|
<p className="font-mono">{formatAmount(position.currentValue)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Earned</p>
|
||||||
|
<p className="font-mono text-green-400">+{formatAmount(position.rewardsEarned)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => withdraw(position.id)}
|
||||||
|
className="mt-3 w-full py-2 bg-gray-700 rounded text-sm hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Withdraw All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Strategies */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Available Strategies ({opportunities.length})</h3>
|
||||||
|
{opportunities.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No yield strategies available yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{opportunities.map((opp) => (
|
||||||
|
<div key={opp.id} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{opp.protocol}</p>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs ${
|
||||||
|
opp.riskLevel === 'low' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
opp.riskLevel === 'medium' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{opp.riskLevel} risk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{opp.asset}</p>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
|
<span>TVL: ${(opp.tvl / 1_000_000).toFixed(2)}M</span>
|
||||||
|
{opp.lockupPeriodDays > 0 && (
|
||||||
|
<span className="ml-2">• {opp.lockupPeriodDays}d lockup</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-2">• Min: {formatAmount(opp.minDeposit)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-bold text-green-400">{opp.apy.toFixed(2)}%</p>
|
||||||
|
<p className="text-xs text-gray-500">APY</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedOpportunity(opp.id); setShowDepositModal(true); }}
|
||||||
|
className="mt-2 px-4 py-1 bg-synor-600 rounded text-sm hover:bg-synor-700 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Deposit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deposit Modal */}
|
||||||
|
{showDepositModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium">Deposit to Yield Strategy</h3>
|
||||||
|
<button onClick={() => setShowDepositModal(false)} className="text-gray-400 hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={depositAmount}
|
||||||
|
onChange={(e) => setDepositAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="autoCompound"
|
||||||
|
checked={autoCompound}
|
||||||
|
onChange={(e) => setAutoCompound(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="autoCompound" className="text-sm text-gray-400">
|
||||||
|
Enable auto-compounding
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDepositModal(false)}
|
||||||
|
className="flex-1 py-2 bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeposit}
|
||||||
|
disabled={isLoading || !depositAmount}
|
||||||
|
className="flex-1 py-2 bg-synor-600 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Deposit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Yield aggregation optimizes gas costs and maximizes returns by automatically
|
||||||
|
moving funds to the highest-yielding opportunities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
apps/desktop-wallet/src/pages/ZK/ZKDashboard.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
Send,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
Box,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useZkStore, formatTps } from '../../store/zk';
|
||||||
|
|
||||||
|
export default function ZKDashboard() {
|
||||||
|
const {
|
||||||
|
stats,
|
||||||
|
account,
|
||||||
|
isLoading,
|
||||||
|
isTransacting,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
fetchStats,
|
||||||
|
fetchAccount,
|
||||||
|
deposit,
|
||||||
|
withdraw,
|
||||||
|
transfer,
|
||||||
|
} = useZkStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'deposit' | 'withdraw' | 'transfer'>('deposit');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
fetchAccount();
|
||||||
|
|
||||||
|
// Poll for stats updates
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchStats, fetchAccount]);
|
||||||
|
|
||||||
|
const handleDeposit = async () => {
|
||||||
|
if (!amount) return;
|
||||||
|
try {
|
||||||
|
await deposit(amount);
|
||||||
|
setAmount('');
|
||||||
|
fetchAccount();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWithdraw = async () => {
|
||||||
|
if (!amount) return;
|
||||||
|
try {
|
||||||
|
await withdraw(amount);
|
||||||
|
setAmount('');
|
||||||
|
fetchAccount();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransfer = async () => {
|
||||||
|
if (!amount || !recipient) return;
|
||||||
|
try {
|
||||||
|
await transfer(recipient, amount);
|
||||||
|
setAmount('');
|
||||||
|
setRecipient('');
|
||||||
|
fetchAccount();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">ZK-Rollup (L2)</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Fast, low-cost transactions with zero-knowledge proofs</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchStats();
|
||||||
|
fetchAccount();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-400" size={20} />
|
||||||
|
<p className="text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Box size={16} className="text-synor-400" />
|
||||||
|
<span className="text-sm text-gray-400">Batch</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">#{stats?.batchNumber || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Activity size={16} className="text-green-400" />
|
||||||
|
<span className="text-sm text-gray-400">Throughput</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats ? formatTps(stats.averageTps) : '0 TPS'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Layers size={16} className="text-blue-400" />
|
||||||
|
<span className="text-sm text-gray-400">Total TXs</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats?.totalTransactions.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock size={16} className="text-yellow-400" />
|
||||||
|
<span className="text-sm text-gray-400">Pending</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats?.pendingTransactions || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account & Operations */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* L2 Account */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Layers size={20} className="text-synor-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">L2 Account</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">L2 Balance</p>
|
||||||
|
<p className="text-3xl font-bold text-white">{account.balance} SYN</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500">Nonce</p>
|
||||||
|
<p className="text-white font-medium">{account.nonce}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500">Status</p>
|
||||||
|
<p className={`font-medium ${account.isActivated ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||||
|
{account.isActivated ? 'Active' : 'Not Activated'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">L2 Address</p>
|
||||||
|
<code className="text-sm text-gray-300 break-all">{account.address}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Layers size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No L2 account</p>
|
||||||
|
<p className="text-sm">Deposit funds to activate</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operations */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('deposit')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'deposit'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={16} className="inline mr-2" />
|
||||||
|
Deposit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('withdraw')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'withdraw'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine size={16} className="inline mr-2" />
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('transfer')}
|
||||||
|
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'transfer'
|
||||||
|
? 'text-synor-400 border-b-2 border-synor-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send size={16} className="inline mr-2" />
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'deposit' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Deposit SYN from L1 to L2 for fast, low-cost transactions. Deposits are confirmed
|
||||||
|
after the next batch proof.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDeposit}
|
||||||
|
disabled={!amount || isTransacting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={18} />
|
||||||
|
{isTransacting ? 'Depositing...' : 'Deposit to L2'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'withdraw' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Withdraw SYN from L2 back to L1. Withdrawals require proof finalization and may
|
||||||
|
take some time to complete.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-yellow-900/20 border border-yellow-800/50 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-400">
|
||||||
|
<Zap size={14} className="inline mr-1" />
|
||||||
|
Withdrawals are batched and processed after proof verification
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleWithdraw}
|
||||||
|
disabled={!amount || isTransacting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine size={18} />
|
||||||
|
{isTransacting ? 'Withdrawing...' : 'Withdraw to L1'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'transfer' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Transfer SYN to another L2 address. L2 transfers are instant and have minimal fees.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Recipient Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleTransfer}
|
||||||
|
disabled={!amount || !recipient || isTransacting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
{isTransacting ? 'Sending...' : 'Transfer on L2'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollup Info */}
|
||||||
|
{stats && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Rollup Status</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">State Root</p>
|
||||||
|
<code className="text-sm text-gray-300 break-all">{stats.stateRoot}</code>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-800 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Last Proof</p>
|
||||||
|
<p className="text-white">{formatTime(stats.lastProofAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
apps/desktop-wallet/src/store/addressbook.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[AddressBook] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[AddressBook] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressBookEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
notes?: string;
|
||||||
|
tags: string[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddressBookState {
|
||||||
|
entries: AddressBookEntry[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchAll: () => Promise<void>;
|
||||||
|
addEntry: (name: string, address: string, notes?: string, tags?: string[]) => Promise<AddressBookEntry>;
|
||||||
|
updateEntry: (id: string, name: string, address: string, notes?: string, tags?: string[]) => Promise<void>;
|
||||||
|
deleteEntry: (id: string) => Promise<void>;
|
||||||
|
findByAddress: (address: string) => AddressBookEntry | undefined;
|
||||||
|
findByTag: (tag: string) => AddressBookEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAddressBookStore = create<AddressBookState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
entries: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchAll: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const entries = await invoke<AddressBookEntry[]>('addressbook_get_all');
|
||||||
|
set({ entries, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchAll', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addEntry: async (name, address, notes, tags = []) => {
|
||||||
|
try {
|
||||||
|
const entry = await invoke<AddressBookEntry>('addressbook_add', {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
set((state) => ({ entries: [...state.entries, entry] }));
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Add failed';
|
||||||
|
logError('addEntry', error);
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEntry: async (id, name, address, notes, tags = []) => {
|
||||||
|
try {
|
||||||
|
const entry = await invoke<AddressBookEntry>('addressbook_update', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
entries: state.entries.map((e) => (e.id === id ? entry : e)),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Update failed';
|
||||||
|
logError('updateEntry', error);
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEntry: async (id) => {
|
||||||
|
try {
|
||||||
|
await invoke('addressbook_delete', { id });
|
||||||
|
set((state) => ({
|
||||||
|
entries: state.entries.filter((e) => e.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('deleteEntry', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
findByAddress: (address) => {
|
||||||
|
return get().entries.find(
|
||||||
|
(e) => e.address.toLowerCase() === address.toLowerCase()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
findByTag: (tag) => {
|
||||||
|
return get().entries.filter((e) =>
|
||||||
|
e.tags.some((t) => t.toLowerCase() === tag.toLowerCase())
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-addressbook-storage',
|
||||||
|
partialize: (state) => ({ entries: state.entries }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useAddressBookEntries(): AddressBookEntry[] {
|
||||||
|
return useAddressBookStore((state) => state.entries);
|
||||||
|
}
|
||||||
117
apps/desktop-wallet/src/store/alerts.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface PriceAlert {
|
||||||
|
id: string;
|
||||||
|
asset: string;
|
||||||
|
condition: 'above' | 'below';
|
||||||
|
targetPrice: number;
|
||||||
|
currentPrice: number;
|
||||||
|
isTriggered: boolean;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
triggeredAt?: number;
|
||||||
|
notificationMethod: 'push' | 'email' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsState {
|
||||||
|
alerts: PriceAlert[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsActions {
|
||||||
|
createAlert: (
|
||||||
|
asset: string,
|
||||||
|
condition: 'above' | 'below',
|
||||||
|
targetPrice: number,
|
||||||
|
notificationMethod: 'push' | 'email' | 'both'
|
||||||
|
) => Promise<PriceAlert>;
|
||||||
|
listAlerts: () => Promise<void>;
|
||||||
|
deleteAlert: (alertId: string) => Promise<void>;
|
||||||
|
toggleAlert: (alertId: string, enabled: boolean) => Promise<PriceAlert>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformAlert = (data: Record<string, unknown>): PriceAlert => ({
|
||||||
|
id: data.id as string,
|
||||||
|
asset: data.asset as string,
|
||||||
|
condition: data.condition as PriceAlert['condition'],
|
||||||
|
targetPrice: data.target_price as number,
|
||||||
|
currentPrice: data.current_price as number,
|
||||||
|
isTriggered: data.is_triggered as boolean,
|
||||||
|
isEnabled: data.is_enabled as boolean,
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
triggeredAt: data.triggered_at as number | undefined,
|
||||||
|
notificationMethod: data.notification_method as PriceAlert['notificationMethod'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAlertsStore = create<AlertsState & AlertsActions>((set) => ({
|
||||||
|
alerts: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createAlert: async (asset, condition, targetPrice, notificationMethod) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('alert_create', {
|
||||||
|
asset,
|
||||||
|
condition,
|
||||||
|
targetPrice,
|
||||||
|
notificationMethod,
|
||||||
|
});
|
||||||
|
const alert = transformAlert(data);
|
||||||
|
set((state) => ({
|
||||||
|
alerts: [alert, ...state.alerts],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return alert;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listAlerts: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('alert_list');
|
||||||
|
const alerts = data.map(transformAlert);
|
||||||
|
set({ alerts, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAlert: async (alertId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('alert_delete', { alertId });
|
||||||
|
set((state) => ({
|
||||||
|
alerts: state.alerts.filter((a) => a.id !== alertId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAlert: async (alertId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('alert_toggle', {
|
||||||
|
alertId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const alert = transformAlert(data);
|
||||||
|
set((state) => ({
|
||||||
|
alerts: state.alerts.map((a) => (a.id === alertId ? alert : a)),
|
||||||
|
}));
|
||||||
|
return alert;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
100
apps/desktop-wallet/src/store/backup.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Backup] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Backup] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportedWallet {
|
||||||
|
path: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportedHistory {
|
||||||
|
path: string;
|
||||||
|
transactionCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupState {
|
||||||
|
isExporting: boolean;
|
||||||
|
isImporting: boolean;
|
||||||
|
lastExport: ExportedWallet | null;
|
||||||
|
lastHistoryExport: ExportedHistory | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
exportWallet: (password: string, path: string) => Promise<ExportedWallet>;
|
||||||
|
importWallet: (path: string, password: string) => Promise<boolean>;
|
||||||
|
exportHistory: (path: string, format: 'json' | 'csv') => Promise<ExportedHistory>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBackupStore = create<BackupState>()((set) => ({
|
||||||
|
isExporting: false,
|
||||||
|
isImporting: false,
|
||||||
|
lastExport: null,
|
||||||
|
lastHistoryExport: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
exportWallet: async (password, path) => {
|
||||||
|
set({ isExporting: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<ExportedWallet>('backup_export_wallet', {
|
||||||
|
password,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
set({ lastExport: result, isExporting: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Export failed';
|
||||||
|
logError('exportWallet', error);
|
||||||
|
set({ error: msg, isExporting: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
importWallet: async (path, password) => {
|
||||||
|
set({ isImporting: true, error: null });
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('backup_import_wallet', {
|
||||||
|
path,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
set({ isImporting: false });
|
||||||
|
return success;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Import failed';
|
||||||
|
logError('importWallet', error);
|
||||||
|
set({ error: msg, isImporting: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportHistory: async (path, format) => {
|
||||||
|
set({ isExporting: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<ExportedHistory>('backup_export_history', {
|
||||||
|
path,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
set({ lastHistoryExport: result, isExporting: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'History export failed';
|
||||||
|
logError('exportHistory', error);
|
||||||
|
set({ error: msg, isExporting: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selector hooks
|
||||||
|
export function useIsBackupInProgress(): boolean {
|
||||||
|
return useBackupStore((state) => state.isExporting || state.isImporting);
|
||||||
|
}
|
||||||
325
apps/desktop-wallet/src/store/batchSend.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient in a batch transaction
|
||||||
|
*/
|
||||||
|
export interface BatchRecipient {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
amount: number; // in SYN (human readable)
|
||||||
|
amountSompi: number; // in sompi (internal)
|
||||||
|
label?: string;
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch transaction summary
|
||||||
|
*/
|
||||||
|
export interface BatchSummary {
|
||||||
|
totalAmount: number; // in sompi
|
||||||
|
totalAmountHuman: string;
|
||||||
|
recipientCount: number;
|
||||||
|
estimatedFee: number; // in sompi
|
||||||
|
estimatedFeeHuman: string;
|
||||||
|
totalWithFee: number; // in sompi
|
||||||
|
totalWithFeeHuman: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created batch transaction
|
||||||
|
*/
|
||||||
|
interface BatchTransactionResponse {
|
||||||
|
tx_hex: string;
|
||||||
|
tx_id: string;
|
||||||
|
total_sent: number;
|
||||||
|
fee: number;
|
||||||
|
recipient_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchSendState {
|
||||||
|
// State
|
||||||
|
recipients: BatchRecipient[];
|
||||||
|
summary: BatchSummary | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastTxId: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addRecipient: () => void;
|
||||||
|
removeRecipient: (id: string) => void;
|
||||||
|
updateRecipient: (id: string, updates: Partial<BatchRecipient>) => void;
|
||||||
|
clearRecipients: () => void;
|
||||||
|
importFromCsv: (csv: string) => void;
|
||||||
|
calculateSummary: () => void;
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
createBatchTransaction: (fee?: number) => Promise<string>;
|
||||||
|
signAndBroadcast: (txHex: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID
|
||||||
|
let idCounter = 0;
|
||||||
|
const generateId = () => `recipient-${++idCounter}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Convert SYN to sompi
|
||||||
|
const synToSompi = (syn: number): number => Math.floor(syn * 100_000_000);
|
||||||
|
|
||||||
|
// Convert sompi to SYN
|
||||||
|
const sompiToSyn = (sompi: number): string => (sompi / 100_000_000).toFixed(8);
|
||||||
|
|
||||||
|
// Validate address format
|
||||||
|
const validateAddress = (address: string): boolean => {
|
||||||
|
return address.startsWith('synor1') || address.startsWith('tsynor1');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBatchSendStore = create<BatchSendState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastTxId: null,
|
||||||
|
|
||||||
|
// Add a new recipient row
|
||||||
|
addRecipient: () => {
|
||||||
|
set((state) => ({
|
||||||
|
recipients: [
|
||||||
|
...state.recipients,
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a recipient
|
||||||
|
removeRecipient: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const newRecipients = state.recipients.filter((r) => r.id !== id);
|
||||||
|
// Keep at least one recipient row
|
||||||
|
if (newRecipients.length === 0) {
|
||||||
|
return {
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { recipients: newRecipients };
|
||||||
|
});
|
||||||
|
get().calculateSummary();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update a recipient
|
||||||
|
updateRecipient: (id, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
recipients: state.recipients.map((r) => {
|
||||||
|
if (r.id !== id) return r;
|
||||||
|
|
||||||
|
const newRecipient = { ...r, ...updates };
|
||||||
|
|
||||||
|
// Validate and update
|
||||||
|
if ('amount' in updates) {
|
||||||
|
newRecipient.amountSompi = synToSompi(updates.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address
|
||||||
|
const addressValid = validateAddress(newRecipient.address);
|
||||||
|
const amountValid = newRecipient.amountSompi > 0;
|
||||||
|
|
||||||
|
newRecipient.isValid = addressValid && amountValid;
|
||||||
|
|
||||||
|
if (!addressValid && newRecipient.address) {
|
||||||
|
newRecipient.error = 'Invalid address format';
|
||||||
|
} else if (!amountValid && newRecipient.amount > 0) {
|
||||||
|
newRecipient.error = 'Amount must be greater than 0';
|
||||||
|
} else {
|
||||||
|
newRecipient.error = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRecipient;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
get().calculateSummary();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all recipients
|
||||||
|
clearRecipients: () => {
|
||||||
|
set({
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
lastTxId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import recipients from CSV (address,amount format)
|
||||||
|
importFromCsv: (csv) => {
|
||||||
|
const lines = csv.trim().split('\n');
|
||||||
|
const recipients: BatchRecipient[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const [address, amountStr, label] = line.split(',').map((s) => s.trim());
|
||||||
|
const amount = parseFloat(amountStr) || 0;
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
const isValidAddress = validateAddress(address);
|
||||||
|
const isValidAmount = amount > 0;
|
||||||
|
|
||||||
|
recipients.push({
|
||||||
|
id: generateId(),
|
||||||
|
address,
|
||||||
|
amount,
|
||||||
|
amountSompi: synToSompi(amount),
|
||||||
|
label: label || undefined,
|
||||||
|
isValid: isValidAddress && isValidAmount,
|
||||||
|
error: !isValidAddress
|
||||||
|
? 'Invalid address'
|
||||||
|
: !isValidAmount
|
||||||
|
? 'Invalid amount'
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length > 0) {
|
||||||
|
set({ recipients });
|
||||||
|
get().calculateSummary();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Calculate transaction summary
|
||||||
|
calculateSummary: () => {
|
||||||
|
const { recipients } = get();
|
||||||
|
const validRecipients = recipients.filter((r) => r.isValid);
|
||||||
|
|
||||||
|
if (validRecipients.length === 0) {
|
||||||
|
set({ summary: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = validRecipients.reduce((sum, r) => sum + r.amountSompi, 0);
|
||||||
|
|
||||||
|
// Estimate fee (roughly 1000 sompi per recipient as base)
|
||||||
|
// This is a simplification - actual fee depends on tx size
|
||||||
|
const estimatedFee = Math.max(1000, validRecipients.length * 500 + 500);
|
||||||
|
|
||||||
|
set({
|
||||||
|
summary: {
|
||||||
|
totalAmount,
|
||||||
|
totalAmountHuman: `${sompiToSyn(totalAmount)} SYN`,
|
||||||
|
recipientCount: validRecipients.length,
|
||||||
|
estimatedFee,
|
||||||
|
estimatedFeeHuman: `${sompiToSyn(estimatedFee)} SYN`,
|
||||||
|
totalWithFee: totalAmount + estimatedFee,
|
||||||
|
totalWithFeeHuman: `${sompiToSyn(totalAmount + estimatedFee)} SYN`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a batch transaction (unsigned)
|
||||||
|
createBatchTransaction: async (fee) => {
|
||||||
|
const { recipients, summary } = get();
|
||||||
|
const validRecipients = recipients.filter((r) => r.isValid);
|
||||||
|
|
||||||
|
if (validRecipients.length === 0) {
|
||||||
|
throw new Error('No valid recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert to backend format
|
||||||
|
const outputs = validRecipients.map((r) => ({
|
||||||
|
address: r.address,
|
||||||
|
amount: r.amountSompi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await invoke<BatchTransactionResponse>('create_batch_transaction', {
|
||||||
|
outputs,
|
||||||
|
fee: fee || summary?.estimatedFee || 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
lastTxId: response.tx_id,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.tx_hex;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create batch transaction';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sign and broadcast the transaction
|
||||||
|
signAndBroadcast: async (txHex) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sign the transaction
|
||||||
|
const signedHex = await invoke<string>('sign_transaction', { txHex });
|
||||||
|
|
||||||
|
// Broadcast it
|
||||||
|
const txId = await invoke<string>('broadcast_transaction', { txHex: signedHex });
|
||||||
|
|
||||||
|
set({
|
||||||
|
lastTxId: txId,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to broadcast transaction';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid recipient count
|
||||||
|
*/
|
||||||
|
export function useValidRecipientCount(): number {
|
||||||
|
return useBatchSendStore((state) =>
|
||||||
|
state.recipients.filter((r) => r.isValid).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if batch is ready to send
|
||||||
|
*/
|
||||||
|
export function useIsBatchReady(): boolean {
|
||||||
|
const { recipients, summary } = useBatchSendStore();
|
||||||
|
const validCount = recipients.filter((r) => r.isValid).length;
|
||||||
|
return validCount > 0 && summary !== null;
|
||||||
|
}
|
||||||
179
apps/desktop-wallet/src/store/bridge.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Bridge] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Bridge] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeChainInfo {
|
||||||
|
chainId: string;
|
||||||
|
name: string;
|
||||||
|
nativeSymbol: string;
|
||||||
|
bridgeAddress: string;
|
||||||
|
isActive: boolean;
|
||||||
|
confirmations: number;
|
||||||
|
supportedTokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeTransferInfo {
|
||||||
|
transferId: string;
|
||||||
|
sourceChain: string;
|
||||||
|
destChain: string;
|
||||||
|
token: string;
|
||||||
|
amount: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
status: string;
|
||||||
|
sourceTxHash: string | null;
|
||||||
|
destTxHash: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BridgeState {
|
||||||
|
chains: BridgeChainInfo[];
|
||||||
|
transfers: BridgeTransferInfo[];
|
||||||
|
wrappedBalances: Record<string, string>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isTransferring: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchChains: () => Promise<void>;
|
||||||
|
fetchTransfers: () => Promise<void>;
|
||||||
|
deposit: (sourceChain: string, token: string, amount: string) => Promise<BridgeTransferInfo>;
|
||||||
|
withdraw: (destChain: string, destAddress: string, token: string, amount: string) => Promise<BridgeTransferInfo>;
|
||||||
|
getTransfer: (transferId: string) => Promise<BridgeTransferInfo>;
|
||||||
|
getWrappedBalance: (token: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBridgeStore = create<BridgeState>()((set) => ({
|
||||||
|
chains: [],
|
||||||
|
transfers: [],
|
||||||
|
wrappedBalances: {},
|
||||||
|
isLoading: false,
|
||||||
|
isTransferring: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchChains: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const chains = await invoke<BridgeChainInfo[]>('bridge_get_chains');
|
||||||
|
set({ chains, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchChains', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchTransfers: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const transfers = await invoke<BridgeTransferInfo[]>('bridge_list_transfers');
|
||||||
|
set({ transfers, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchTransfers', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deposit: async (sourceChain, token, amount) => {
|
||||||
|
set({ isTransferring: true });
|
||||||
|
try {
|
||||||
|
const transfer = await invoke<BridgeTransferInfo>('bridge_deposit', {
|
||||||
|
sourceChain,
|
||||||
|
token,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
transfers: [transfer, ...state.transfers],
|
||||||
|
isTransferring: false,
|
||||||
|
}));
|
||||||
|
return transfer;
|
||||||
|
} catch (error) {
|
||||||
|
logError('deposit', error);
|
||||||
|
set({ isTransferring: false, error: 'Failed to initiate deposit' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
withdraw: async (destChain, destAddress, token, amount) => {
|
||||||
|
set({ isTransferring: true });
|
||||||
|
try {
|
||||||
|
const transfer = await invoke<BridgeTransferInfo>('bridge_withdraw', {
|
||||||
|
destChain,
|
||||||
|
destAddress,
|
||||||
|
token,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
transfers: [transfer, ...state.transfers],
|
||||||
|
isTransferring: false,
|
||||||
|
}));
|
||||||
|
return transfer;
|
||||||
|
} catch (error) {
|
||||||
|
logError('withdraw', error);
|
||||||
|
set({ isTransferring: false, error: 'Failed to initiate withdrawal' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTransfer: async (transferId) => {
|
||||||
|
try {
|
||||||
|
const transfer = await invoke<BridgeTransferInfo>('bridge_get_transfer', { transferId });
|
||||||
|
set((state) => ({
|
||||||
|
transfers: state.transfers.map((t) => (t.transferId === transferId ? transfer : t)),
|
||||||
|
}));
|
||||||
|
return transfer;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getTransfer', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getWrappedBalance: async (token) => {
|
||||||
|
try {
|
||||||
|
const balance = await invoke<string>('bridge_get_wrapped_balance', { token });
|
||||||
|
set((state) => ({
|
||||||
|
wrappedBalances: { ...state.wrappedBalances, [token]: balance },
|
||||||
|
}));
|
||||||
|
return balance;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getWrappedBalance', error);
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function getChainIcon(chainId: string): string {
|
||||||
|
switch (chainId) {
|
||||||
|
case 'ethereum':
|
||||||
|
return '⟠';
|
||||||
|
case 'bitcoin':
|
||||||
|
return '₿';
|
||||||
|
case 'cosmos':
|
||||||
|
return '⚛';
|
||||||
|
default:
|
||||||
|
return '🔗';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'pending':
|
||||||
|
case 'confirming':
|
||||||
|
case 'relaying':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
112
apps/desktop-wallet/src/store/cli.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface CliResult {
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
isError: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliState {
|
||||||
|
history: CliResult[];
|
||||||
|
commandHistory: string[];
|
||||||
|
historyIndex: number;
|
||||||
|
isExecuting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliActions {
|
||||||
|
execute: (command: string) => Promise<CliResult>;
|
||||||
|
loadHistory: () => Promise<void>;
|
||||||
|
clearOutput: () => void;
|
||||||
|
navigateHistory: (direction: 'up' | 'down') => string | null;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformResult = (data: Record<string, unknown>): CliResult => ({
|
||||||
|
command: data.command as string,
|
||||||
|
output: data.output as string,
|
||||||
|
isError: data.is_error as boolean,
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCliStore = create<CliState & CliActions>((set, get) => ({
|
||||||
|
history: [],
|
||||||
|
commandHistory: [],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
execute: async (command: string) => {
|
||||||
|
try {
|
||||||
|
set({ isExecuting: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('cli_execute', { command });
|
||||||
|
const result = transformResult(data);
|
||||||
|
|
||||||
|
// Handle special "CLEAR" output
|
||||||
|
if (result.output === 'CLEAR') {
|
||||||
|
set({
|
||||||
|
history: [],
|
||||||
|
commandHistory: [...get().commandHistory, command],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
});
|
||||||
|
return { ...result, output: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
history: [...state.history, result],
|
||||||
|
commandHistory: [...state.commandHistory, command],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorResult: CliResult = {
|
||||||
|
command,
|
||||||
|
output: String(error),
|
||||||
|
isError: true,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
set((state) => ({
|
||||||
|
history: [...state.history, errorResult],
|
||||||
|
isExecuting: false,
|
||||||
|
error: String(error),
|
||||||
|
}));
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHistory: async () => {
|
||||||
|
try {
|
||||||
|
const commands = await invoke<string[]>('cli_get_history');
|
||||||
|
set({ commandHistory: commands });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearOutput: () => {
|
||||||
|
set({ history: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateHistory: (direction: 'up' | 'down') => {
|
||||||
|
const { commandHistory, historyIndex } = get();
|
||||||
|
if (commandHistory.length === 0) return null;
|
||||||
|
|
||||||
|
let newIndex = historyIndex;
|
||||||
|
if (direction === 'up') {
|
||||||
|
newIndex = historyIndex === -1 ? commandHistory.length - 1 : Math.max(0, historyIndex - 1);
|
||||||
|
} else {
|
||||||
|
newIndex = historyIndex === -1 ? -1 : Math.min(commandHistory.length - 1, historyIndex + 1);
|
||||||
|
if (newIndex === historyIndex) newIndex = -1; // Reset at end
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ historyIndex: newIndex });
|
||||||
|
return newIndex === -1 ? '' : commandHistory[newIndex];
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
142
apps/desktop-wallet/src/store/compute.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Compute] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Compute] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeProviderInfo {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
gpuTypes: string[];
|
||||||
|
cpuCores: number;
|
||||||
|
memoryGb: number;
|
||||||
|
pricePerHour: string;
|
||||||
|
reputation: number;
|
||||||
|
isAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeJobInfo {
|
||||||
|
jobId: string;
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
gpuType: string | null;
|
||||||
|
cpuCores: number;
|
||||||
|
memoryGb: number;
|
||||||
|
startedAt: number | null;
|
||||||
|
endedAt: number | null;
|
||||||
|
totalCost: string;
|
||||||
|
resultCid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComputeState {
|
||||||
|
providers: ComputeProviderInfo[];
|
||||||
|
jobs: ComputeJobInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchProviders: (gpuType?: string, minMemoryGb?: number) => Promise<void>;
|
||||||
|
fetchJobs: () => Promise<void>;
|
||||||
|
submitJob: (params: {
|
||||||
|
provider: string;
|
||||||
|
inputCid: string;
|
||||||
|
dockerImage: string;
|
||||||
|
command: string[];
|
||||||
|
gpuType?: string;
|
||||||
|
cpuCores: number;
|
||||||
|
memoryGb: number;
|
||||||
|
maxHours: number;
|
||||||
|
}) => Promise<ComputeJobInfo>;
|
||||||
|
getJob: (jobId: string) => Promise<ComputeJobInfo>;
|
||||||
|
cancelJob: (jobId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComputeStore = create<ComputeState>()((set) => ({
|
||||||
|
providers: [],
|
||||||
|
jobs: [],
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchProviders: async (gpuType, minMemoryGb) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const providers = await invoke<ComputeProviderInfo[]>('compute_list_providers', {
|
||||||
|
gpuType,
|
||||||
|
minMemoryGb,
|
||||||
|
});
|
||||||
|
set({ providers, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchProviders', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchJobs: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const jobs = await invoke<ComputeJobInfo[]>('compute_list_jobs');
|
||||||
|
set({ jobs, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchJobs', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
submitJob: async (params) => {
|
||||||
|
set({ isSubmitting: true });
|
||||||
|
try {
|
||||||
|
const job = await invoke<ComputeJobInfo>('compute_submit_job', params);
|
||||||
|
set((state) => ({
|
||||||
|
jobs: [job, ...state.jobs],
|
||||||
|
isSubmitting: false,
|
||||||
|
}));
|
||||||
|
return job;
|
||||||
|
} catch (error) {
|
||||||
|
logError('submitJob', error);
|
||||||
|
set({ isSubmitting: false, error: 'Failed to submit job' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getJob: async (jobId) => {
|
||||||
|
try {
|
||||||
|
const job = await invoke<ComputeJobInfo>('compute_get_job', { jobId });
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((j) => (j.jobId === jobId ? job : j)),
|
||||||
|
}));
|
||||||
|
return job;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getJob', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelJob: async (jobId) => {
|
||||||
|
try {
|
||||||
|
await invoke('compute_cancel_job', { jobId });
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((j) =>
|
||||||
|
j.jobId === jobId ? { ...j, status: 'cancelled' } : j
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('cancelJob', error);
|
||||||
|
set({ error: 'Failed to cancel job' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function formatPrice(price: string): string {
|
||||||
|
const num = parseFloat(price);
|
||||||
|
return `${num.toFixed(4)} SYN`;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitized error logging
|
* Sanitized error logging
|
||||||
|
|
|
||||||
122
apps/desktop-wallet/src/store/dapps.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[DApps] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[DApps] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedDApp {
|
||||||
|
origin: string;
|
||||||
|
name: string;
|
||||||
|
connectedAddress: string;
|
||||||
|
permissions: string[];
|
||||||
|
connectedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DAppsState {
|
||||||
|
connectedDApps: ConnectedDApp[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchConnected: () => Promise<void>;
|
||||||
|
connect: (origin: string, name: string, address: string, permissions: string[]) => Promise<ConnectedDApp>;
|
||||||
|
disconnect: (origin: string) => Promise<void>;
|
||||||
|
handleRequest: (origin: string, method: string, params: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDAppsStore = create<DAppsState>()((set) => ({
|
||||||
|
connectedDApps: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchConnected: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const dapps = await invoke<ConnectedDApp[]>('dapp_get_connected');
|
||||||
|
set({ connectedDApps: dapps, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchConnected', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connect: async (origin, name, address, permissions) => {
|
||||||
|
try {
|
||||||
|
const dapp = await invoke<ConnectedDApp>('dapp_connect', {
|
||||||
|
origin,
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
connectedDApps: [
|
||||||
|
...state.connectedDApps.filter((d) => d.origin !== origin),
|
||||||
|
dapp,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return dapp;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Connect failed';
|
||||||
|
logError('connect', error);
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: async (origin) => {
|
||||||
|
try {
|
||||||
|
await invoke('dapp_disconnect', { origin });
|
||||||
|
set((state) => ({
|
||||||
|
connectedDApps: state.connectedDApps.filter((d) => d.origin !== origin),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('disconnect', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRequest: async (origin, method, params) => {
|
||||||
|
try {
|
||||||
|
return await invoke('dapp_handle_request', { origin, method, params });
|
||||||
|
} catch (error) {
|
||||||
|
logError('handleRequest', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function useConnectedDApps(): ConnectedDApp[] {
|
||||||
|
return useDAppsStore((state) => state.connectedDApps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popular DApps for discovery
|
||||||
|
export const POPULAR_DAPPS = [
|
||||||
|
{
|
||||||
|
name: 'SynorSwap',
|
||||||
|
description: 'Decentralized exchange for Synor tokens',
|
||||||
|
url: 'https://swap.synor.cc',
|
||||||
|
icon: '🔄',
|
||||||
|
category: 'DeFi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SynorNFT',
|
||||||
|
description: 'NFT marketplace on Synor',
|
||||||
|
url: 'https://nft.synor.cc',
|
||||||
|
icon: '🖼️',
|
||||||
|
category: 'NFT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SynorStake',
|
||||||
|
description: 'Staking platform for SYN tokens',
|
||||||
|
url: 'https://stake.synor.cc',
|
||||||
|
icon: '📈',
|
||||||
|
category: 'DeFi',
|
||||||
|
},
|
||||||
|
];
|
||||||
132
apps/desktop-wallet/src/store/database.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Database] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Database] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatabaseType = 'kv' | 'document' | 'vector' | 'timeseries' | 'graph' | 'sql';
|
||||||
|
|
||||||
|
export interface DatabaseInstanceInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dbType: DatabaseType;
|
||||||
|
region: string;
|
||||||
|
status: string;
|
||||||
|
storageUsed: number;
|
||||||
|
readOps: number;
|
||||||
|
writeOps: number;
|
||||||
|
monthlyCost: string;
|
||||||
|
connectionString: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatabaseState {
|
||||||
|
instances: DatabaseInstanceInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isCreating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchInstances: () => Promise<void>;
|
||||||
|
createDatabase: (name: string, dbType: DatabaseType, region: string) => Promise<DatabaseInstanceInfo>;
|
||||||
|
getDatabase: (dbId: string) => Promise<DatabaseInstanceInfo>;
|
||||||
|
deleteDatabase: (dbId: string) => Promise<void>;
|
||||||
|
executeQuery: (dbId: string, query: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDatabaseStore = create<DatabaseState>()((set) => ({
|
||||||
|
instances: [],
|
||||||
|
isLoading: false,
|
||||||
|
isCreating: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchInstances: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const instances = await invoke<DatabaseInstanceInfo[]>('database_list');
|
||||||
|
set({ instances, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchInstances', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createDatabase: async (name, dbType, region) => {
|
||||||
|
set({ isCreating: true });
|
||||||
|
try {
|
||||||
|
const instance = await invoke<DatabaseInstanceInfo>('database_create', {
|
||||||
|
name,
|
||||||
|
dbType,
|
||||||
|
region,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
instances: [instance, ...state.instances],
|
||||||
|
isCreating: false,
|
||||||
|
}));
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
logError('createDatabase', error);
|
||||||
|
set({ isCreating: false, error: 'Failed to create database' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getDatabase: async (dbId) => {
|
||||||
|
try {
|
||||||
|
const instance = await invoke<DatabaseInstanceInfo>('database_get_info', { dbId });
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map((i) => (i.id === dbId ? instance : i)),
|
||||||
|
}));
|
||||||
|
return instance;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getDatabase', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDatabase: async (dbId) => {
|
||||||
|
try {
|
||||||
|
await invoke('database_delete', { dbId });
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.filter((i) => i.id !== dbId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('deleteDatabase', error);
|
||||||
|
set({ error: 'Failed to delete database' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
executeQuery: async (dbId, query) => {
|
||||||
|
try {
|
||||||
|
const result = await invoke('database_query', { dbId, query });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logError('executeQuery', error);
|
||||||
|
set({ error: 'Query execution failed' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const DATABASE_TYPES: { value: DatabaseType; label: string; description: string }[] = [
|
||||||
|
{ value: 'kv', label: 'Key-Value', description: 'Redis-compatible fast key-value store' },
|
||||||
|
{ value: 'document', label: 'Document', description: 'MongoDB-compatible document database' },
|
||||||
|
{ value: 'vector', label: 'Vector', description: 'AI/ML vector search database' },
|
||||||
|
{ value: 'timeseries', label: 'Time-Series', description: 'Metrics and analytics database' },
|
||||||
|
{ value: 'graph', label: 'Graph', description: 'Node/edge relationship database' },
|
||||||
|
{ value: 'sql', label: 'SQL', description: 'PostgreSQL-compatible relational database' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REGIONS = [
|
||||||
|
{ value: 'us-east', label: 'US East (Virginia)' },
|
||||||
|
{ value: 'us-west', label: 'US West (Oregon)' },
|
||||||
|
{ value: 'eu-west', label: 'EU West (Ireland)' },
|
||||||
|
{ value: 'ap-southeast', label: 'Asia Pacific (Singapore)' },
|
||||||
|
];
|
||||||
148
apps/desktop-wallet/src/store/decoy.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decoy wallet for plausible deniability
|
||||||
|
*/
|
||||||
|
export interface DecoyWallet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
balance: number;
|
||||||
|
balanceHuman: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessed?: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoyState {
|
||||||
|
// State
|
||||||
|
isEnabled: boolean;
|
||||||
|
decoys: DecoyWallet[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkEnabled: () => Promise<void>;
|
||||||
|
setup: (duressPassword: string) => Promise<void>;
|
||||||
|
createDecoy: (name: string, balance: number) => Promise<DecoyWallet>;
|
||||||
|
fetchDecoys: () => Promise<void>;
|
||||||
|
updateBalance: (decoyId: string, balance: number) => Promise<void>;
|
||||||
|
deleteDecoy: (decoyId: string) => Promise<void>;
|
||||||
|
disable: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sompiToSyn = (sompi: number): string => {
|
||||||
|
return `${(sompi / 100_000_000).toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformDecoy(data: any): DecoyWallet {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
balance: data.balance,
|
||||||
|
balanceHuman: sompiToSyn(data.balance),
|
||||||
|
createdAt: data.created_at,
|
||||||
|
lastAccessed: data.last_accessed || undefined,
|
||||||
|
isActive: data.is_active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDecoyStore = create<DecoyState>()((set) => ({
|
||||||
|
isEnabled: false,
|
||||||
|
decoys: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
checkEnabled: async () => {
|
||||||
|
try {
|
||||||
|
const enabled = await invoke<boolean>('decoy_is_enabled');
|
||||||
|
set({ isEnabled: enabled });
|
||||||
|
} catch (error) {
|
||||||
|
set({ isEnabled: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (duressPassword) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('decoy_setup', { duressPassword });
|
||||||
|
set({ isEnabled: true, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to setup decoy wallets';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createDecoy: async (name, balance) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('decoy_create', {
|
||||||
|
name,
|
||||||
|
initialBalance: Math.floor(balance * 100_000_000),
|
||||||
|
});
|
||||||
|
const decoy = transformDecoy(data);
|
||||||
|
set((state) => ({
|
||||||
|
decoys: [...state.decoys, decoy],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return decoy;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create decoy';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchDecoys: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await invoke<any[]>('decoy_list');
|
||||||
|
const decoys = data.map(transformDecoy);
|
||||||
|
set({ decoys, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch decoys';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBalance: async (decoyId, balance) => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_update_balance', {
|
||||||
|
decoyId,
|
||||||
|
balance: Math.floor(balance * 100_000_000),
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
decoys: state.decoys.map((d) =>
|
||||||
|
d.id === decoyId
|
||||||
|
? { ...d, balance: balance * 100_000_000, balanceHuman: sompiToSyn(balance * 100_000_000) }
|
||||||
|
: d
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDecoy: async (decoyId) => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_delete', { decoyId });
|
||||||
|
set((state) => ({
|
||||||
|
decoys: state.decoys.filter((d) => d.id !== decoyId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disable: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_disable');
|
||||||
|
set({ isEnabled: false });
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
227
apps/desktop-wallet/src/store/feeAnalytics.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool statistics from the network
|
||||||
|
*/
|
||||||
|
export interface MempoolStats {
|
||||||
|
txCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
totalFees: number;
|
||||||
|
minFeeRate: number; // sompi per byte
|
||||||
|
avgFeeRate: number;
|
||||||
|
maxFeeRate: number;
|
||||||
|
percentile10: number; // fee rate at 10th percentile
|
||||||
|
percentile50: number; // fee rate at 50th percentile (median)
|
||||||
|
percentile90: number; // fee rate at 90th percentile
|
||||||
|
lastUpdated: number; // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee tier recommendation
|
||||||
|
*/
|
||||||
|
export interface FeeRecommendation {
|
||||||
|
tier: 'economy' | 'standard' | 'priority' | 'instant';
|
||||||
|
feeRate: number; // sompi per byte
|
||||||
|
estimatedBlocks: number;
|
||||||
|
estimatedTimeSecs: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historical fee data point for charting
|
||||||
|
*/
|
||||||
|
export interface FeeHistoryPoint {
|
||||||
|
timestamp: number;
|
||||||
|
avgFeeRate: number;
|
||||||
|
minFeeRate: number;
|
||||||
|
maxFeeRate: number;
|
||||||
|
blockHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full fee analytics data
|
||||||
|
*/
|
||||||
|
export interface FeeAnalytics {
|
||||||
|
mempool: MempoolStats;
|
||||||
|
recommendations: FeeRecommendation[];
|
||||||
|
feeHistory: FeeHistoryPoint[];
|
||||||
|
networkCongestion: 'low' | 'medium' | 'high';
|
||||||
|
blockTargetTimeSecs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeeAnalyticsState {
|
||||||
|
// State
|
||||||
|
analytics: FeeAnalytics | null;
|
||||||
|
selectedTier: FeeRecommendation['tier'];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchAnalytics: () => Promise<void>;
|
||||||
|
setSelectedTier: (tier: FeeRecommendation['tier']) => void;
|
||||||
|
setAutoRefresh: (enabled: boolean) => void;
|
||||||
|
calculateFee: (txSizeBytes: number) => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case backend response to camelCase
|
||||||
|
function transformMempoolStats(data: any): MempoolStats {
|
||||||
|
return {
|
||||||
|
txCount: data.tx_count,
|
||||||
|
totalSizeBytes: data.total_size_bytes,
|
||||||
|
totalFees: data.total_fees,
|
||||||
|
minFeeRate: data.min_fee_rate,
|
||||||
|
avgFeeRate: data.avg_fee_rate,
|
||||||
|
maxFeeRate: data.max_fee_rate,
|
||||||
|
percentile10: data.percentile_10,
|
||||||
|
percentile50: data.percentile_50,
|
||||||
|
percentile90: data.percentile_90,
|
||||||
|
lastUpdated: data.last_updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRecommendation(data: any): FeeRecommendation {
|
||||||
|
return {
|
||||||
|
tier: data.tier as FeeRecommendation['tier'],
|
||||||
|
feeRate: data.fee_rate,
|
||||||
|
estimatedBlocks: data.estimated_blocks,
|
||||||
|
estimatedTimeSecs: data.estimated_time_secs,
|
||||||
|
description: data.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformFeeHistory(data: any): FeeHistoryPoint {
|
||||||
|
return {
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
avgFeeRate: data.avg_fee_rate,
|
||||||
|
minFeeRate: data.min_fee_rate,
|
||||||
|
maxFeeRate: data.max_fee_rate,
|
||||||
|
blockHeight: data.block_height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformAnalytics(data: any): FeeAnalytics {
|
||||||
|
return {
|
||||||
|
mempool: transformMempoolStats(data.mempool),
|
||||||
|
recommendations: data.recommendations.map(transformRecommendation),
|
||||||
|
feeHistory: data.fee_history.map(transformFeeHistory),
|
||||||
|
networkCongestion: data.network_congestion as FeeAnalytics['networkCongestion'],
|
||||||
|
blockTargetTimeSecs: data.block_target_time_secs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFeeAnalyticsStore = create<FeeAnalyticsState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
analytics: null,
|
||||||
|
selectedTier: 'standard',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshIntervalMs: 30000, // 30 seconds
|
||||||
|
|
||||||
|
// Fetch all analytics data
|
||||||
|
fetchAnalytics: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('fee_get_analytics');
|
||||||
|
const analytics = transformAnalytics(data);
|
||||||
|
set({ analytics, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch fee analytics';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set selected fee tier
|
||||||
|
setSelectedTier: (tier) => {
|
||||||
|
set({ selectedTier: tier });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle auto-refresh
|
||||||
|
setAutoRefresh: (enabled) => {
|
||||||
|
set({ autoRefresh: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Calculate fee for a transaction
|
||||||
|
calculateFee: async (txSizeBytes) => {
|
||||||
|
const { selectedTier } = get();
|
||||||
|
try {
|
||||||
|
const fee = await invoke<number>('fee_calculate', {
|
||||||
|
txSizeBytes,
|
||||||
|
tier: selectedTier,
|
||||||
|
});
|
||||||
|
return fee;
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback calculation
|
||||||
|
const { analytics } = get();
|
||||||
|
const recommendation = analytics?.recommendations.find((r) => r.tier === selectedTier);
|
||||||
|
const feeRate = recommendation?.feeRate || 1.0;
|
||||||
|
return Math.ceil(txSizeBytes * feeRate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the selected recommendation
|
||||||
|
*/
|
||||||
|
export function useSelectedRecommendation(): FeeRecommendation | null {
|
||||||
|
const { analytics, selectedTier } = useFeeAnalyticsStore();
|
||||||
|
return analytics?.recommendations.find((r) => r.tier === selectedTier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get congestion color class
|
||||||
|
*/
|
||||||
|
export function getCongestionColor(congestion: FeeAnalytics['networkCongestion']): string {
|
||||||
|
switch (congestion) {
|
||||||
|
case 'low':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'high':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get congestion background color
|
||||||
|
*/
|
||||||
|
export function getCongestionBgColor(congestion: FeeAnalytics['networkCongestion']): string {
|
||||||
|
switch (congestion) {
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-500/20 border-green-500/30';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-500/20 border-yellow-500/30';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-500/20 border-red-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 border-gray-500/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time duration in human readable format
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `~${seconds}s`;
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
return `~${mins}m`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
return `~${hours}h`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format fee rate
|
||||||
|
*/
|
||||||
|
export function formatFeeRate(rate: number): string {
|
||||||
|
return `${rate.toFixed(2)} sompi/byte`;
|
||||||
|
}
|
||||||
214
apps/desktop-wallet/src/store/governance.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Governance] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Governance] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GovernanceProposal {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
proposer: string;
|
||||||
|
status: string;
|
||||||
|
forVotes: string;
|
||||||
|
againstVotes: string;
|
||||||
|
abstainVotes: string;
|
||||||
|
quorum: string;
|
||||||
|
startBlock: number;
|
||||||
|
endBlock: number;
|
||||||
|
executionDelay: number;
|
||||||
|
userVoted: boolean;
|
||||||
|
userVote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VotingPowerInfo {
|
||||||
|
votingPower: string;
|
||||||
|
delegatedOut: string;
|
||||||
|
delegatedIn: string;
|
||||||
|
delegate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GovernanceState {
|
||||||
|
proposals: GovernanceProposal[];
|
||||||
|
votingPower: VotingPowerInfo | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isVoting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchProposals: (statusFilter?: string) => Promise<void>;
|
||||||
|
fetchProposal: (proposalId: string) => Promise<GovernanceProposal>;
|
||||||
|
fetchVotingPower: () => Promise<void>;
|
||||||
|
createProposal: (title: string, description: string, actions: string[]) => Promise<string>;
|
||||||
|
vote: (proposalId: string, vote: 'for' | 'against' | 'abstain') => Promise<string>;
|
||||||
|
executeProposal: (proposalId: string) => Promise<string>;
|
||||||
|
delegate: (delegateTo: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGovernanceStore = create<GovernanceState>()((set) => ({
|
||||||
|
proposals: [],
|
||||||
|
votingPower: null,
|
||||||
|
isLoading: false,
|
||||||
|
isVoting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchProposals: async (statusFilter) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const proposals = await invoke<GovernanceProposal[]>('governance_get_proposals', {
|
||||||
|
statusFilter,
|
||||||
|
});
|
||||||
|
set({ proposals, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchProposals', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchProposal: async (proposalId) => {
|
||||||
|
try {
|
||||||
|
const proposal = await invoke<GovernanceProposal>('governance_get_proposal', {
|
||||||
|
proposalId,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
proposals: state.proposals.map((p) => (p.id === proposalId ? proposal : p)),
|
||||||
|
}));
|
||||||
|
return proposal;
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchProposal', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchVotingPower: async () => {
|
||||||
|
try {
|
||||||
|
const votingPower = await invoke<VotingPowerInfo>('governance_get_voting_power');
|
||||||
|
set({ votingPower });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchVotingPower', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createProposal: async (title, description, actions) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('governance_create_proposal', {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
});
|
||||||
|
set({ isLoading: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('createProposal', error);
|
||||||
|
set({ isLoading: false, error: 'Failed to create proposal' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
vote: async (proposalId, vote) => {
|
||||||
|
set({ isVoting: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('governance_vote', { proposalId, vote });
|
||||||
|
set((state) => ({
|
||||||
|
proposals: state.proposals.map((p) =>
|
||||||
|
p.id === proposalId ? { ...p, userVoted: true, userVote: vote } : p
|
||||||
|
),
|
||||||
|
isVoting: false,
|
||||||
|
}));
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('vote', error);
|
||||||
|
set({ isVoting: false, error: 'Failed to submit vote' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
executeProposal: async (proposalId) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('governance_execute_proposal', { proposalId });
|
||||||
|
set({ isLoading: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('executeProposal', error);
|
||||||
|
set({ isLoading: false, error: 'Failed to execute proposal' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delegate: async (delegateTo) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('governance_delegate', { delegateTo });
|
||||||
|
set({ isLoading: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('delegate', error);
|
||||||
|
set({ isLoading: false, error: 'Failed to delegate voting power' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Pending';
|
||||||
|
case 'active':
|
||||||
|
return 'Active';
|
||||||
|
case 'passed':
|
||||||
|
return 'Passed';
|
||||||
|
case 'rejected':
|
||||||
|
return 'Rejected';
|
||||||
|
case 'executed':
|
||||||
|
return 'Executed';
|
||||||
|
case 'expired':
|
||||||
|
return 'Expired';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'bg-blue-500/20 text-blue-400';
|
||||||
|
case 'passed':
|
||||||
|
return 'bg-green-500/20 text-green-400';
|
||||||
|
case 'rejected':
|
||||||
|
return 'bg-red-500/20 text-red-400';
|
||||||
|
case 'executed':
|
||||||
|
return 'bg-purple-500/20 text-purple-400';
|
||||||
|
case 'expired':
|
||||||
|
return 'bg-gray-500/20 text-gray-400';
|
||||||
|
default:
|
||||||
|
return 'bg-yellow-500/20 text-yellow-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateVotePercentage(
|
||||||
|
forVotes: string,
|
||||||
|
againstVotes: string,
|
||||||
|
abstainVotes: string
|
||||||
|
): { for: number; against: number; abstain: number } {
|
||||||
|
const forNum = parseFloat(forVotes) || 0;
|
||||||
|
const againstNum = parseFloat(againstVotes) || 0;
|
||||||
|
const abstainNum = parseFloat(abstainVotes) || 0;
|
||||||
|
const total = forNum + againstNum + abstainNum;
|
||||||
|
|
||||||
|
if (total === 0) return { for: 0, against: 0, abstain: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
for: (forNum / total) * 100,
|
||||||
|
against: (againstNum / total) * 100,
|
||||||
|
abstain: (abstainNum / total) * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
apps/desktop-wallet/src/store/hardware.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Hardware] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Hardware] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deviceType: 'ledger' | 'trezor' | 'unknown';
|
||||||
|
connected: boolean;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareAddress {
|
||||||
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedTransaction {
|
||||||
|
txId: string;
|
||||||
|
signedTx: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HardwareState {
|
||||||
|
devices: HardwareDevice[];
|
||||||
|
selectedDevice: HardwareDevice | null;
|
||||||
|
isScanning: boolean;
|
||||||
|
isSigning: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
detectDevices: () => Promise<HardwareDevice[]>;
|
||||||
|
selectDevice: (device: HardwareDevice | null) => void;
|
||||||
|
getAddress: (deviceId: string, accountIndex: number) => Promise<HardwareAddress>;
|
||||||
|
signTransaction: (
|
||||||
|
deviceId: string,
|
||||||
|
txHex: string,
|
||||||
|
accountIndex: number
|
||||||
|
) => Promise<SignedTransaction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHardwareStore = create<HardwareState>()((set) => ({
|
||||||
|
devices: [],
|
||||||
|
selectedDevice: null,
|
||||||
|
isScanning: false,
|
||||||
|
isSigning: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
detectDevices: async () => {
|
||||||
|
set({ isScanning: true, error: null });
|
||||||
|
try {
|
||||||
|
const devices = await invoke<HardwareDevice[]>('hardware_detect_devices');
|
||||||
|
set({ devices, isScanning: false });
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Device detection failed';
|
||||||
|
logError('detectDevices', error);
|
||||||
|
set({ error: msg, isScanning: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectDevice: (device) => set({ selectedDevice: device }),
|
||||||
|
|
||||||
|
getAddress: async (deviceId, accountIndex) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const address = await invoke<HardwareAddress>('hardware_get_address', {
|
||||||
|
deviceId,
|
||||||
|
accountIndex,
|
||||||
|
});
|
||||||
|
return address;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to get address';
|
||||||
|
logError('getAddress', error);
|
||||||
|
set({ error: msg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signTransaction: async (deviceId, txHex, accountIndex) => {
|
||||||
|
set({ isSigning: true, error: null });
|
||||||
|
try {
|
||||||
|
const signed = await invoke<SignedTransaction>('hardware_sign_transaction', {
|
||||||
|
deviceId,
|
||||||
|
txHex,
|
||||||
|
accountIndex,
|
||||||
|
});
|
||||||
|
set({ isSigning: false });
|
||||||
|
return signed;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Signing failed';
|
||||||
|
logError('signTransaction', error);
|
||||||
|
set({ error: msg, isSigning: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selector hooks
|
||||||
|
export function useHardwareDevices(): HardwareDevice[] {
|
||||||
|
return useHardwareStore((state) => state.devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectedDevice(): HardwareDevice | null {
|
||||||
|
return useHardwareStore((state) => state.selectedDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsHardwareSigning(): boolean {
|
||||||
|
return useHardwareStore((state) => state.isSigning);
|
||||||
|
}
|
||||||
135
apps/desktop-wallet/src/store/hosting.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Hosting] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Hosting] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostedSiteInfo {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
customDomain: string | null;
|
||||||
|
contentCid: string;
|
||||||
|
deployedAt: number;
|
||||||
|
sslEnabled: boolean;
|
||||||
|
bandwidthUsed: number;
|
||||||
|
monthlyCost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainVerificationStatus {
|
||||||
|
domain: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
txtRecord: string;
|
||||||
|
expectedValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostingState {
|
||||||
|
sites: HostedSiteInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isDeploying: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchSites: () => Promise<void>;
|
||||||
|
registerName: (name: string) => Promise<HostedSiteInfo>;
|
||||||
|
deploySite: (name: string, contentCid: string) => Promise<HostedSiteInfo>;
|
||||||
|
addCustomDomain: (name: string, customDomain: string) => Promise<DomainVerificationStatus>;
|
||||||
|
verifyDomain: (customDomain: string) => Promise<DomainVerificationStatus>;
|
||||||
|
deleteSite: (name: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHostingStore = create<HostingState>()((set) => ({
|
||||||
|
sites: [],
|
||||||
|
isLoading: false,
|
||||||
|
isDeploying: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchSites: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const sites = await invoke<HostedSiteInfo[]>('hosting_list_sites');
|
||||||
|
set({ sites, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchSites', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
registerName: async (name) => {
|
||||||
|
set({ isDeploying: true });
|
||||||
|
try {
|
||||||
|
const site = await invoke<HostedSiteInfo>('hosting_register_name', { name });
|
||||||
|
set((state) => ({
|
||||||
|
sites: [site, ...state.sites],
|
||||||
|
isDeploying: false,
|
||||||
|
}));
|
||||||
|
return site;
|
||||||
|
} catch (error) {
|
||||||
|
logError('registerName', error);
|
||||||
|
set({ isDeploying: false, error: 'Failed to register name' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deploySite: async (name, contentCid) => {
|
||||||
|
set({ isDeploying: true });
|
||||||
|
try {
|
||||||
|
const site = await invoke<HostedSiteInfo>('hosting_deploy', { name, contentCid });
|
||||||
|
set((state) => ({
|
||||||
|
sites: state.sites.map((s) => (s.name === name ? site : s)),
|
||||||
|
isDeploying: false,
|
||||||
|
}));
|
||||||
|
return site;
|
||||||
|
} catch (error) {
|
||||||
|
logError('deploySite', error);
|
||||||
|
set({ isDeploying: false, error: 'Failed to deploy site' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addCustomDomain: async (name, customDomain) => {
|
||||||
|
try {
|
||||||
|
const status = await invoke<DomainVerificationStatus>('hosting_add_custom_domain', {
|
||||||
|
name,
|
||||||
|
customDomain,
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
logError('addCustomDomain', error);
|
||||||
|
set({ error: 'Failed to add custom domain' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyDomain: async (customDomain) => {
|
||||||
|
try {
|
||||||
|
const status = await invoke<DomainVerificationStatus>('hosting_verify_domain', {
|
||||||
|
customDomain,
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
logError('verifyDomain', error);
|
||||||
|
set({ error: 'Failed to verify domain' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSite: async (name) => {
|
||||||
|
try {
|
||||||
|
await invoke('hosting_delete_site', { name });
|
||||||
|
set((state) => ({
|
||||||
|
sites: state.sites.filter((s) => s.name !== name),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('deleteSite', error);
|
||||||
|
set({ error: 'Failed to delete site' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
export { useWalletStore } from './wallet';
|
export { useWalletStore } from './wallet';
|
||||||
export type { WalletAddress, Balance, NetworkStatus } from './wallet';
|
export type { WalletAddress, Balance, NetworkStatus } from './wallet';
|
||||||
|
|
||||||
|
// Multi-wallet management
|
||||||
|
export { useWalletManagerStore } from './walletManager';
|
||||||
|
export type { WalletSummary, ActiveWalletInfo } from './walletManager';
|
||||||
|
|
||||||
|
// Watch-only addresses
|
||||||
|
export {
|
||||||
|
useWatchOnlyStore,
|
||||||
|
useFilteredWatchOnlyAddresses,
|
||||||
|
formatWatchOnlyBalance,
|
||||||
|
} from './watchOnly';
|
||||||
|
export type { WatchOnlyAddress } from './watchOnly';
|
||||||
|
|
||||||
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
|
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
|
||||||
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';
|
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';
|
||||||
|
|
||||||
|
|
@ -61,3 +73,184 @@ export type {
|
||||||
OwnedNft,
|
OwnedNft,
|
||||||
TrackedCollection,
|
TrackedCollection,
|
||||||
} from './nfts';
|
} from './nfts';
|
||||||
|
|
||||||
|
// Staking
|
||||||
|
export {
|
||||||
|
useStakingStore,
|
||||||
|
formatApy,
|
||||||
|
formatLockPeriod,
|
||||||
|
} from './staking';
|
||||||
|
export type {
|
||||||
|
StakingPoolInfo,
|
||||||
|
UserStakeInfo,
|
||||||
|
} from './staking';
|
||||||
|
|
||||||
|
// DEX/Swap
|
||||||
|
export {
|
||||||
|
useSwapStore,
|
||||||
|
formatPriceImpact,
|
||||||
|
} from './swap';
|
||||||
|
export type {
|
||||||
|
SwapQuote,
|
||||||
|
LiquidityPoolInfo,
|
||||||
|
} from './swap';
|
||||||
|
|
||||||
|
// Address Book
|
||||||
|
export {
|
||||||
|
useAddressBookStore,
|
||||||
|
useAddressBookEntries,
|
||||||
|
} from './addressbook';
|
||||||
|
export type {
|
||||||
|
AddressBookEntry,
|
||||||
|
} from './addressbook';
|
||||||
|
|
||||||
|
// Market/Prices
|
||||||
|
export {
|
||||||
|
useMarketStore,
|
||||||
|
formatPrice,
|
||||||
|
formatChange,
|
||||||
|
formatVolume,
|
||||||
|
} from './market';
|
||||||
|
export type {
|
||||||
|
TokenPriceInfo,
|
||||||
|
PriceHistoryPoint,
|
||||||
|
} from './market';
|
||||||
|
|
||||||
|
// Multi-sig
|
||||||
|
export {
|
||||||
|
useMultisigStore,
|
||||||
|
} from './multisig';
|
||||||
|
export type {
|
||||||
|
MultisigWalletInfo,
|
||||||
|
PendingMultisigTx,
|
||||||
|
} from './multisig';
|
||||||
|
|
||||||
|
// DApps
|
||||||
|
export {
|
||||||
|
useDAppsStore,
|
||||||
|
useConnectedDApps,
|
||||||
|
POPULAR_DAPPS,
|
||||||
|
} from './dapps';
|
||||||
|
export type {
|
||||||
|
ConnectedDApp,
|
||||||
|
} from './dapps';
|
||||||
|
|
||||||
|
// Backup/Export
|
||||||
|
export {
|
||||||
|
useBackupStore,
|
||||||
|
useIsBackupInProgress,
|
||||||
|
} from './backup';
|
||||||
|
export type {
|
||||||
|
ExportedWallet,
|
||||||
|
ExportedHistory,
|
||||||
|
} from './backup';
|
||||||
|
|
||||||
|
// Hardware Wallet
|
||||||
|
export {
|
||||||
|
useHardwareStore,
|
||||||
|
useHardwareDevices,
|
||||||
|
useSelectedDevice,
|
||||||
|
useIsHardwareSigning,
|
||||||
|
} from './hardware';
|
||||||
|
export type {
|
||||||
|
HardwareDevice,
|
||||||
|
HardwareAddress,
|
||||||
|
SignedTransaction,
|
||||||
|
} from './hardware';
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
export {
|
||||||
|
useNotificationsStore,
|
||||||
|
useUnreadCount,
|
||||||
|
useNotifications,
|
||||||
|
useNotificationPreferences,
|
||||||
|
requestNotificationPermission,
|
||||||
|
} from './notifications';
|
||||||
|
export type {
|
||||||
|
Notification,
|
||||||
|
NotificationType,
|
||||||
|
NotificationPreferences,
|
||||||
|
} from './notifications';
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
export {
|
||||||
|
useStorageStore,
|
||||||
|
formatFileSize,
|
||||||
|
} from './storage';
|
||||||
|
export type {
|
||||||
|
StoredFileInfo,
|
||||||
|
StorageUsageStats,
|
||||||
|
} from './storage';
|
||||||
|
|
||||||
|
// Hosting
|
||||||
|
export {
|
||||||
|
useHostingStore,
|
||||||
|
} from './hosting';
|
||||||
|
export type {
|
||||||
|
HostedSiteInfo,
|
||||||
|
DomainVerificationStatus,
|
||||||
|
} from './hosting';
|
||||||
|
|
||||||
|
// Compute
|
||||||
|
export {
|
||||||
|
useComputeStore,
|
||||||
|
formatPrice as formatComputePrice,
|
||||||
|
} from './compute';
|
||||||
|
export type {
|
||||||
|
ComputeProviderInfo,
|
||||||
|
ComputeJobInfo,
|
||||||
|
} from './compute';
|
||||||
|
|
||||||
|
// Database
|
||||||
|
export {
|
||||||
|
useDatabaseStore,
|
||||||
|
DATABASE_TYPES,
|
||||||
|
REGIONS,
|
||||||
|
} from './database';
|
||||||
|
export type {
|
||||||
|
DatabaseInstanceInfo,
|
||||||
|
DatabaseType,
|
||||||
|
} from './database';
|
||||||
|
|
||||||
|
// Privacy
|
||||||
|
export {
|
||||||
|
usePrivacyStore,
|
||||||
|
RING_SIZES,
|
||||||
|
} from './privacy';
|
||||||
|
export type {
|
||||||
|
ConfidentialBalanceInfo,
|
||||||
|
PrivacyTransactionRequest,
|
||||||
|
} from './privacy';
|
||||||
|
|
||||||
|
// Bridge
|
||||||
|
export {
|
||||||
|
useBridgeStore,
|
||||||
|
getChainIcon,
|
||||||
|
getStatusColor as getBridgeStatusColor,
|
||||||
|
} from './bridge';
|
||||||
|
export type {
|
||||||
|
BridgeChainInfo,
|
||||||
|
BridgeTransferInfo,
|
||||||
|
} from './bridge';
|
||||||
|
|
||||||
|
// Governance
|
||||||
|
export {
|
||||||
|
useGovernanceStore,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusColor as getGovernanceStatusColor,
|
||||||
|
calculateVotePercentage,
|
||||||
|
} from './governance';
|
||||||
|
export type {
|
||||||
|
GovernanceProposal,
|
||||||
|
VotingPowerInfo,
|
||||||
|
} from './governance';
|
||||||
|
|
||||||
|
// ZK-Rollup
|
||||||
|
export {
|
||||||
|
useZkStore,
|
||||||
|
formatTps,
|
||||||
|
} from './zk';
|
||||||
|
export type {
|
||||||
|
ZkRollupStats,
|
||||||
|
ZkAccountInfo,
|
||||||
|
} from './zk';
|
||||||
|
|
|
||||||
138
apps/desktop-wallet/src/store/limitOrders.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface LimitOrder {
|
||||||
|
id: string;
|
||||||
|
orderType: 'buy' | 'sell';
|
||||||
|
pair: string;
|
||||||
|
price: number;
|
||||||
|
amount: number;
|
||||||
|
filledAmount: number;
|
||||||
|
status: 'open' | 'partial' | 'filled' | 'cancelled';
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBook {
|
||||||
|
pair: string;
|
||||||
|
bids: { price: number; amount: number }[];
|
||||||
|
asks: { price: number; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitOrdersState {
|
||||||
|
orders: LimitOrder[];
|
||||||
|
orderBook: OrderBook | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitOrdersActions {
|
||||||
|
createOrder: (
|
||||||
|
orderType: 'buy' | 'sell',
|
||||||
|
pair: string,
|
||||||
|
price: number,
|
||||||
|
amount: number,
|
||||||
|
expiresInHours?: number
|
||||||
|
) => Promise<LimitOrder>;
|
||||||
|
getOrder: (orderId: string) => Promise<LimitOrder>;
|
||||||
|
listOrders: (statusFilter?: string) => Promise<void>;
|
||||||
|
cancelOrder: (orderId: string) => Promise<LimitOrder>;
|
||||||
|
getOrderBook: (pair: string) => Promise<OrderBook>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformOrder = (data: Record<string, unknown>): LimitOrder => ({
|
||||||
|
id: data.id as string,
|
||||||
|
orderType: data.order_type as LimitOrder['orderType'],
|
||||||
|
pair: data.pair as string,
|
||||||
|
price: data.price as number,
|
||||||
|
amount: data.amount as number,
|
||||||
|
filledAmount: data.filled_amount as number,
|
||||||
|
status: data.status as LimitOrder['status'],
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
expiresAt: data.expires_at as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLimitOrdersStore = create<LimitOrdersState & LimitOrdersActions>((set) => ({
|
||||||
|
orders: [],
|
||||||
|
orderBook: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createOrder: async (orderType, pair, price, amount, expiresInHours) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_create', {
|
||||||
|
orderType,
|
||||||
|
pair,
|
||||||
|
price,
|
||||||
|
amount,
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
const order = transformOrder(data);
|
||||||
|
set((state) => ({
|
||||||
|
orders: [order, ...state.orders],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return order;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrder: async (orderId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_get', { orderId });
|
||||||
|
return transformOrder(data);
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listOrders: async (statusFilter?: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('limit_order_list', { statusFilter });
|
||||||
|
const orders = data.map(transformOrder);
|
||||||
|
set({ orders, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelOrder: async (orderId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_cancel', { orderId });
|
||||||
|
const order = transformOrder(data);
|
||||||
|
set((state) => ({
|
||||||
|
orders: state.orders.map((o) => (o.id === orderId ? order : o)),
|
||||||
|
}));
|
||||||
|
return order;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrderBook: async (pair: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<OrderBook>('limit_order_get_orderbook', { pair });
|
||||||
|
set({ orderBook: data });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format sompi to SYN
|
||||||
|
export const formatAmount = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn.toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
89
apps/desktop-wallet/src/store/market.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Market] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Market] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPriceInfo {
|
||||||
|
symbol: string;
|
||||||
|
priceUsd: number;
|
||||||
|
change24h: number;
|
||||||
|
volume24h: number;
|
||||||
|
marketCap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceHistoryPoint {
|
||||||
|
timestamp: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketState {
|
||||||
|
prices: TokenPriceInfo[];
|
||||||
|
history: Record<string, PriceHistoryPoint[]>;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchPrices: (symbols: string[]) => Promise<void>;
|
||||||
|
fetchHistory: (symbol: string, interval: string, limit: number) => Promise<PriceHistoryPoint[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMarketStore = create<MarketState>()((set) => ({
|
||||||
|
prices: [],
|
||||||
|
history: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchPrices: async (symbols) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const prices = await invoke<TokenPriceInfo[]>('market_get_prices', { symbols });
|
||||||
|
set({ prices, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchPrices', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchHistory: async (symbol, interval, limit) => {
|
||||||
|
try {
|
||||||
|
const history = await invoke<PriceHistoryPoint[]>('market_get_history', {
|
||||||
|
symbol,
|
||||||
|
interval,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
history: { ...state.history, [symbol]: history },
|
||||||
|
}));
|
||||||
|
return history;
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchHistory', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function formatPrice(price: number): string {
|
||||||
|
if (price >= 1) return `$${price.toFixed(2)}`;
|
||||||
|
if (price >= 0.01) return `$${price.toFixed(4)}`;
|
||||||
|
return `$${price.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChange(change: number): string {
|
||||||
|
const sign = change >= 0 ? '+' : '';
|
||||||
|
return `${sign}${change.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVolume(volume: number): string {
|
||||||
|
if (volume >= 1_000_000_000) return `$${(volume / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (volume >= 1_000_000) return `$${(volume / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (volume >= 1_000) return `$${(volume / 1_000).toFixed(2)}K`;
|
||||||
|
return `$${volume.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke, listen, type UnlistenFn } from '../lib/tauri';
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitized error logging
|
* Sanitized error logging
|
||||||
|
|
|
||||||
163
apps/desktop-wallet/src/store/mixer.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
// Types matching backend
|
||||||
|
export interface MixPoolStatus {
|
||||||
|
poolId: string;
|
||||||
|
denomination: number;
|
||||||
|
participants: number;
|
||||||
|
requiredParticipants: number;
|
||||||
|
status: 'waiting' | 'mixing' | 'completed';
|
||||||
|
estimatedTimeSecs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixRequest {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
denomination: number;
|
||||||
|
status: 'pending' | 'mixing' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
outputAddress: string;
|
||||||
|
createdAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
txId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerState {
|
||||||
|
denominations: number[];
|
||||||
|
poolStatus: Record<number, MixPoolStatus>;
|
||||||
|
requests: MixRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerActions {
|
||||||
|
loadDenominations: () => Promise<void>;
|
||||||
|
getPoolStatus: (denomination: number) => Promise<MixPoolStatus>;
|
||||||
|
createRequest: (amount: number, denomination: number, outputAddress: string) => Promise<MixRequest>;
|
||||||
|
getRequest: (requestId: string) => Promise<MixRequest>;
|
||||||
|
listRequests: () => Promise<void>;
|
||||||
|
cancelRequest: (requestId: string) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformMixRequest = (data: Record<string, unknown>): MixRequest => ({
|
||||||
|
id: data.id as string,
|
||||||
|
amount: data.amount as number,
|
||||||
|
denomination: data.denomination as number,
|
||||||
|
status: data.status as MixRequest['status'],
|
||||||
|
outputAddress: data.output_address as string,
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
completedAt: data.completed_at as number | undefined,
|
||||||
|
txId: data.tx_id as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformPoolStatus = (data: Record<string, unknown>): MixPoolStatus => ({
|
||||||
|
poolId: data.pool_id as string,
|
||||||
|
denomination: data.denomination as number,
|
||||||
|
participants: data.participants as number,
|
||||||
|
requiredParticipants: data.required_participants as number,
|
||||||
|
status: data.status as MixPoolStatus['status'],
|
||||||
|
estimatedTimeSecs: data.estimated_time_secs as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useMixerStore = create<MixerState & MixerActions>((set) => ({
|
||||||
|
denominations: [],
|
||||||
|
poolStatus: {},
|
||||||
|
requests: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadDenominations: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const denominations = await invoke<number[]>('mixer_get_denominations');
|
||||||
|
set({ denominations, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPoolStatus: async (denomination: number) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_get_pool_status', { denomination });
|
||||||
|
const status = transformPoolStatus(data);
|
||||||
|
set((state) => ({
|
||||||
|
poolStatus: { ...state.poolStatus, [denomination]: status },
|
||||||
|
}));
|
||||||
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createRequest: async (amount: number, denomination: number, outputAddress: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_create_request', {
|
||||||
|
amount,
|
||||||
|
denomination,
|
||||||
|
outputAddress,
|
||||||
|
});
|
||||||
|
const request = transformMixRequest(data);
|
||||||
|
set((state) => ({
|
||||||
|
requests: [request, ...state.requests],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getRequest: async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_get_request', { requestId });
|
||||||
|
return transformMixRequest(data);
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listRequests: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('mixer_list_requests');
|
||||||
|
const requests = data.map(transformMixRequest);
|
||||||
|
set({ requests, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRequest: async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('mixer_cancel_request', { requestId });
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? { ...r, status: 'cancelled' as const } : r
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format denomination
|
||||||
|
export const formatDenomination = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn} SYN`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format sompi to SYN
|
||||||
|
export const formatAmount = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn.toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
178
apps/desktop-wallet/src/store/multisig.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Multisig] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Multisig] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultisigWalletInfo {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
threshold: number;
|
||||||
|
owners: string[];
|
||||||
|
pendingTxCount: number;
|
||||||
|
balance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingMultisigTx {
|
||||||
|
txId: string;
|
||||||
|
to: string;
|
||||||
|
value: string;
|
||||||
|
data?: string;
|
||||||
|
signatures: string[];
|
||||||
|
threshold: number;
|
||||||
|
proposer: string;
|
||||||
|
proposedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultisigState {
|
||||||
|
wallets: MultisigWalletInfo[];
|
||||||
|
pendingTxs: Record<string, PendingMultisigTx[]>;
|
||||||
|
isCreating: boolean;
|
||||||
|
isProposing: boolean;
|
||||||
|
isSigning: boolean;
|
||||||
|
isExecuting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
addWallet: (wallet: MultisigWalletInfo) => void;
|
||||||
|
removeWallet: (address: string) => void;
|
||||||
|
createWallet: (name: string, owners: string[], threshold: number) => Promise<MultisigWalletInfo>;
|
||||||
|
getWalletInfo: (address: string) => Promise<MultisigWalletInfo>;
|
||||||
|
proposeTx: (walletAddress: string, to: string, value: string, data?: string) => Promise<string>;
|
||||||
|
signTx: (walletAddress: string, txId: string) => Promise<string>;
|
||||||
|
executeTx: (walletAddress: string, txId: string) => Promise<string>;
|
||||||
|
fetchPendingTxs: (walletAddress: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMultisigStore = create<MultisigState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
wallets: [],
|
||||||
|
pendingTxs: {},
|
||||||
|
isCreating: false,
|
||||||
|
isProposing: false,
|
||||||
|
isSigning: false,
|
||||||
|
isExecuting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
addWallet: (wallet) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.wallets.find((w) => w.address === wallet.address)) return state;
|
||||||
|
return { wallets: [...state.wallets, wallet] };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeWallet: (address) =>
|
||||||
|
set((state) => ({
|
||||||
|
wallets: state.wallets.filter((w) => w.address !== address),
|
||||||
|
})),
|
||||||
|
|
||||||
|
createWallet: async (name, owners, threshold) => {
|
||||||
|
set({ isCreating: true, error: null });
|
||||||
|
try {
|
||||||
|
const wallet = await invoke<MultisigWalletInfo>('multisig_create', {
|
||||||
|
name,
|
||||||
|
owners,
|
||||||
|
threshold,
|
||||||
|
});
|
||||||
|
get().addWallet(wallet);
|
||||||
|
set({ isCreating: false });
|
||||||
|
return wallet;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Create failed';
|
||||||
|
logError('createWallet', error);
|
||||||
|
set({ error: msg, isCreating: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getWalletInfo: async (address) => {
|
||||||
|
try {
|
||||||
|
return await invoke<MultisigWalletInfo>('multisig_get_info', {
|
||||||
|
walletAddress: address,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError('getWalletInfo', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
proposeTx: async (walletAddress, to, value, data) => {
|
||||||
|
set({ isProposing: true, error: null });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('multisig_propose_tx', {
|
||||||
|
walletAddress,
|
||||||
|
to,
|
||||||
|
value,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
set({ isProposing: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Propose failed';
|
||||||
|
logError('proposeTx', error);
|
||||||
|
set({ error: msg, isProposing: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signTx: async (walletAddress, txId) => {
|
||||||
|
set({ isSigning: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('multisig_sign_tx', {
|
||||||
|
walletAddress,
|
||||||
|
txId,
|
||||||
|
});
|
||||||
|
set({ isSigning: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Sign failed';
|
||||||
|
logError('signTx', error);
|
||||||
|
set({ error: msg, isSigning: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
executeTx: async (walletAddress, txId) => {
|
||||||
|
set({ isExecuting: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('multisig_execute_tx', {
|
||||||
|
walletAddress,
|
||||||
|
txId,
|
||||||
|
});
|
||||||
|
set({ isExecuting: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Execute failed';
|
||||||
|
logError('executeTx', error);
|
||||||
|
set({ error: msg, isExecuting: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchPendingTxs: async (walletAddress) => {
|
||||||
|
try {
|
||||||
|
const txs = await invoke<PendingMultisigTx[]>('multisig_get_pending_txs', {
|
||||||
|
walletAddress,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
pendingTxs: { ...state.pendingTxs, [walletAddress]: txs },
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchPendingTxs', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-multisig-storage',
|
||||||
|
partialize: (state) => ({ wallets: state.wallets }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitized error logging
|
* Sanitized error logging
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke, listen, type UnlistenFn } from '../lib/tauri';
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitized error logging
|
* Sanitized error logging
|
||||||
|
|
|
||||||
176
apps/desktop-wallet/src/store/notifications.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export type NotificationType = 'transaction' | 'mining' | 'staking' | 'system' | 'price';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
read: boolean;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
enabled: boolean;
|
||||||
|
transactionAlerts: boolean;
|
||||||
|
miningAlerts: boolean;
|
||||||
|
stakingAlerts: boolean;
|
||||||
|
priceAlerts: boolean;
|
||||||
|
systemAlerts: boolean;
|
||||||
|
soundEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationsState {
|
||||||
|
notifications: Notification[];
|
||||||
|
preferences: NotificationPreferences;
|
||||||
|
unreadCount: number;
|
||||||
|
|
||||||
|
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
||||||
|
markAsRead: (id: string) => void;
|
||||||
|
markAllAsRead: () => void;
|
||||||
|
removeNotification: (id: string) => void;
|
||||||
|
clearAll: () => void;
|
||||||
|
updatePreferences: (prefs: Partial<NotificationPreferences>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: NotificationPreferences = {
|
||||||
|
enabled: true,
|
||||||
|
transactionAlerts: true,
|
||||||
|
miningAlerts: true,
|
||||||
|
stakingAlerts: true,
|
||||||
|
priceAlerts: false,
|
||||||
|
systemAlerts: true,
|
||||||
|
soundEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNotificationsStore = create<NotificationsState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
notifications: [],
|
||||||
|
preferences: DEFAULT_PREFERENCES,
|
||||||
|
unreadCount: 0,
|
||||||
|
|
||||||
|
addNotification: (notification) => {
|
||||||
|
const prefs = get().preferences;
|
||||||
|
|
||||||
|
// Check if this type of notification is enabled
|
||||||
|
if (!prefs.enabled) return;
|
||||||
|
|
||||||
|
const typeEnabled = {
|
||||||
|
transaction: prefs.transactionAlerts,
|
||||||
|
mining: prefs.miningAlerts,
|
||||||
|
staking: prefs.stakingAlerts,
|
||||||
|
price: prefs.priceAlerts,
|
||||||
|
system: prefs.systemAlerts,
|
||||||
|
}[notification.type];
|
||||||
|
|
||||||
|
if (!typeEnabled) return;
|
||||||
|
|
||||||
|
const newNotification: Notification = {
|
||||||
|
...notification,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
notifications: [newNotification, ...state.notifications].slice(0, 100), // Keep last 100
|
||||||
|
unreadCount: state.unreadCount + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show system notification if available
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
|
new Notification(notification.title, {
|
||||||
|
body: notification.message,
|
||||||
|
icon: '/icon.png',
|
||||||
|
silent: !prefs.soundEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsRead: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const notification = state.notifications.find((n) => n.id === id);
|
||||||
|
if (!notification || notification.read) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: state.notifications.map((n) =>
|
||||||
|
n.id === id ? { ...n, read: true } : n
|
||||||
|
),
|
||||||
|
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAsRead: () => {
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||||
|
unreadCount: 0,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNotification: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const notification = state.notifications.find((n) => n.id === id);
|
||||||
|
const wasUnread = notification && !notification.read;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: state.notifications.filter((n) => n.id !== id),
|
||||||
|
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAll: () => {
|
||||||
|
set({ notifications: [], unreadCount: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePreferences: (prefs) => {
|
||||||
|
set((state) => ({
|
||||||
|
preferences: { ...state.preferences, ...prefs },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-notifications',
|
||||||
|
partialize: (state) => ({
|
||||||
|
notifications: state.notifications.slice(0, 50), // Persist last 50
|
||||||
|
preferences: state.preferences,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selector hooks
|
||||||
|
export function useUnreadCount(): number {
|
||||||
|
return useNotificationsStore((state) => state.unreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(): Notification[] {
|
||||||
|
return useNotificationsStore((state) => state.notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationPreferences(): NotificationPreferences {
|
||||||
|
return useNotificationsStore((state) => state.preferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
export async function requestNotificationPermission(): Promise<boolean> {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== 'denied') {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
148
apps/desktop-wallet/src/store/plugins.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
homepage?: string;
|
||||||
|
permissions: string[];
|
||||||
|
isEnabled: boolean;
|
||||||
|
isInstalled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginsState {
|
||||||
|
availablePlugins: PluginInfo[];
|
||||||
|
installedPlugins: PluginInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginsActions {
|
||||||
|
loadAvailable: () => Promise<void>;
|
||||||
|
loadInstalled: () => Promise<void>;
|
||||||
|
install: (pluginId: string) => Promise<PluginInfo>;
|
||||||
|
uninstall: (pluginId: string) => Promise<void>;
|
||||||
|
toggle: (pluginId: string, enabled: boolean) => Promise<PluginInfo>;
|
||||||
|
getSettings: (pluginId: string) => Promise<Record<string, unknown>>;
|
||||||
|
setSettings: (pluginId: string, settings: Record<string, unknown>) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformPlugin = (data: Record<string, unknown>): PluginInfo => ({
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
description: data.description as string,
|
||||||
|
version: data.version as string,
|
||||||
|
author: data.author as string,
|
||||||
|
homepage: data.homepage as string | undefined,
|
||||||
|
permissions: data.permissions as string[],
|
||||||
|
isEnabled: data.is_enabled as boolean,
|
||||||
|
isInstalled: data.is_installed as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePluginsStore = create<PluginsState & PluginsActions>((set) => ({
|
||||||
|
availablePlugins: [],
|
||||||
|
installedPlugins: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadAvailable: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('plugin_list_available');
|
||||||
|
const availablePlugins = data.map(transformPlugin);
|
||||||
|
set({ availablePlugins, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadInstalled: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('plugin_list_installed');
|
||||||
|
const installedPlugins = data.map(transformPlugin);
|
||||||
|
set({ installedPlugins, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
install: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('plugin_install', { pluginId });
|
||||||
|
const plugin = transformPlugin(data);
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: [...state.installedPlugins, plugin],
|
||||||
|
availablePlugins: state.availablePlugins.map((p) =>
|
||||||
|
p.id === pluginId ? { ...p, isInstalled: true } : p
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return plugin;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
uninstall: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('plugin_uninstall', { pluginId });
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: state.installedPlugins.filter((p) => p.id !== pluginId),
|
||||||
|
availablePlugins: state.availablePlugins.map((p) =>
|
||||||
|
p.id === pluginId ? { ...p, isInstalled: false } : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle: async (pluginId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('plugin_toggle', {
|
||||||
|
pluginId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const plugin = transformPlugin(data);
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: state.installedPlugins.map((p) =>
|
||||||
|
p.id === pluginId ? plugin : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return plugin;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSettings: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
const settings = await invoke<Record<string, unknown>>('plugin_get_settings', { pluginId });
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSettings: async (pluginId: string, settings: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await invoke('plugin_set_settings', { pluginId, settings });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
189
apps/desktop-wallet/src/store/portfolio.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface PortfolioSummary {
|
||||||
|
totalValueUsd: number;
|
||||||
|
totalCostBasisUsd: number;
|
||||||
|
totalPnlUsd: number;
|
||||||
|
totalPnlPercent: number;
|
||||||
|
dayChangeUsd: number;
|
||||||
|
dayChangePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetHolding {
|
||||||
|
asset: string;
|
||||||
|
symbol: string;
|
||||||
|
balance: number;
|
||||||
|
balanceFormatted: string;
|
||||||
|
priceUsd: number;
|
||||||
|
valueUsd: number;
|
||||||
|
costBasisUsd: number;
|
||||||
|
pnlUsd: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
allocationPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxableTransaction {
|
||||||
|
id: string;
|
||||||
|
txType: 'buy' | 'sell' | 'swap' | 'transfer';
|
||||||
|
asset: string;
|
||||||
|
amount: number;
|
||||||
|
priceUsd: number;
|
||||||
|
totalUsd: number;
|
||||||
|
costBasisUsd?: number;
|
||||||
|
gainLossUsd?: number;
|
||||||
|
timestamp: number;
|
||||||
|
isLongTerm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryPoint {
|
||||||
|
timestamp: number;
|
||||||
|
valueUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortfolioState {
|
||||||
|
summary: PortfolioSummary | null;
|
||||||
|
holdings: AssetHolding[];
|
||||||
|
taxReport: TaxableTransaction[];
|
||||||
|
history: HistoryPoint[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortfolioActions {
|
||||||
|
loadSummary: () => Promise<void>;
|
||||||
|
loadHoldings: () => Promise<void>;
|
||||||
|
loadTaxReport: (year: number) => Promise<void>;
|
||||||
|
exportTaxReport: (year: number, format: 'csv' | 'txf' | 'json') => Promise<string>;
|
||||||
|
loadHistory: (days: number) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformSummary = (data: Record<string, unknown>): PortfolioSummary => ({
|
||||||
|
totalValueUsd: data.total_value_usd as number,
|
||||||
|
totalCostBasisUsd: data.total_cost_basis_usd as number,
|
||||||
|
totalPnlUsd: data.total_pnl_usd as number,
|
||||||
|
totalPnlPercent: data.total_pnl_percent as number,
|
||||||
|
dayChangeUsd: data.day_change_usd as number,
|
||||||
|
dayChangePercent: data.day_change_percent as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformHolding = (data: Record<string, unknown>): AssetHolding => ({
|
||||||
|
asset: data.asset as string,
|
||||||
|
symbol: data.symbol as string,
|
||||||
|
balance: data.balance as number,
|
||||||
|
balanceFormatted: data.balance_formatted as string,
|
||||||
|
priceUsd: data.price_usd as number,
|
||||||
|
valueUsd: data.value_usd as number,
|
||||||
|
costBasisUsd: data.cost_basis_usd as number,
|
||||||
|
pnlUsd: data.pnl_usd as number,
|
||||||
|
pnlPercent: data.pnl_percent as number,
|
||||||
|
allocationPercent: data.allocation_percent as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformTaxTx = (data: Record<string, unknown>): TaxableTransaction => ({
|
||||||
|
id: data.id as string,
|
||||||
|
txType: data.tx_type as TaxableTransaction['txType'],
|
||||||
|
asset: data.asset as string,
|
||||||
|
amount: data.amount as number,
|
||||||
|
priceUsd: data.price_usd as number,
|
||||||
|
totalUsd: data.total_usd as number,
|
||||||
|
costBasisUsd: data.cost_basis_usd as number | undefined,
|
||||||
|
gainLossUsd: data.gain_loss_usd as number | undefined,
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
isLongTerm: data.is_long_term as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformHistory = (data: Record<string, unknown>): HistoryPoint => ({
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
valueUsd: data.value_usd as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePortfolioStore = create<PortfolioState & PortfolioActions>((set) => ({
|
||||||
|
summary: null,
|
||||||
|
holdings: [],
|
||||||
|
taxReport: [],
|
||||||
|
history: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadSummary: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('portfolio_get_summary');
|
||||||
|
const summary = transformSummary(data);
|
||||||
|
set({ summary, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHoldings: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_holdings');
|
||||||
|
const holdings = data.map(transformHolding);
|
||||||
|
set({ holdings, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTaxReport: async (year: number) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_tax_report', { year });
|
||||||
|
const taxReport = data.map(transformTaxTx);
|
||||||
|
set({ taxReport, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportTaxReport: async (year: number, format: 'csv' | 'txf' | 'json') => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<string>('portfolio_export_tax_report', { year, format });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHistory: async (days: number) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_history', { days });
|
||||||
|
const history = data.map(transformHistory);
|
||||||
|
set({ history, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format currency
|
||||||
|
export const formatUSD = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format percentage
|
||||||
|
export const formatPercent = (value: number): string => {
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alias for formatting USD
|
||||||
|
export const formatCurrency = formatUSD;
|
||||||
|
|
||||||
|
// Helper to format sompi to SYN
|
||||||
|
export const formatAmount = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn.toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
146
apps/desktop-wallet/src/store/privacy.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Privacy] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Privacy] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfidentialBalanceInfo {
|
||||||
|
commitment: string;
|
||||||
|
balance: string;
|
||||||
|
utxoCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivacyTransactionRequest {
|
||||||
|
to: string;
|
||||||
|
amount: string;
|
||||||
|
useStealthAddress: boolean;
|
||||||
|
useRingSignature: boolean;
|
||||||
|
ringSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrivacyState {
|
||||||
|
confidentialBalance: ConfidentialBalanceInfo | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchBalance: () => Promise<void>;
|
||||||
|
sendPrivate: (request: PrivacyTransactionRequest) => Promise<string>;
|
||||||
|
generateStealthAddress: () => Promise<string>;
|
||||||
|
shield: (amount: string) => Promise<string>;
|
||||||
|
unshield: (amount: string) => Promise<string>;
|
||||||
|
createPrivateToken: (name: string, symbol: string, initialSupply: string) => Promise<string>;
|
||||||
|
deployPrivateContract: (bytecode: string, constructorArgs?: string, hideCode?: boolean) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrivacyStore = create<PrivacyState>()((set) => ({
|
||||||
|
confidentialBalance: null,
|
||||||
|
isLoading: false,
|
||||||
|
isSending: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchBalance: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const balance = await invoke<ConfidentialBalanceInfo>('privacy_get_balance');
|
||||||
|
set({ confidentialBalance: balance, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchBalance', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendPrivate: async (request) => {
|
||||||
|
set({ isSending: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('privacy_send', { request });
|
||||||
|
set({ isSending: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('sendPrivate', error);
|
||||||
|
set({ isSending: false, error: 'Failed to send private transaction' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateStealthAddress: async () => {
|
||||||
|
try {
|
||||||
|
const address = await invoke<string>('privacy_generate_stealth_address');
|
||||||
|
return address;
|
||||||
|
} catch (error) {
|
||||||
|
logError('generateStealthAddress', error);
|
||||||
|
set({ error: 'Failed to generate stealth address' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
shield: async (amount) => {
|
||||||
|
set({ isSending: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('privacy_shield', { amount });
|
||||||
|
set({ isSending: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('shield', error);
|
||||||
|
set({ isSending: false, error: 'Failed to shield tokens' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unshield: async (amount) => {
|
||||||
|
set({ isSending: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('privacy_unshield', { amount });
|
||||||
|
set({ isSending: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('unshield', error);
|
||||||
|
set({ isSending: false, error: 'Failed to unshield tokens' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createPrivateToken: async (name, symbol, initialSupply) => {
|
||||||
|
set({ isSending: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('privacy_create_token', {
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
initialSupply,
|
||||||
|
});
|
||||||
|
set({ isSending: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('createPrivateToken', error);
|
||||||
|
set({ isSending: false, error: 'Failed to create private token' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deployPrivateContract: async (bytecode, constructorArgs, hideCode = false) => {
|
||||||
|
set({ isSending: true });
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('privacy_deploy_contract', {
|
||||||
|
bytecode,
|
||||||
|
constructorArgs,
|
||||||
|
hideCode,
|
||||||
|
});
|
||||||
|
set({ isSending: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
logError('deployPrivateContract', error);
|
||||||
|
set({ isSending: false, error: 'Failed to deploy private contract' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const RING_SIZES = [3, 5, 7, 11];
|
||||||
351
apps/desktop-wallet/src/store/recovery.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guardian for social recovery
|
||||||
|
*/
|
||||||
|
export interface Guardian {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string; // Synor address
|
||||||
|
publicKey?: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: 'pending' | 'confirmed' | 'revoked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery configuration
|
||||||
|
*/
|
||||||
|
export interface RecoveryConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
threshold: number; // Number of guardians required
|
||||||
|
totalGuardians: number;
|
||||||
|
guardians: Guardian[];
|
||||||
|
recoveryDelaySecs: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery request
|
||||||
|
*/
|
||||||
|
export interface RecoveryRequest {
|
||||||
|
id: string;
|
||||||
|
walletAddress: string;
|
||||||
|
newOwnerAddress: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
status: 'pending' | 'approved' | 'completed' | 'cancelled' | 'expired';
|
||||||
|
approvals: string[]; // Guardian IDs
|
||||||
|
requiredApprovals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryState {
|
||||||
|
// State
|
||||||
|
config: RecoveryConfig | null;
|
||||||
|
requests: RecoveryRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchConfig: () => Promise<void>;
|
||||||
|
setupRecovery: (threshold: number, delaySecs: number) => Promise<void>;
|
||||||
|
addGuardian: (name: string, email?: string, address?: string) => Promise<Guardian>;
|
||||||
|
removeGuardian: (guardianId: string) => Promise<void>;
|
||||||
|
updateThreshold: (threshold: number) => Promise<void>;
|
||||||
|
disableRecovery: () => Promise<void>;
|
||||||
|
// Recovery process
|
||||||
|
initiateRecovery: (walletAddress: string, newOwnerAddress: string) => Promise<RecoveryRequest>;
|
||||||
|
approveRecovery: (requestId: string, guardianId: string) => Promise<RecoveryRequest>;
|
||||||
|
cancelRecovery: (requestId: string) => Promise<void>;
|
||||||
|
fetchRequests: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend guardian to frontend format
|
||||||
|
function transformGuardian(data: any): Guardian {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
email: data.email || undefined,
|
||||||
|
address: data.address || undefined,
|
||||||
|
publicKey: data.public_key || undefined,
|
||||||
|
addedAt: data.added_at,
|
||||||
|
status: data.status as Guardian['status'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend config
|
||||||
|
function transformConfig(data: any): RecoveryConfig {
|
||||||
|
return {
|
||||||
|
enabled: data.enabled,
|
||||||
|
threshold: data.threshold,
|
||||||
|
totalGuardians: data.total_guardians,
|
||||||
|
guardians: data.guardians.map(transformGuardian),
|
||||||
|
recoveryDelaySecs: data.recovery_delay_secs,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform recovery request
|
||||||
|
function transformRequest(data: any): RecoveryRequest {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
walletAddress: data.wallet_address,
|
||||||
|
newOwnerAddress: data.new_owner_address,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
expiresAt: data.expires_at,
|
||||||
|
status: data.status as RecoveryRequest['status'],
|
||||||
|
approvals: data.approvals,
|
||||||
|
requiredApprovals: data.required_approvals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRecoveryStore = create<RecoveryState>()((set) => ({
|
||||||
|
// Initial state
|
||||||
|
config: null,
|
||||||
|
requests: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch current configuration
|
||||||
|
fetchConfig: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any | null>('recovery_get_config');
|
||||||
|
const config = data ? transformConfig(data) : null;
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch recovery config';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Setup social recovery
|
||||||
|
setupRecovery: async (threshold, delaySecs) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_setup', {
|
||||||
|
threshold,
|
||||||
|
recoveryDelaySecs: delaySecs,
|
||||||
|
});
|
||||||
|
const config = transformConfig(data);
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to setup recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a guardian
|
||||||
|
addGuardian: async (name, email, address) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_add_guardian', {
|
||||||
|
name,
|
||||||
|
email: email || null,
|
||||||
|
address: address || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guardian = transformGuardian(data);
|
||||||
|
|
||||||
|
// Update local config
|
||||||
|
set((state) => {
|
||||||
|
if (!state.config) return state;
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
guardians: [...state.config.guardians, guardian],
|
||||||
|
totalGuardians: state.config.totalGuardians + 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return guardian;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to add guardian';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a guardian
|
||||||
|
removeGuardian: async (guardianId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_remove_guardian', { guardianId });
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
if (!state.config) return state;
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
guardians: state.config.guardians.filter((g) => g.id !== guardianId),
|
||||||
|
totalGuardians: state.config.totalGuardians - 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to remove guardian';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update threshold
|
||||||
|
updateThreshold: async (threshold) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_update_threshold', { threshold });
|
||||||
|
const config = transformConfig(data);
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update threshold';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disable recovery
|
||||||
|
disableRecovery: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_disable');
|
||||||
|
set((state) => ({
|
||||||
|
config: state.config ? { ...state.config, enabled: false } : null,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to disable recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Initiate a recovery request
|
||||||
|
initiateRecovery: async (walletAddress, newOwnerAddress) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_initiate', {
|
||||||
|
walletAddress,
|
||||||
|
newOwnerAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = transformRequest(data);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: [request, ...state.requests],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to initiate recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Guardian approves a recovery
|
||||||
|
approveRecovery: async (requestId, guardianId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_approve', {
|
||||||
|
requestId,
|
||||||
|
guardianId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRequest = transformRequest(data);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? updatedRequest : r
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to approve recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel a recovery request
|
||||||
|
cancelRecovery: async (requestId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_cancel', { requestId });
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? { ...r, status: 'cancelled' as const } : r
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to cancel recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch all recovery requests
|
||||||
|
fetchRequests: async () => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<any[]>('recovery_list_requests');
|
||||||
|
const requests = data.map(transformRequest);
|
||||||
|
set({ requests });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch recovery requests:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for guardian
|
||||||
|
*/
|
||||||
|
export function getGuardianStatusColor(status: Guardian['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'revoked':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for recovery request
|
||||||
|
*/
|
||||||
|
export function getRequestStatusColor(status: RecoveryRequest['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
case 'completed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'cancelled':
|
||||||
|
case 'expired':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
130
apps/desktop-wallet/src/store/rpcProfiles.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
export interface RpcProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
httpUrl: string;
|
||||||
|
wsUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
priority: number;
|
||||||
|
latencyMs?: number;
|
||||||
|
lastChecked?: number;
|
||||||
|
isHealthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcProfilesState {
|
||||||
|
profiles: RpcProfile[];
|
||||||
|
activeProfile: RpcProfile | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcProfilesActions {
|
||||||
|
createProfile: (name: string, httpUrl: string, wsUrl?: string) => Promise<RpcProfile>;
|
||||||
|
listProfiles: () => Promise<void>;
|
||||||
|
setActive: (profileId: string) => Promise<RpcProfile>;
|
||||||
|
deleteProfile: (profileId: string) => Promise<void>;
|
||||||
|
testProfile: (profileId: string) => Promise<RpcProfile>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformProfile = (data: Record<string, unknown>): RpcProfile => ({
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
httpUrl: data.http_url as string,
|
||||||
|
wsUrl: data.ws_url as string | undefined,
|
||||||
|
isActive: data.is_active as boolean,
|
||||||
|
isDefault: data.is_default as boolean,
|
||||||
|
priority: data.priority as number,
|
||||||
|
latencyMs: data.latency_ms as number | undefined,
|
||||||
|
lastChecked: data.last_checked as number | undefined,
|
||||||
|
isHealthy: data.is_healthy as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useRpcProfilesStore = create<RpcProfilesState & RpcProfilesActions>((set) => ({
|
||||||
|
profiles: [],
|
||||||
|
activeProfile: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createProfile: async (name: string, httpUrl: string, wsUrl?: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_create', {
|
||||||
|
name,
|
||||||
|
httpUrl,
|
||||||
|
wsUrl,
|
||||||
|
});
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: [...state.profiles, profile],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listProfiles: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('rpc_profile_list');
|
||||||
|
const profiles = data.map(transformProfile);
|
||||||
|
const activeProfile = profiles.find((p) => p.isActive) || null;
|
||||||
|
set({ profiles, activeProfile, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_set_active', { profileId });
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((p) => ({
|
||||||
|
...p,
|
||||||
|
isActive: p.id === profileId,
|
||||||
|
})),
|
||||||
|
activeProfile: profile,
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProfile: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('rpc_profile_delete', { profileId });
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.filter((p) => p.id !== profileId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
testProfile: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_test', { profileId });
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((p) => (p.id === profileId ? profile : p)),
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
132
apps/desktop-wallet/src/store/staking.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
console.error(`[Staking] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Staking] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StakingPoolInfo {
|
||||||
|
poolAddress: string;
|
||||||
|
name: string;
|
||||||
|
totalStaked: string;
|
||||||
|
apyBps: number;
|
||||||
|
minStake: string;
|
||||||
|
lockPeriod: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStakeInfo {
|
||||||
|
poolAddress: string;
|
||||||
|
stakedAmount: string;
|
||||||
|
pendingRewards: string;
|
||||||
|
stakedAt: number;
|
||||||
|
unlockAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StakingState {
|
||||||
|
pools: StakingPoolInfo[];
|
||||||
|
userStakes: UserStakeInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isStaking: boolean;
|
||||||
|
isUnstaking: boolean;
|
||||||
|
isClaiming: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
clearError: () => void;
|
||||||
|
fetchPools: () => Promise<void>;
|
||||||
|
fetchUserStakes: (address: string) => Promise<void>;
|
||||||
|
stake: (poolAddress: string, amount: string) => Promise<string>;
|
||||||
|
unstake: (poolAddress: string, amount: string) => Promise<string>;
|
||||||
|
claimRewards: (poolAddress: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStakingStore = create<StakingState>()((set) => ({
|
||||||
|
pools: [],
|
||||||
|
userStakes: [],
|
||||||
|
isLoading: false,
|
||||||
|
isStaking: false,
|
||||||
|
isUnstaking: false,
|
||||||
|
isClaiming: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
fetchPools: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const pools = await invoke<StakingPoolInfo[]>('staking_get_pools');
|
||||||
|
set({ pools, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchPools', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUserStakes: async (address: string) => {
|
||||||
|
try {
|
||||||
|
const userStakes = await invoke<UserStakeInfo[]>('staking_get_user_stakes', { address });
|
||||||
|
set({ userStakes });
|
||||||
|
} catch (error) {
|
||||||
|
logError('fetchUserStakes', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stake: async (poolAddress: string, amount: string) => {
|
||||||
|
set({ isStaking: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('staking_stake', { poolAddress, amount });
|
||||||
|
set({ isStaking: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Stake failed';
|
||||||
|
logError('stake', error);
|
||||||
|
set({ error: msg, isStaking: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unstake: async (poolAddress: string, amount: string) => {
|
||||||
|
set({ isUnstaking: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('staking_unstake', { poolAddress, amount });
|
||||||
|
set({ isUnstaking: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unstake failed';
|
||||||
|
logError('unstake', error);
|
||||||
|
set({ error: msg, isUnstaking: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
claimRewards: async (poolAddress: string) => {
|
||||||
|
set({ isClaiming: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('staking_claim_rewards', { poolAddress });
|
||||||
|
set({ isClaiming: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Claim failed';
|
||||||
|
logError('claimRewards', error);
|
||||||
|
set({ error: msg, isClaiming: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function formatApy(bps: number): string {
|
||||||
|
return `${(bps / 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLockPeriod(seconds: number): string {
|
||||||
|
if (seconds === 0) return 'Flexible';
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
if (days === 1) return '1 Day';
|
||||||
|
if (days < 30) return `${days} Days`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return months === 1 ? '1 Month' : `${months} Months`;
|
||||||
|
}
|
||||||