🎤 SolVox: Voice-First Private AI Wallet for Solana — Powered by QVAC SDK
Browse files- .gitignore +11 -0
- LICENSE +21 -0
- README.md +376 -5
- SECURITY.md +141 -0
- electron-builder.yml +38 -0
- models/.gitignore +4 -0
- package.json +56 -0
- postcss.config.js +6 -0
- scripts/download-models.sh +94 -0
- src/main/ai/qvacEngine.ts +579 -0
- src/main/main.ts +420 -0
- src/main/preload.ts +94 -0
- src/main/security/keyVault.ts +209 -0
- src/main/security/securityManager.ts +136 -0
- src/main/security/transactionGuard.ts +401 -0
- src/main/wallet/walletService.ts +336 -0
- src/renderer/App.tsx +165 -0
- src/renderer/components/Sidebar.tsx +58 -0
- src/renderer/components/TopBar.tsx +84 -0
- src/renderer/index.css +92 -0
- src/renderer/index.html +16 -0
- src/renderer/main.tsx +10 -0
- src/renderer/pages/Dashboard.tsx +186 -0
- src/renderer/pages/HistoryPage.tsx +146 -0
- src/renderer/pages/LockScreen.tsx +142 -0
- src/renderer/pages/OnboardingScreen.tsx +258 -0
- src/renderer/pages/SecurityPage.tsx +269 -0
- src/renderer/pages/SendPage.tsx +233 -0
- src/renderer/pages/SettingsPage.tsx +114 -0
- src/renderer/pages/VoicePage.tsx +301 -0
- src/renderer/types.ts +59 -0
- tailwind.config.js +50 -0
- tsconfig.json +22 -0
- tsconfig.main.json +19 -0
- vite.config.ts +21 -0
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
release/
|
| 4 |
+
models/*.gguf
|
| 5 |
+
models/*.bin
|
| 6 |
+
models/*.onnx
|
| 7 |
+
models/*.onnx.json
|
| 8 |
+
.DS_Store
|
| 9 |
+
*.log
|
| 10 |
+
.env
|
| 11 |
+
.env.local
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 SolVox Team
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
|
| 12 |
*No cloud. No API keys. No data leaves your machine. Ever.*
|
| 13 |
|
| 14 |
-
[
|
| 15 |
|
| 16 |
</div>
|
| 17 |
|
|
@@ -19,9 +19,7 @@
|
|
| 19 |
|
| 20 |
## 🏆 Built for Colosseum Frontier Hackathon — Tether QVAC Track
|
| 21 |
|
| 22 |
-
SolVox demonstrates the full power of [Tether's QVAC SDK](https://qvac.tether.io) by integrating **all 6 AI addon packages** into a production-grade Solana wallet with enterprise-level security
|
| 23 |
-
|
| 24 |
-
**Hackathon listing:** [Tether Frontier Track on Superteam](https://superteam.fun/earn/listing/tether-frontier-hackathon-track)
|
| 25 |
|
| 26 |
| QVAC Package | Capability | Use in SolVox |
|
| 27 |
|---|---|---|
|
|
@@ -32,4 +30,377 @@ SolVox demonstrates the full power of [Tether's QVAC SDK](https://qvac.tether.io
|
|
| 32 |
| `@qvac/translation-nmtcpp` | Translation | Multilingual voice wallet (speak any language) |
|
| 33 |
| `@qvac/ocr-onnx` | OCR | Read QR codes, invoices, addresses from images |
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
*No cloud. No API keys. No data leaves your machine. Ever.*
|
| 13 |
|
| 14 |
+
[Documentation](#architecture) · [Quick Start](#quick-start) · [Security](#security-architecture) · [QVAC Integration](#qvac-integration)
|
| 15 |
|
| 16 |
</div>
|
| 17 |
|
|
|
|
| 19 |
|
| 20 |
## 🏆 Built for Colosseum Frontier Hackathon — Tether QVAC Track
|
| 21 |
|
| 22 |
+
SolVox demonstrates the full power of [Tether's QVAC SDK](https://qvac.tether.io) by integrating **all 6 AI addon packages** into a production-grade Solana wallet with enterprise-level security:
|
|
|
|
|
|
|
| 23 |
|
| 24 |
| QVAC Package | Capability | Use in SolVox |
|
| 25 |
|---|---|---|
|
|
|
|
| 30 |
| `@qvac/translation-nmtcpp` | Translation | Multilingual voice wallet (speak any language) |
|
| 31 |
| `@qvac/ocr-onnx` | OCR | Read QR codes, invoices, addresses from images |
|
| 32 |
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## ✨ Key Features
|
| 36 |
+
|
| 37 |
+
### 🎤 Voice-First Interaction
|
| 38 |
+
- **Hold-to-speak** voice commands processed entirely on-device
|
| 39 |
+
- Natural language understanding: *"Send 50 USDT to alice.sol"*
|
| 40 |
+
- AI-generated voice responses via local TTS
|
| 41 |
+
- Real-time waveform visualization during recording
|
| 42 |
+
|
| 43 |
+
### 🧠 6-Package QVAC AI Integration
|
| 44 |
+
- **LLM**: Llama 3.2 3B Instruct — parses intents, answers questions, validates transactions
|
| 45 |
+
- **Embeddings**: Nomic Embed Text — powers semantic search (RAG) over your transaction history
|
| 46 |
+
- **Speech-to-Text**: Whisper — converts voice to text offline
|
| 47 |
+
- **Text-to-Speech**: Piper TTS — speaks confirmations and responses
|
| 48 |
+
- **Translation**: Neural machine translation — use SolVox in any language
|
| 49 |
+
- **OCR**: Extracts Solana addresses and amounts from images/QR codes
|
| 50 |
+
|
| 51 |
+
### 💰 Non-Custodial Solana Wallet
|
| 52 |
+
- BIP39 mnemonic generation (24 words)
|
| 53 |
+
- Standard Solana derivation path (`m/44'/501'/0'/0'`)
|
| 54 |
+
- Send/receive SOL and USDT (SPL tokens)
|
| 55 |
+
- Transaction history with Solscan links
|
| 56 |
+
- Devnet, testnet, and mainnet support
|
| 57 |
+
|
| 58 |
+
### 🔒 Enterprise Security
|
| 59 |
+
- **OS-level key encryption** via Electron `safeStorage` (Keychain/DPAPI/libsecret)
|
| 60 |
+
- **PIN + Biometric** authentication (Touch ID on macOS)
|
| 61 |
+
- **Transaction limits** — per-tx max, daily volume, velocity limiting
|
| 62 |
+
- **Address whitelisting** — restrict sends to approved addresses only
|
| 63 |
+
- **AI anomaly detection** — flags unusual amounts, timing, and velocity
|
| 64 |
+
- **Auto-lock** after 5 minutes of inactivity
|
| 65 |
+
- **Zero key exposure** — private keys never cross the IPC boundary
|
| 66 |
+
- **Content Security Policy** — blocks XSS and script injection
|
| 67 |
+
- **Process isolation** — `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false`
|
| 68 |
+
|
| 69 |
+
### 🔍 RAG-Powered Transaction Intelligence
|
| 70 |
+
- Semantic search over your transaction history: *"When did I last send to Bob?"*
|
| 71 |
+
- Local vector store with cosine similarity search
|
| 72 |
+
- Automatic transaction context enrichment for LLM responses
|
| 73 |
+
- All indexing and search runs offline via `@qvac/embed-llamacpp`
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## 🏗 Architecture
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 81 |
+
│ SolVox App │
|
| 82 |
+
│ Electron + React + TypeScript │
|
| 83 |
+
├──────────────────────────┬──────────────────────────────────────┤
|
| 84 |
+
│ Renderer Process │ Main Process │
|
| 85 |
+
│ (React UI) │ (Security Perimeter) │
|
| 86 |
+
│ │ │
|
| 87 |
+
│ ┌─────────────────┐ │ ┌──────────────────────────────┐ │
|
| 88 |
+
│ │ Dashboard │ │ │ WalletService │ │
|
| 89 |
+
│ │ Voice AI Page │◄═══►│ │ • BIP39/ed25519 derivation │ │
|
| 90 |
+
│ │ Send Page │ IPC │ │ • SOL + USDT transfers │ │
|
| 91 |
+
│ │ History (RAG) │ │ │ • Transaction history │ │
|
| 92 |
+
│ │ Security Center │ │ │ • In-memory keypair (zeroed │ │
|
| 93 |
+
│ │ Settings │ │ │ on lock) │ │
|
| 94 |
+
│ └─────────────────┘ │ └─────────────────────��────────┘ │
|
| 95 |
+
│ │ │
|
| 96 |
+
│ contextBridge only │ ┌──────────────────────────────┐ │
|
| 97 |
+
│ (allowlisted channels) │ │ SecurityManager │ │
|
| 98 |
+
│ │ │ • PIN (PBKDF2 600K iter) │ │
|
| 99 |
+
│ │ │ • Touch ID / biometric │ │
|
| 100 |
+
│ │ │ • Auto-lock timer │ │
|
| 101 |
+
│ │ │ • Rate limiting │ │
|
| 102 |
+
│ │ └──────────────────────────────┘ │
|
| 103 |
+
│ │ │
|
| 104 |
+
│ │ ┌──────────────────────────────┐ │
|
| 105 |
+
│ │ │ TransactionGuard │ │
|
| 106 |
+
│ │ │ • Per-tx limits │ │
|
| 107 |
+
│ │ │ • Daily volume limits │ │
|
| 108 |
+
│ │ │ • Velocity limiting │ │
|
| 109 |
+
│ │ │ • Address whitelisting │ │
|
| 110 |
+
│ │ │ • Anomaly detection │ │
|
| 111 |
+
│ │ │ • Audit trail │ │
|
| 112 |
+
│ │ └──────────────────────────────┘ │
|
| 113 |
+
│ │ │
|
| 114 |
+
│ │ ┌──────────────────────────────┐ │
|
| 115 |
+
│ │ │ KeyVault │ │
|
| 116 |
+
│ │ │ • safeStorage (OS keychain) │ │
|
| 117 |
+
│ │ │ • PIN-based AES-256-GCM │ │
|
| 118 |
+
│ │ │ • PBKDF2 key derivation │ │
|
| 119 |
+
│ │ └──────────────────────────────┘ │
|
| 120 |
+
│ │ │
|
| 121 |
+
│ │ ┌──────────────────────────────┐ │
|
| 122 |
+
│ │ │ QVACEngine │ │
|
| 123 |
+
│ │ │ ┌─────────────────────────┐ │ │
|
| 124 |
+
│ │ │ │ @qvac/sdk │ │ │
|
| 125 |
+
│ │ │ │ .use(LLMLlamacpp) │ │ │
|
| 126 |
+
│ │ │ │ .use(EmbedLlamacpp) │ │ │
|
| 127 |
+
│ │ │ │ .use(TranscriptionW.) │ │ │
|
| 128 |
+
│ │ │ │ .use(TTSOnnx) │ │ │
|
| 129 |
+
│ │ │ │ .use(TranslationNmt) │ │ │
|
| 130 |
+
│ │ │ │ .use(OCROnnx) │ │ │
|
| 131 |
+
│ │ │ └─────────────────────────┘ │ │
|
| 132 |
+
│ │ │ │ │
|
| 133 |
+
│ │ │ LocalVectorStore (RAG) │ │
|
| 134 |
+
│ │ │ Intent Parser (LLM + regex) │ │
|
| 135 |
+
│ │ └──────────────────────────────┘ │
|
| 136 |
+
├──────────────────────────┴──────────────────────────────────────┤
|
| 137 |
+
│ QVAC Fabric LLM (Vulkan GPU / CPU) │
|
| 138 |
+
│ Hardware-agnostic inference on ANY GPU via Vulkan │
|
| 139 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
## 🚀 Quick Start
|
| 145 |
+
|
| 146 |
+
### Prerequisites
|
| 147 |
+
- **Node.js** ≥ 18
|
| 148 |
+
- **npm** ≥ 9
|
| 149 |
+
- ~3 GB disk space for AI models
|
| 150 |
+
- Any GPU with Vulkan support (or CPU fallback)
|
| 151 |
+
|
| 152 |
+
### 1. Clone & Install
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
git clone https://github.com/muthuk1/solvox.git
|
| 156 |
+
cd solvox
|
| 157 |
+
npm install
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
### 2. Download AI Models
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
chmod +x scripts/download-models.sh
|
| 164 |
+
./scripts/download-models.sh
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
This downloads ~2.6 GB of models:
|
| 168 |
+
| Model | Size | Purpose |
|
| 169 |
+
|---|---|---|
|
| 170 |
+
| Llama 3.2 3B Instruct (Q4_K_M GGUF) | ~2.0 GB | Chat, intent parsing |
|
| 171 |
+
| Nomic Embed Text v1.5 (Q4_K_M GGUF) | ~260 MB | Semantic search / RAG |
|
| 172 |
+
| Whisper Base English (GGML) | ~150 MB | Speech recognition |
|
| 173 |
+
| Piper TTS Amy (ONNX) | ~75 MB | Voice synthesis |
|
| 174 |
+
| Translation model (OPUS) | ~50 MB | Multilingual support |
|
| 175 |
+
| PaddleOCR v4 (ONNX) | ~30 MB | Text extraction |
|
| 176 |
+
|
| 177 |
+
### 3. Run in Development
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
npm run dev # Starts Electron + Vite dev server
|
| 181 |
+
npm start # Runs built version
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### 4. Build for Distribution
|
| 185 |
+
|
| 186 |
+
```bash
|
| 187 |
+
npm run build # Build TypeScript + Vite
|
| 188 |
+
npm run package # Create installers (dmg/nsis/AppImage)
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## 🔒 Security Architecture
|
| 194 |
+
|
| 195 |
+
### Key Protection
|
| 196 |
+
|
| 197 |
+
```
|
| 198 |
+
User PIN ──→ PBKDF2 (600K iterations, SHA-512)
|
| 199 |
+
│
|
| 200 |
+
▼
|
| 201 |
+
AES-256-GCM encryption
|
| 202 |
+
│
|
| 203 |
+
▼
|
| 204 |
+
Electron safeStorage (OS keychain)
|
| 205 |
+
│
|
| 206 |
+
▼
|
| 207 |
+
Encrypted vault file (0600 permissions)
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
- **Private keys NEVER leave the main process** — the renderer only receives the public key and signed transaction bytes
|
| 211 |
+
- **In-memory keypair is zeroed on lock** — `secretKey.fill(0)` for best-effort memory clearing
|
| 212 |
+
- **5 failed PIN attempts → 15 minute lockout** — prevents brute force
|
| 213 |
+
- **CSP headers block** `unsafe-eval`, `unsafe-inline` scripts, and external connections
|
| 214 |
+
|
| 215 |
+
### Transaction Security
|
| 216 |
+
|
| 217 |
+
| Feature | Description |
|
| 218 |
+
|---|---|
|
| 219 |
+
| **Amount limits** | Configurable per-transaction and daily volume caps |
|
| 220 |
+
| **Velocity limiting** | Max N transactions per hour |
|
| 221 |
+
| **Cooldown** | Configurable delay between transactions |
|
| 222 |
+
| **Whitelist** | Optional — restrict sends to pre-approved addresses only |
|
| 223 |
+
| **Anomaly detection** | AI flags: unusually large amounts (>5x average), odd-hour transactions, rapid sequences, volume spikes |
|
| 224 |
+
| **Confirmation** | Every transaction requires explicit user confirmation |
|
| 225 |
+
| **Audit trail** | Complete log of all transactions and anomaly events |
|
| 226 |
+
|
| 227 |
+
### IPC Security
|
| 228 |
+
|
| 229 |
+
```
|
| 230 |
+
Renderer (untrusted)
|
| 231 |
+
│
|
| 232 |
+
│ contextBridge — allowlisted channels only
|
| 233 |
+
│ No require(), no Node.js, no filesystem access
|
| 234 |
+
│
|
| 235 |
+
▼
|
| 236 |
+
Main Process (trusted)
|
| 237 |
+
│
|
| 238 |
+
│ Input validation on EVERY IPC handler
|
| 239 |
+
│ Address regex: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
|
| 240 |
+
│ Amount bounds: 0 < amount < 1e12
|
| 241 |
+
│
|
| 242 |
+
▼
|
| 243 |
+
Wallet Service / Transaction Guard
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## 🧠 QVAC Integration
|
| 249 |
+
|
| 250 |
+
### Voice Command Pipeline
|
| 251 |
+
|
| 252 |
+
```
|
| 253 |
+
🎤 Microphone → MediaRecorder API
|
| 254 |
+
│
|
| 255 |
+
▼
|
| 256 |
+
@qvac/transcription-whispercpp
|
| 257 |
+
│ "Send fifty USDT to alice"
|
| 258 |
+
▼
|
| 259 |
+
@qvac/llm-llamacpp (intent parsing)
|
| 260 |
+
│ { action: "send", token: "USDT", amount: 50, to: "alice" }
|
| 261 |
+
▼
|
| 262 |
+
@qvac/embed-llamacpp (RAG context)
|
| 263 |
+
│ "alice.sol = 7xK...abc (used 3 times before)"
|
| 264 |
+
▼
|
| 265 |
+
@qvac/llm-llamacpp (response generation)
|
| 266 |
+
│ "Sending 50 USDT to alice.sol. Please confirm."
|
| 267 |
+
▼
|
| 268 |
+
@qvac/tts-onnx
|
| 269 |
+
│ [audio buffer]
|
| 270 |
+
▼
|
| 271 |
+
🔊 Speaker playback
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### Multilingual Support
|
| 275 |
+
|
| 276 |
+
```
|
| 277 |
+
🎤 User speaks Georgian
|
| 278 |
+
│
|
| 279 |
+
▼
|
| 280 |
+
@qvac/transcription-whispercpp (auto-detect language)
|
| 281 |
+
│ "გამომიგზავნეთ 100 USDT ალისას"
|
| 282 |
+
▼
|
| 283 |
+
@qvac/translation-nmtcpp (→ English)
|
| 284 |
+
│ "Send 100 USDT to Alice"
|
| 285 |
+
▼
|
| 286 |
+
@qvac/llm-llamacpp (process in English)
|
| 287 |
+
│
|
| 288 |
+
▼
|
| 289 |
+
@qvac/translation-nmtcpp (→ Georgian)
|
| 290 |
+
│
|
| 291 |
+
▼
|
| 292 |
+
@qvac/tts-onnx (speak Georgian response)
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
### RAG System
|
| 296 |
+
|
| 297 |
+
The local vector store indexes:
|
| 298 |
+
- Transaction descriptions (amount, recipient, token, timestamp)
|
| 299 |
+
- Contact names and addresses
|
| 300 |
+
- User queries and AI responses
|
| 301 |
+
|
| 302 |
+
Search uses cosine similarity on embeddings generated by `@qvac/embed-llamacpp`. No data ever leaves the device.
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
## 📁 Project Structure
|
| 307 |
+
|
| 308 |
+
```
|
| 309 |
+
solvox/
|
| 310 |
+
├── src/
|
| 311 |
+
│ ├── main/ # Electron main process (SECURITY PERIMETER)
|
| 312 |
+
│ │ ├── main.ts # App entry, window creation, IPC handlers
|
| 313 |
+
│ │ ├── preload.ts # contextBridge — only allowed IPC channels
|
| 314 |
+
│ │ ├── ai/
|
| 315 |
+
│ │ │ └── qvacEngine.ts # QVAC SDK integration (all 6 addons)
|
| 316 |
+
│ │ ├── wallet/
|
| 317 |
+
│ │ │ └── walletService.ts # Solana wallet (BIP39, SOL, USDT)
|
| 318 |
+
│ │ └── security/
|
| 319 |
+
│ │ ├── keyVault.ts # Encrypted key storage (safeStorage + AES-256-GCM)
|
| 320 |
+
│ │ ├── securityManager.ts # Auth (PIN + biometric + auto-lock)
|
| 321 |
+
│ │ └── transactionGuard.ts # Limits, whitelist, anomaly detection
|
| 322 |
+
│ │
|
| 323 |
+
│ └── renderer/ # React UI (NO access to Node.js/keys)
|
| 324 |
+
│ ├── App.tsx # Root component, routing, state
|
| 325 |
+
│ ├── components/
|
| 326 |
+
│ │ ├── Sidebar.tsx # Navigation sidebar
|
| 327 |
+
│ │ └── TopBar.tsx # Balance, AI status, wallet address
|
| 328 |
+
│ └── pages/
|
| 329 |
+
│ ├── Dashboard.tsx # Portfolio overview, AI status
|
| 330 |
+
│ ├── VoicePage.tsx # Voice AI assistant + chat
|
| 331 |
+
│ ├── SendPage.tsx # Send SOL/USDT with confirmation
|
| 332 |
+
│ ├── HistoryPage.tsx # Transaction history + RAG search
|
| 333 |
+
│ ├── SecurityPage.tsx # Security settings, whitelist, anomaly log
|
| 334 |
+
│ ├── SettingsPage.tsx # Network, models, about
|
| 335 |
+
│ ├── LockScreen.tsx # PIN + biometric unlock
|
| 336 |
+
│ └── OnboardingScreen.tsx # Wallet creation/import
|
| 337 |
+
│
|
| 338 |
+
├── models/ # AI models (downloaded locally)
|
| 339 |
+
├── scripts/
|
| 340 |
+
│ └── download-models.sh # Model download script
|
| 341 |
+
├── package.json
|
| 342 |
+
├── tsconfig.main.json # TypeScript config (main process)
|
| 343 |
+
├── tsconfig.json # TypeScript config (renderer)
|
| 344 |
+
├── vite.config.ts # Vite bundler config
|
| 345 |
+
├── tailwind.config.js # Tailwind CSS theme
|
| 346 |
+
├── electron-builder.yml # Build/packaging config
|
| 347 |
+
└── README.md
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## 🔧 Tech Stack
|
| 353 |
+
|
| 354 |
+
| Layer | Technology |
|
| 355 |
+
|---|---|
|
| 356 |
+
| **App Shell** | Electron 30+ |
|
| 357 |
+
| **Frontend** | React 18 + TypeScript + TailwindCSS |
|
| 358 |
+
| **Build** | Vite 5 + electron-builder |
|
| 359 |
+
| **AI Runtime** | QVAC SDK (all 6 addons) |
|
| 360 |
+
| **GPU Compute** | Vulkan API (any GPU — no CUDA required) |
|
| 361 |
+
| **Blockchain** | Solana (@solana/web3.js + @solana/spl-token) |
|
| 362 |
+
| **Key Derivation** | BIP39 + ed25519-hd-key |
|
| 363 |
+
| **Encryption** | Electron safeStorage + AES-256-GCM + PBKDF2 |
|
| 364 |
+
|
| 365 |
+
---
|
| 366 |
+
|
| 367 |
+
## 🌍 Why Local AI Matters for Wallets
|
| 368 |
+
|
| 369 |
+
Cloud-based AI wallets have a fundamental problem: **your financial data passes through someone else's servers.** Every transaction, every balance check, every voice command — all routed through centralized infrastructure that can:
|
| 370 |
+
|
| 371 |
+
- **Log your data** — your financial history becomes training data
|
| 372 |
+
- **Censor transactions** — refuse to process based on arbitrary rules
|
| 373 |
+
- **Go offline** — if the server goes down, your wallet is useless
|
| 374 |
+
- **Be compromised** — centralized servers are honeypots for attackers
|
| 375 |
+
|
| 376 |
+
SolVox eliminates all of these risks. QVAC's Vulkan-based engine runs on **any GPU** — NVIDIA, AMD, Intel, even mobile GPUs — without requiring CUDA or cloud credentials. Your voice commands are transcribed locally, intents are parsed locally, and transaction confirmations are generated locally.
|
| 377 |
+
|
| 378 |
+
**Your AI. Your wallet. Your device. Your rules.**
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## 📜 License
|
| 383 |
+
|
| 384 |
+
MIT License — see [LICENSE](LICENSE).
|
| 385 |
+
|
| 386 |
+
---
|
| 387 |
+
|
| 388 |
+
## 🙏 Acknowledgments
|
| 389 |
+
|
| 390 |
+
- [Tether](https://tether.io) — QVAC SDK, the foundation for local AI
|
| 391 |
+
- [QVAC](https://qvac.tether.io) — Universal on-device AI platform
|
| 392 |
+
- [Solana](https://solana.com) — High-performance blockchain
|
| 393 |
+
- [Colosseum](https://colosseum.org) — Frontier Hackathon
|
| 394 |
+
- [llama.cpp](https://github.com/ggerganov/llama.cpp) — The inference engine behind QVAC Fabric
|
| 395 |
+
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — Offline speech recognition
|
| 396 |
+
- [Piper TTS](https://github.com/rhasspy/piper) — Local text-to-speech
|
| 397 |
+
|
| 398 |
+
---
|
| 399 |
+
|
| 400 |
+
<div align="center">
|
| 401 |
+
|
| 402 |
+
**Built with 💜 for the Colosseum Frontier Hackathon — Tether QVAC Track**
|
| 403 |
+
|
| 404 |
+
*All AI runs 100% locally. No cloud. No compromises.*
|
| 405 |
+
|
| 406 |
+
</div>
|
SECURITY.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🛡️ SolVox Security Model
|
| 2 |
+
|
| 3 |
+
## Threat Model
|
| 4 |
+
|
| 5 |
+
SolVox operates under the assumption that:
|
| 6 |
+
1. The **renderer process is untrusted** — it could be compromised via XSS
|
| 7 |
+
2. The **main process is the trust boundary** — all key operations happen here
|
| 8 |
+
3. The **local filesystem is semi-trusted** — OS-level encryption protects at-rest data
|
| 9 |
+
4. The **network is adversarial** — only Solana RPC calls leave the device
|
| 10 |
+
|
| 11 |
+
## Security Controls
|
| 12 |
+
|
| 13 |
+
### 1. Process Isolation
|
| 14 |
+
|
| 15 |
+
| Control | Setting | Purpose |
|
| 16 |
+
|---|---|---|
|
| 17 |
+
| `contextIsolation` | `true` | Renderer cannot access Node.js globals |
|
| 18 |
+
| `nodeIntegration` | `false` | No `require()` in renderer |
|
| 19 |
+
| `sandbox` | `true` | OS-level process sandbox (Chromium) |
|
| 20 |
+
| `webSecurity` | `true` | Same-origin policy enforced |
|
| 21 |
+
| `allowRunningInsecureContent` | `false` | No HTTP content in HTTPS context |
|
| 22 |
+
| `navigateOnDragDrop` | `false` | Prevents file drag-and-drop navigation |
|
| 23 |
+
|
| 24 |
+
### 2. IPC Channel Allowlist
|
| 25 |
+
|
| 26 |
+
The preload script exposes ONLY these namespaced channels via `contextBridge`:
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
wallet:create, wallet:import, wallet:getPublicKey, wallet:getBalance,
|
| 30 |
+
wallet:sendSOL, wallet:sendUSDT, wallet:getHistory, wallet:isUnlocked,
|
| 31 |
+
wallet:lock, wallet:exists
|
| 32 |
+
|
| 33 |
+
auth:biometric, auth:unlock, auth:setPin, auth:biometricAvailable
|
| 34 |
+
|
| 35 |
+
security:getSettings, security:updateSettings, security:addWhitelist,
|
| 36 |
+
security:removeWhitelist, security:getWhitelist, security:getAnomalies
|
| 37 |
+
|
| 38 |
+
ai:initialize, ai:processVoice, ai:chat, ai:parseIntent, ai:speak,
|
| 39 |
+
ai:translate, ai:embed, ai:ocr, ai:getStatus
|
| 40 |
+
|
| 41 |
+
rag:search, rag:addDocument
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
**No raw `ipcRenderer` is ever exposed.** The renderer cannot invoke arbitrary IPC channels.
|
| 45 |
+
|
| 46 |
+
### 3. Key Storage
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
Mnemonic/Private Key
|
| 50 |
+
│
|
| 51 |
+
▼
|
| 52 |
+
PIN-based AES-256-GCM (PBKDF2, 600K iterations, SHA-512)
|
| 53 |
+
│
|
| 54 |
+
▼
|
| 55 |
+
Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux libsecret)
|
| 56 |
+
│
|
| 57 |
+
▼
|
| 58 |
+
File on disk (0600 permissions, owner-only)
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
The mnemonic is **double-encrypted**: first with the user's PIN (AES-256-GCM with PBKDF2-derived key), then with OS-level `safeStorage`. An attacker who obtains the vault file still needs:
|
| 62 |
+
1. The user's OS login credentials (to decrypt safeStorage)
|
| 63 |
+
2. The user's PIN (to decrypt the inner AES layer)
|
| 64 |
+
|
| 65 |
+
### 4. In-Memory Key Handling
|
| 66 |
+
|
| 67 |
+
- The `Keypair` object lives exclusively in the main process's `_sessionKeypair` variable
|
| 68 |
+
- On lock: `secretKey.fill(0)` zeroes the key buffer (best-effort; V8 GC is non-deterministic)
|
| 69 |
+
- Auto-lock triggers after 5 minutes of inactivity
|
| 70 |
+
- Key material is NEVER sent over IPC — only the public key (base58 string) and signed transaction bytes
|
| 71 |
+
|
| 72 |
+
### 5. Transaction Guards
|
| 73 |
+
|
| 74 |
+
| Guard | Protection |
|
| 75 |
+
|---|---|
|
| 76 |
+
| **Input validation** | Regex-validated addresses, bounds-checked amounts |
|
| 77 |
+
| **Per-tx limits** | Configurable maximum per transaction |
|
| 78 |
+
| **Daily volume** | Cumulative daily spend limit |
|
| 79 |
+
| **Velocity** | Max transactions per hour |
|
| 80 |
+
| **Cooldown** | Minimum time between transactions |
|
| 81 |
+
| **Whitelist** | Optional — only send to pre-approved addresses |
|
| 82 |
+
| **Anomaly detection** | AI-powered pattern analysis (high amounts, odd hours, rapid sequences) |
|
| 83 |
+
| **Confirmation** | Every transaction requires explicit user confirmation |
|
| 84 |
+
|
| 85 |
+
### 6. Content Security Policy
|
| 86 |
+
|
| 87 |
+
```
|
| 88 |
+
default-src 'self';
|
| 89 |
+
script-src 'self';
|
| 90 |
+
style-src 'self' 'unsafe-inline';
|
| 91 |
+
img-src 'self' data: https:;
|
| 92 |
+
connect-src 'self' https://api.mainnet-beta.solana.com https://api.devnet.solana.com;
|
| 93 |
+
object-src 'none';
|
| 94 |
+
base-uri 'self';
|
| 95 |
+
form-action 'self';
|
| 96 |
+
frame-ancestors 'none';
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
- No `unsafe-eval` — prevents `eval()` and `new Function()` attacks
|
| 100 |
+
- No external scripts — blocks CDN-based script injection
|
| 101 |
+
- Limited connect-src — only Solana RPC endpoints allowed
|
| 102 |
+
- No frames — prevents clickjacking
|
| 103 |
+
|
| 104 |
+
### 7. Navigation Protection
|
| 105 |
+
|
| 106 |
+
- All navigation to non-local URLs is blocked
|
| 107 |
+
- New window creation is denied (`setWindowOpenHandler(() => ({ action: 'deny' }))`)
|
| 108 |
+
- DevTools are disabled in production builds
|
| 109 |
+
|
| 110 |
+
## Data Flow
|
| 111 |
+
|
| 112 |
+
```
|
| 113 |
+
Voice Input ──→ QVAC (local) ──→ Intent ──→ Main Process validates ──→ Wallet signs ──→ Solana RPC
|
| 114 |
+
│
|
| 115 |
+
▼
|
| 116 |
+
TransactionGuard checks:
|
| 117 |
+
├── Address format valid?
|
| 118 |
+
├── Amount within limits?
|
| 119 |
+
├── Daily volume OK?
|
| 120 |
+
├── Velocity limit OK?
|
| 121 |
+
├── Address whitelisted?
|
| 122 |
+
├── Anomaly detected?
|
| 123 |
+
└── User confirmed?
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
**Only the signed transaction bytes leave the device** — via Solana RPC. All AI processing, intent parsing, and validation happen locally.
|
| 127 |
+
|
| 128 |
+
## Known Limitations
|
| 129 |
+
|
| 130 |
+
1. **V8 garbage collection** means `secretKey.fill(0)` doesn't guarantee the key is erased from all memory pages
|
| 131 |
+
2. **Electron renderer** could theoretically be compromised via a 0-day in Chromium — contextIsolation + sandbox mitigate this
|
| 132 |
+
3. **Transaction privacy** is limited by Solana's public blockchain — recipients and amounts are on-chain
|
| 133 |
+
4. **PIN brute-force** is mitigated by PBKDF2 (600K iterations) + lockout, but a sufficiently weak PIN (e.g., `123456`) can still be guessed
|
| 134 |
+
|
| 135 |
+
## Recommendations for Production
|
| 136 |
+
|
| 137 |
+
- Use a hardware security module (HSM) or secure enclave for key storage
|
| 138 |
+
- Implement Shamir's Secret Sharing for mnemonic backup
|
| 139 |
+
- Add a dead man's switch (auto-transfer if inactive for N days)
|
| 140 |
+
- Integrate with hardware wallets (Ledger/Trezor) for signing
|
| 141 |
+
- Add transaction simulation (Solana `simulateTransaction`) before signing
|
electron-builder.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
appId: com.solvox.wallet
|
| 2 |
+
productName: SolVox
|
| 3 |
+
copyright: Copyright © 2026 SolVox Team
|
| 4 |
+
|
| 5 |
+
directories:
|
| 6 |
+
output: release
|
| 7 |
+
buildResources: build
|
| 8 |
+
|
| 9 |
+
files:
|
| 10 |
+
- dist/**/*
|
| 11 |
+
- package.json
|
| 12 |
+
|
| 13 |
+
mac:
|
| 14 |
+
category: public.app-category.finance
|
| 15 |
+
target:
|
| 16 |
+
- dmg
|
| 17 |
+
- zip
|
| 18 |
+
|
| 19 |
+
win:
|
| 20 |
+
target:
|
| 21 |
+
- nsis
|
| 22 |
+
- portable
|
| 23 |
+
|
| 24 |
+
linux:
|
| 25 |
+
category: Finance
|
| 26 |
+
target:
|
| 27 |
+
- AppImage
|
| 28 |
+
- deb
|
| 29 |
+
|
| 30 |
+
nsis:
|
| 31 |
+
oneClick: false
|
| 32 |
+
allowToChangeInstallationDirectory: true
|
| 33 |
+
|
| 34 |
+
extraResources:
|
| 35 |
+
- from: models
|
| 36 |
+
to: models
|
| 37 |
+
filter:
|
| 38 |
+
- "**/*"
|
models/.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep models directory but not the actual model files
|
| 2 |
+
*
|
| 3 |
+
!.gitkeep
|
| 4 |
+
!*.placeholder
|
package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "solvox",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Voice-First Private AI Wallet for Solana — Powered by QVAC",
|
| 5 |
+
"main": "dist/main/main.js",
|
| 6 |
+
"author": "SolVox Team",
|
| 7 |
+
"license": "MIT",
|
| 8 |
+
"private": true,
|
| 9 |
+
"scripts": {
|
| 10 |
+
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
|
| 11 |
+
"dev:main": "tsc -p tsconfig.main.json --watch",
|
| 12 |
+
"dev:renderer": "vite",
|
| 13 |
+
"build": "npm run build:main && npm run build:renderer",
|
| 14 |
+
"build:main": "tsc -p tsconfig.main.json",
|
| 15 |
+
"build:renderer": "vite build",
|
| 16 |
+
"start": "electron dist/main/main.js",
|
| 17 |
+
"package": "electron-builder --config electron-builder.yml",
|
| 18 |
+
"postinstall": "electron-builder install-app-deps",
|
| 19 |
+
"lint": "eslint src --ext .ts,.tsx",
|
| 20 |
+
"typecheck": "tsc --noEmit"
|
| 21 |
+
},
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"@qvac/sdk": "latest",
|
| 24 |
+
"@qvac/llm-llamacpp": "latest",
|
| 25 |
+
"@qvac/embed-llamacpp": "latest",
|
| 26 |
+
"@qvac/tts-onnx": "latest",
|
| 27 |
+
"@qvac/transcription-whispercpp": "latest",
|
| 28 |
+
"@qvac/translation-nmtcpp": "latest",
|
| 29 |
+
"@qvac/ocr-onnx": "latest",
|
| 30 |
+
"@solana/web3.js": "^1.98.0",
|
| 31 |
+
"@solana/spl-token": "^0.4.6",
|
| 32 |
+
"bip39": "^3.1.0",
|
| 33 |
+
"ed25519-hd-key": "^1.3.0",
|
| 34 |
+
"bs58": "^5.0.0",
|
| 35 |
+
"electron-store": "^8.2.0",
|
| 36 |
+
"uuid": "^9.0.0"
|
| 37 |
+
},
|
| 38 |
+
"devDependencies": {
|
| 39 |
+
"@types/node": "^20.11.0",
|
| 40 |
+
"@types/react": "^18.2.0",
|
| 41 |
+
"@types/react-dom": "^18.2.0",
|
| 42 |
+
"@types/uuid": "^9.0.0",
|
| 43 |
+
"@vitejs/plugin-react": "^4.2.0",
|
| 44 |
+
"autoprefixer": "^10.4.0",
|
| 45 |
+
"concurrently": "^8.2.0",
|
| 46 |
+
"electron": "^30.0.0",
|
| 47 |
+
"electron-builder": "^24.13.0",
|
| 48 |
+
"eslint": "^8.56.0",
|
| 49 |
+
"postcss": "^8.4.0",
|
| 50 |
+
"react": "^18.2.0",
|
| 51 |
+
"react-dom": "^18.2.0",
|
| 52 |
+
"tailwindcss": "^3.4.0",
|
| 53 |
+
"typescript": "^5.3.0",
|
| 54 |
+
"vite": "^5.1.0"
|
| 55 |
+
}
|
| 56 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
scripts/download-models.sh
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# SolVox Model Downloader
|
| 3 |
+
# Downloads all required GGUF/ONNX models for QVAC SDK
|
| 4 |
+
# All models are stored locally — no cloud required after download.
|
| 5 |
+
|
| 6 |
+
set -e
|
| 7 |
+
|
| 8 |
+
MODELS_DIR="./models"
|
| 9 |
+
mkdir -p "$MODELS_DIR"
|
| 10 |
+
|
| 11 |
+
echo "╔══════════════════════════════════════════════════════════╗"
|
| 12 |
+
echo "║ SolVox — QVAC Model Downloader ║"
|
| 13 |
+
echo "║ Downloading AI models for 100% local inference ║"
|
| 14 |
+
echo "╚══════════════════════════════════════════════════════════╝"
|
| 15 |
+
echo ""
|
| 16 |
+
|
| 17 |
+
# 1. LLM — Llama 3.2 3B Instruct (Q4_K_M quantization, ~2GB)
|
| 18 |
+
echo "📦 [1/6] Downloading LLM: Llama 3.2 3B Instruct (Q4_K_M)..."
|
| 19 |
+
if [ ! -f "$MODELS_DIR/llama-3.2-3b-instruct-q4_k_m.gguf" ]; then
|
| 20 |
+
curl -L -o "$MODELS_DIR/llama-3.2-3b-instruct-q4_k_m.gguf" \
|
| 21 |
+
"https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf"
|
| 22 |
+
echo " ✓ LLM model downloaded (~2.0 GB)"
|
| 23 |
+
else
|
| 24 |
+
echo " ✓ LLM model already exists"
|
| 25 |
+
fi
|
| 26 |
+
|
| 27 |
+
# 2. Embeddings — Nomic Embed Text v1.5 (Q4_K_M, ~260MB)
|
| 28 |
+
echo "📦 [2/6] Downloading Embeddings: Nomic Embed Text v1.5..."
|
| 29 |
+
if [ ! -f "$MODELS_DIR/nomic-embed-text-v1.5.Q4_K_M.gguf" ]; then
|
| 30 |
+
curl -L -o "$MODELS_DIR/nomic-embed-text-v1.5.Q4_K_M.gguf" \
|
| 31 |
+
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf"
|
| 32 |
+
echo " ✓ Embedding model downloaded (~260 MB)"
|
| 33 |
+
else
|
| 34 |
+
echo " ✓ Embedding model already exists"
|
| 35 |
+
fi
|
| 36 |
+
|
| 37 |
+
# 3. Speech-to-Text — Whisper Base English (GGML, ~150MB)
|
| 38 |
+
echo "📦 [3/6] Downloading STT: Whisper Base (English)..."
|
| 39 |
+
if [ ! -f "$MODELS_DIR/ggml-base.en.bin" ]; then
|
| 40 |
+
curl -L -o "$MODELS_DIR/ggml-base.en.bin" \
|
| 41 |
+
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"
|
| 42 |
+
echo " ✓ Whisper model downloaded (~150 MB)"
|
| 43 |
+
else
|
| 44 |
+
echo " ✓ Whisper model already exists"
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
# 4. Text-to-Speech — Piper TTS Amy (en_US, medium quality, ONNX, ~75MB)
|
| 48 |
+
echo "📦 [4/6] Downloading TTS: Piper Amy (en_US, medium)..."
|
| 49 |
+
if [ ! -f "$MODELS_DIR/en_US-amy-medium.onnx" ]; then
|
| 50 |
+
curl -L -o "$MODELS_DIR/en_US-amy-medium.onnx" \
|
| 51 |
+
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx"
|
| 52 |
+
curl -L -o "$MODELS_DIR/en_US-amy-medium.onnx.json" \
|
| 53 |
+
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json"
|
| 54 |
+
echo " ✓ TTS model downloaded (~75 MB)"
|
| 55 |
+
else
|
| 56 |
+
echo " ✓ TTS model already exists"
|
| 57 |
+
fi
|
| 58 |
+
|
| 59 |
+
# 5. Translation — MarianMT English↔Spanish (OPUS, ~50MB)
|
| 60 |
+
echo "📦 [5/6] Downloading Translation: EN↔ES..."
|
| 61 |
+
if [ ! -f "$MODELS_DIR/translate-en-es.bin" ]; then
|
| 62 |
+
echo " ℹ Translation model requires manual download from LibreTranslate"
|
| 63 |
+
echo " Visit: https://github.com/LibreTranslate/LibreTranslate"
|
| 64 |
+
echo " Or use QVAC's bundled translation models (see docs.qvac.tether.io)"
|
| 65 |
+
touch "$MODELS_DIR/translate-en-es.bin.placeholder"
|
| 66 |
+
echo " ⚠ Placeholder created — install actual model for translation"
|
| 67 |
+
else
|
| 68 |
+
echo " ✓ Translation model already exists"
|
| 69 |
+
fi
|
| 70 |
+
|
| 71 |
+
# 6. OCR — PaddleOCR v4 (ONNX, ~30MB)
|
| 72 |
+
echo "📦 [6/6] Downloading OCR: PaddleOCR v4..."
|
| 73 |
+
if [ ! -f "$MODELS_DIR/ppocr-v4.onnx" ]; then
|
| 74 |
+
echo " ℹ OCR model requires QVAC's bundled OCR models"
|
| 75 |
+
echo " See: https://docs.qvac.tether.io/addons/ocr-onnx/"
|
| 76 |
+
touch "$MODELS_DIR/ppocr-v4.onnx.placeholder"
|
| 77 |
+
echo " ⚠ Placeholder created — install actual model for OCR"
|
| 78 |
+
else
|
| 79 |
+
echo " ✓ OCR model already exists"
|
| 80 |
+
fi
|
| 81 |
+
|
| 82 |
+
echo ""
|
| 83 |
+
echo "╔══════════════════════════════════════════════════════════╗"
|
| 84 |
+
echo "║ Download Complete! ║"
|
| 85 |
+
echo "╚══════════════════════════════════════════════════════════╝"
|
| 86 |
+
echo ""
|
| 87 |
+
echo "Model directory: $MODELS_DIR"
|
| 88 |
+
echo ""
|
| 89 |
+
ls -lh "$MODELS_DIR"
|
| 90 |
+
echo ""
|
| 91 |
+
echo "Next steps:"
|
| 92 |
+
echo " 1. npm install"
|
| 93 |
+
echo " 2. npm run dev"
|
| 94 |
+
echo " 3. npm start"
|
src/main/ai/qvacEngine.ts
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — QVAC AI Engine
|
| 3 |
+
*
|
| 4 |
+
* Integrates ALL 6 QVAC addon packages for a complete
|
| 5 |
+
* local-first AI pipeline:
|
| 6 |
+
*
|
| 7 |
+
* 1. @qvac/llm-llamacpp — Intent parsing, chat, financial reasoning
|
| 8 |
+
* 2. @qvac/embed-llamacpp — Semantic search over transactions & contacts
|
| 9 |
+
* 3. @qvac/transcription-whispercpp — Voice → text (speech recognition)
|
| 10 |
+
* 4. @qvac/tts-onnx — Text → voice (speech synthesis)
|
| 11 |
+
* 5. @qvac/translation-nmtcpp — Multilingual support
|
| 12 |
+
* 6. @qvac/ocr-onnx — Read QR codes, invoices, addresses from images
|
| 13 |
+
*
|
| 14 |
+
* ALL AI runs 100% locally via QVAC's Vulkan-accelerated engine.
|
| 15 |
+
* No data ever leaves the device. No API keys. No cloud.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
import { QVAC } from '@qvac/sdk';
|
| 19 |
+
import { LLMLlamacpp } from '@qvac/llm-llamacpp';
|
| 20 |
+
import { EmbedLlamacpp } from '@qvac/embed-llamacpp';
|
| 21 |
+
import { TranscriptionWhispercpp } from '@qvac/transcription-whispercpp';
|
| 22 |
+
import { TTSOnnx } from '@qvac/tts-onnx';
|
| 23 |
+
import { TranslationNmtcpp } from '@qvac/translation-nmtcpp';
|
| 24 |
+
import { OCROnnx } from '@qvac/ocr-onnx';
|
| 25 |
+
import * as path from 'path';
|
| 26 |
+
import * as fs from 'fs';
|
| 27 |
+
import { app } from 'electron';
|
| 28 |
+
|
| 29 |
+
// ─── Intent Types ────────────────────────────────────────────────────────
|
| 30 |
+
export interface WalletIntent {
|
| 31 |
+
action: 'send' | 'balance' | 'history' | 'receive' | 'swap' | 'help' | 'unknown';
|
| 32 |
+
token?: string; // SOL, USDT
|
| 33 |
+
amount?: number;
|
| 34 |
+
to?: string; // Recipient address or contact name
|
| 35 |
+
query?: string; // For search/help queries
|
| 36 |
+
confidence: number; // 0-1
|
| 37 |
+
rawText: string;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface RAGResult {
|
| 41 |
+
text: string;
|
| 42 |
+
score: number;
|
| 43 |
+
metadata: Record<string, any>;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface AIStatus {
|
| 47 |
+
llm: boolean;
|
| 48 |
+
embed: boolean;
|
| 49 |
+
transcription: boolean;
|
| 50 |
+
tts: boolean;
|
| 51 |
+
translation: boolean;
|
| 52 |
+
ocr: boolean;
|
| 53 |
+
initialized: boolean;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// ─── Local Vector Store ──────────────────────────────────────────────────
|
| 57 |
+
interface VectorEntry {
|
| 58 |
+
id: string;
|
| 59 |
+
text: string;
|
| 60 |
+
vector: number[];
|
| 61 |
+
metadata: Record<string, any>;
|
| 62 |
+
timestamp: number;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
class LocalVectorStore {
|
| 66 |
+
private entries: VectorEntry[] = [];
|
| 67 |
+
private storePath: string;
|
| 68 |
+
|
| 69 |
+
constructor(storePath: string) {
|
| 70 |
+
this.storePath = storePath;
|
| 71 |
+
this.load();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
add(id: string, text: string, vector: number[], metadata: Record<string, any>): void {
|
| 75 |
+
// Remove existing entry with same id
|
| 76 |
+
this.entries = this.entries.filter(e => e.id !== id);
|
| 77 |
+
this.entries.push({ id, text, vector, metadata, timestamp: Date.now() });
|
| 78 |
+
this.save();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
search(queryVector: number[], topK: number = 5): RAGResult[] {
|
| 82 |
+
if (this.entries.length === 0) return [];
|
| 83 |
+
|
| 84 |
+
const scored = this.entries.map(entry => ({
|
| 85 |
+
text: entry.text,
|
| 86 |
+
score: this.cosineSimilarity(queryVector, entry.vector),
|
| 87 |
+
metadata: entry.metadata,
|
| 88 |
+
}));
|
| 89 |
+
|
| 90 |
+
return scored
|
| 91 |
+
.sort((a, b) => b.score - a.score)
|
| 92 |
+
.slice(0, topK);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
private cosineSimilarity(a: number[], b: number[]): number {
|
| 96 |
+
if (a.length !== b.length) return 0;
|
| 97 |
+
let dotProduct = 0;
|
| 98 |
+
let normA = 0;
|
| 99 |
+
let normB = 0;
|
| 100 |
+
for (let i = 0; i < a.length; i++) {
|
| 101 |
+
dotProduct += a[i] * b[i];
|
| 102 |
+
normA += a[i] * a[i];
|
| 103 |
+
normB += b[i] * b[i];
|
| 104 |
+
}
|
| 105 |
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
| 106 |
+
return denominator === 0 ? 0 : dotProduct / denominator;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
private load(): void {
|
| 110 |
+
try {
|
| 111 |
+
if (fs.existsSync(this.storePath)) {
|
| 112 |
+
this.entries = JSON.parse(fs.readFileSync(this.storePath, 'utf8'));
|
| 113 |
+
}
|
| 114 |
+
} catch {
|
| 115 |
+
this.entries = [];
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
private save(): void {
|
| 120 |
+
fs.writeFileSync(this.storePath, JSON.stringify(this.entries));
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// ─── System Prompt ───────────────────────────────────────────────────────
|
| 125 |
+
const WALLET_SYSTEM_PROMPT = `You are SolVox, a private AI wallet assistant running 100% locally on the user's device. You help manage their Solana wallet through voice and text commands.
|
| 126 |
+
|
| 127 |
+
Your capabilities:
|
| 128 |
+
- Send SOL and USDT tokens to addresses or contacts
|
| 129 |
+
- Check wallet balance (SOL and USDT)
|
| 130 |
+
- View transaction history
|
| 131 |
+
- Help with Solana ecosystem questions
|
| 132 |
+
- Receive payment requests
|
| 133 |
+
|
| 134 |
+
When parsing commands, extract structured intents. Always respond concisely and clearly.
|
| 135 |
+
|
| 136 |
+
IMPORTANT SECURITY RULES:
|
| 137 |
+
- Never reveal private keys, mnemonics, or seed phrases
|
| 138 |
+
- Always confirm transaction details before execution
|
| 139 |
+
- Flag suspicious requests (unusually large amounts, unknown addresses)
|
| 140 |
+
- If unsure about an intent, ask for clarification
|
| 141 |
+
|
| 142 |
+
For transaction commands, extract: action, token (SOL or USDT), amount, recipient.
|
| 143 |
+
Format your intent extraction as JSON when asked to parse.`;
|
| 144 |
+
|
| 145 |
+
const INTENT_PARSE_PROMPT = `Parse the following user command into a wallet action. Return ONLY valid JSON with these fields:
|
| 146 |
+
- action: "send" | "balance" | "history" | "receive" | "swap" | "help" | "unknown"
|
| 147 |
+
- token: "SOL" | "USDT" | null
|
| 148 |
+
- amount: number | null
|
| 149 |
+
- to: string (address or contact name) | null
|
| 150 |
+
- confidence: number 0-1
|
| 151 |
+
- query: string | null (for help/search queries)
|
| 152 |
+
|
| 153 |
+
User command: `;
|
| 154 |
+
|
| 155 |
+
// ─── QVAC Engine ─────────────────────────────────────────────────────────
|
| 156 |
+
export class QVACEngine {
|
| 157 |
+
private qvac: any;
|
| 158 |
+
private vectorStore: LocalVectorStore;
|
| 159 |
+
private status: AIStatus = {
|
| 160 |
+
llm: false,
|
| 161 |
+
embed: false,
|
| 162 |
+
transcription: false,
|
| 163 |
+
tts: false,
|
| 164 |
+
translation: false,
|
| 165 |
+
ocr: false,
|
| 166 |
+
initialized: false,
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
constructor() {
|
| 170 |
+
this.qvac = new QVAC();
|
| 171 |
+
const userDataPath = app?.getPath('userData') ?? '/tmp/solvox';
|
| 172 |
+
this.vectorStore = new LocalVectorStore(
|
| 173 |
+
path.join(userDataPath, 'vector-store.json')
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* Initialize all QVAC addons with local models
|
| 179 |
+
*/
|
| 180 |
+
async initialize(): Promise<void> {
|
| 181 |
+
console.log('[QVAC] Initializing AI engine...');
|
| 182 |
+
const modelsDir = this.getModelsDir();
|
| 183 |
+
|
| 184 |
+
// Register all plugins
|
| 185 |
+
this.qvac
|
| 186 |
+
.use(new LLMLlamacpp())
|
| 187 |
+
.use(new EmbedLlamacpp())
|
| 188 |
+
.use(new TranscriptionWhispercpp())
|
| 189 |
+
.use(new TTSOnnx())
|
| 190 |
+
.use(new TranslationNmtcpp())
|
| 191 |
+
.use(new OCROnnx());
|
| 192 |
+
|
| 193 |
+
// Load models in parallel where possible
|
| 194 |
+
const loadPromises: Promise<void>[] = [];
|
| 195 |
+
|
| 196 |
+
// LLM — primary model for chat and intent parsing
|
| 197 |
+
const llmModelPath = path.join(modelsDir, 'llama-3.2-3b-instruct-q4_k_m.gguf');
|
| 198 |
+
if (fs.existsSync(llmModelPath)) {
|
| 199 |
+
loadPromises.push(
|
| 200 |
+
this.qvac.llm.load(llmModelPath, {
|
| 201 |
+
contextSize: 4096,
|
| 202 |
+
nGpuLayers: 32,
|
| 203 |
+
}).then(() => {
|
| 204 |
+
this.status.llm = true;
|
| 205 |
+
console.log('[QVAC] ✓ LLM loaded');
|
| 206 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ LLM failed:', e.message))
|
| 207 |
+
);
|
| 208 |
+
} else {
|
| 209 |
+
console.warn(`[QVAC] LLM model not found at ${llmModelPath}`);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Embeddings — for semantic search
|
| 213 |
+
const embedModelPath = path.join(modelsDir, 'nomic-embed-text-v1.5.Q4_K_M.gguf');
|
| 214 |
+
if (fs.existsSync(embedModelPath)) {
|
| 215 |
+
loadPromises.push(
|
| 216 |
+
this.qvac.embed.load(embedModelPath).then(() => {
|
| 217 |
+
this.status.embed = true;
|
| 218 |
+
console.log('[QVAC] ✓ Embeddings loaded');
|
| 219 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ Embeddings failed:', e.message))
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Speech-to-text — Whisper
|
| 224 |
+
const whisperModelPath = path.join(modelsDir, 'ggml-base.en.bin');
|
| 225 |
+
if (fs.existsSync(whisperModelPath)) {
|
| 226 |
+
loadPromises.push(
|
| 227 |
+
this.qvac.transcription.load(whisperModelPath, {
|
| 228 |
+
language: 'en',
|
| 229 |
+
}).then(() => {
|
| 230 |
+
this.status.transcription = true;
|
| 231 |
+
console.log('[QVAC] ✓ Speech-to-text loaded');
|
| 232 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ STT failed:', e.message))
|
| 233 |
+
);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// Text-to-speech
|
| 237 |
+
const ttsModelPath = path.join(modelsDir, 'en_US-amy-medium.onnx');
|
| 238 |
+
if (fs.existsSync(ttsModelPath)) {
|
| 239 |
+
loadPromises.push(
|
| 240 |
+
this.qvac.tts.load(ttsModelPath, {
|
| 241 |
+
sampleRate: 22050,
|
| 242 |
+
}).then(() => {
|
| 243 |
+
this.status.tts = true;
|
| 244 |
+
console.log('[QVAC] ✓ Text-to-speech loaded');
|
| 245 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ TTS failed:', e.message))
|
| 246 |
+
);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// Translation
|
| 250 |
+
const translationModelPath = path.join(modelsDir, 'translate-en-es.bin');
|
| 251 |
+
if (fs.existsSync(translationModelPath)) {
|
| 252 |
+
loadPromises.push(
|
| 253 |
+
this.qvac.translation.load(translationModelPath).then(() => {
|
| 254 |
+
this.status.translation = true;
|
| 255 |
+
console.log('[QVAC] ✓ Translation loaded');
|
| 256 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ Translation failed:', e.message))
|
| 257 |
+
);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// OCR
|
| 261 |
+
const ocrModelPath = path.join(modelsDir, 'ppocr-v4.onnx');
|
| 262 |
+
if (fs.existsSync(ocrModelPath)) {
|
| 263 |
+
loadPromises.push(
|
| 264 |
+
this.qvac.ocr.load(ocrModelPath).then(() => {
|
| 265 |
+
this.status.ocr = true;
|
| 266 |
+
console.log('[QVAC] ✓ OCR loaded');
|
| 267 |
+
}).catch((e: Error) => console.error('[QVAC] ✗ OCR failed:', e.message))
|
| 268 |
+
);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
await Promise.allSettled(loadPromises);
|
| 272 |
+
this.status.initialized = true;
|
| 273 |
+
console.log('[QVAC] Engine initialized. Status:', this.status);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/**
|
| 277 |
+
* Process a voice command end-to-end:
|
| 278 |
+
* Audio → Transcription → Intent Parsing → Response → Speech
|
| 279 |
+
*/
|
| 280 |
+
async processVoiceCommand(audioBuffer: Buffer): Promise<{
|
| 281 |
+
transcription: string;
|
| 282 |
+
intent: WalletIntent;
|
| 283 |
+
response: string;
|
| 284 |
+
audio?: Buffer;
|
| 285 |
+
}> {
|
| 286 |
+
// Step 1: Transcribe voice to text
|
| 287 |
+
let transcription: string;
|
| 288 |
+
if (this.status.transcription) {
|
| 289 |
+
transcription = await this.qvac.transcription.transcribe(audioBuffer);
|
| 290 |
+
} else {
|
| 291 |
+
throw new Error('Speech-to-text not available. Please check model files.');
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
console.log('[QVAC] Transcription:', transcription);
|
| 295 |
+
|
| 296 |
+
// Step 2: Parse intent from transcription
|
| 297 |
+
const intent = await this.parseIntent(transcription);
|
| 298 |
+
|
| 299 |
+
// Step 3: Generate natural language response
|
| 300 |
+
const response = await this.generateResponse(intent);
|
| 301 |
+
|
| 302 |
+
// Step 4: Synthesize speech response (optional)
|
| 303 |
+
let audio: Buffer | undefined;
|
| 304 |
+
if (this.status.tts) {
|
| 305 |
+
try {
|
| 306 |
+
audio = await this.qvac.tts.synthesize(response);
|
| 307 |
+
} catch (e) {
|
| 308 |
+
console.warn('[QVAC] TTS failed, returning text only');
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
return { transcription, intent, response, audio };
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/**
|
| 316 |
+
* Parse text into a structured wallet intent
|
| 317 |
+
*/
|
| 318 |
+
async parseIntent(text: string): Promise<WalletIntent> {
|
| 319 |
+
if (!this.status.llm) {
|
| 320 |
+
// Fallback: regex-based intent parsing
|
| 321 |
+
return this.regexParseIntent(text);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
try {
|
| 325 |
+
const prompt = INTENT_PARSE_PROMPT + `"${text}"`;
|
| 326 |
+
const response = await this.qvac.llm.chat([
|
| 327 |
+
{ role: 'system', content: 'You are an intent parser. Return ONLY valid JSON.' },
|
| 328 |
+
{ role: 'user', content: prompt },
|
| 329 |
+
], {
|
| 330 |
+
maxTokens: 256,
|
| 331 |
+
temperature: 0.1,
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
// Extract JSON from response
|
| 335 |
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
| 336 |
+
if (jsonMatch) {
|
| 337 |
+
const parsed = JSON.parse(jsonMatch[0]);
|
| 338 |
+
return {
|
| 339 |
+
action: parsed.action || 'unknown',
|
| 340 |
+
token: parsed.token || undefined,
|
| 341 |
+
amount: parsed.amount || undefined,
|
| 342 |
+
to: parsed.to || undefined,
|
| 343 |
+
query: parsed.query || undefined,
|
| 344 |
+
confidence: parsed.confidence || 0.5,
|
| 345 |
+
rawText: text,
|
| 346 |
+
};
|
| 347 |
+
}
|
| 348 |
+
} catch (error) {
|
| 349 |
+
console.warn('[QVAC] LLM intent parsing failed, using regex fallback');
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
return this.regexParseIntent(text);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/**
|
| 356 |
+
* Regex-based intent parser (fallback when LLM is unavailable)
|
| 357 |
+
*/
|
| 358 |
+
private regexParseIntent(text: string): WalletIntent {
|
| 359 |
+
const lower = text.toLowerCase().trim();
|
| 360 |
+
|
| 361 |
+
// Send patterns
|
| 362 |
+
const sendMatch = lower.match(
|
| 363 |
+
/(?:send|transfer|pay)\s+(\d+(?:\.\d+)?)\s*(sol|usdt|dollars?|tether)?\s*(?:to\s+)?(.+)?/i
|
| 364 |
+
);
|
| 365 |
+
if (sendMatch) {
|
| 366 |
+
const amount = parseFloat(sendMatch[1]);
|
| 367 |
+
let token = (sendMatch[2] || 'sol').toUpperCase();
|
| 368 |
+
if (token === 'DOLLARS' || token === 'DOLLAR' || token === 'TETHER') token = 'USDT';
|
| 369 |
+
const to = sendMatch[3]?.trim();
|
| 370 |
+
return {
|
| 371 |
+
action: 'send',
|
| 372 |
+
token,
|
| 373 |
+
amount,
|
| 374 |
+
to,
|
| 375 |
+
confidence: 0.8,
|
| 376 |
+
rawText: text,
|
| 377 |
+
};
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// Balance patterns
|
| 381 |
+
if (/(?:balance|how much|what.*(?:have|balance|funds))/.test(lower)) {
|
| 382 |
+
return { action: 'balance', confidence: 0.9, rawText: text };
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
// History patterns
|
| 386 |
+
if (/(?:history|transactions?|recent|activity|last)/.test(lower)) {
|
| 387 |
+
return { action: 'history', confidence: 0.8, rawText: text };
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
// Receive patterns
|
| 391 |
+
if (/(?:receive|my address|deposit|qr)/.test(lower)) {
|
| 392 |
+
return { action: 'receive', confidence: 0.8, rawText: text };
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// Help patterns
|
| 396 |
+
if (/(?:help|what can|how do|explain)/.test(lower)) {
|
| 397 |
+
return { action: 'help', query: text, confidence: 0.7, rawText: text };
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
return { action: 'unknown', confidence: 0.3, rawText: text };
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/**
|
| 404 |
+
* Generate a natural language response for an intent
|
| 405 |
+
*/
|
| 406 |
+
private async generateResponse(intent: WalletIntent): Promise<string> {
|
| 407 |
+
if (!this.status.llm) {
|
| 408 |
+
// Fallback responses
|
| 409 |
+
switch (intent.action) {
|
| 410 |
+
case 'send':
|
| 411 |
+
return `Sending ${intent.amount} ${intent.token || 'SOL'} to ${intent.to || 'unknown address'}. Please confirm.`;
|
| 412 |
+
case 'balance':
|
| 413 |
+
return 'Checking your balance...';
|
| 414 |
+
case 'history':
|
| 415 |
+
return 'Loading your recent transactions...';
|
| 416 |
+
case 'receive':
|
| 417 |
+
return 'Here is your wallet address for receiving funds.';
|
| 418 |
+
case 'help':
|
| 419 |
+
return 'I can help you send SOL and USDT, check your balance, and view transaction history. Try saying "Send 5 SOL to..." or "What is my balance?"';
|
| 420 |
+
default:
|
| 421 |
+
return "I didn't understand that. Try saying 'send 5 SOL to...' or 'check my balance'.";
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
try {
|
| 426 |
+
// Use RAG context for richer responses
|
| 427 |
+
let context = '';
|
| 428 |
+
if (this.status.embed) {
|
| 429 |
+
const results = await this.semanticSearch(intent.rawText);
|
| 430 |
+
if (results.length > 0) {
|
| 431 |
+
context = '\n\nRelevant context from your history:\n' +
|
| 432 |
+
results.slice(0, 3).map(r => `- ${r.text}`).join('\n');
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
const response = await this.qvac.llm.chat([
|
| 437 |
+
{ role: 'system', content: WALLET_SYSTEM_PROMPT + context },
|
| 438 |
+
{ role: 'user', content: intent.rawText },
|
| 439 |
+
], {
|
| 440 |
+
maxTokens: 256,
|
| 441 |
+
temperature: 0.7,
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
return response;
|
| 445 |
+
} catch {
|
| 446 |
+
return this.generateResponse({ ...intent, action: intent.action }); // Trigger fallback
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/**
|
| 451 |
+
* Free-form chat with the local LLM
|
| 452 |
+
*/
|
| 453 |
+
async chat(message: string): Promise<string> {
|
| 454 |
+
if (!this.status.llm) {
|
| 455 |
+
return "AI chat is not available. Please ensure the LLM model is downloaded.";
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
// Enrich with RAG context
|
| 459 |
+
let context = '';
|
| 460 |
+
if (this.status.embed) {
|
| 461 |
+
const results = await this.semanticSearch(message);
|
| 462 |
+
if (results.length > 0) {
|
| 463 |
+
context = '\n\nContext from your wallet history:\n' +
|
| 464 |
+
results.slice(0, 3).map(r => `- ${r.text}`).join('\n');
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
return this.qvac.llm.chat([
|
| 469 |
+
{ role: 'system', content: WALLET_SYSTEM_PROMPT + context },
|
| 470 |
+
{ role: 'user', content: message },
|
| 471 |
+
], {
|
| 472 |
+
maxTokens: 512,
|
| 473 |
+
temperature: 0.7,
|
| 474 |
+
});
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/**
|
| 478 |
+
* Text-to-speech synthesis
|
| 479 |
+
*/
|
| 480 |
+
async speak(text: string): Promise<Buffer> {
|
| 481 |
+
if (!this.status.tts) {
|
| 482 |
+
throw new Error('Text-to-speech not available');
|
| 483 |
+
}
|
| 484 |
+
return this.qvac.tts.synthesize(text);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
/**
|
| 488 |
+
* Translate text between languages
|
| 489 |
+
*/
|
| 490 |
+
async translate(text: string, from: string, to: string): Promise<string> {
|
| 491 |
+
if (!this.status.translation) {
|
| 492 |
+
throw new Error('Translation not available');
|
| 493 |
+
}
|
| 494 |
+
return this.qvac.translation.translate(text, { from, to });
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/**
|
| 498 |
+
* Generate text embeddings
|
| 499 |
+
*/
|
| 500 |
+
async embed(text: string): Promise<number[]> {
|
| 501 |
+
if (!this.status.embed) {
|
| 502 |
+
throw new Error('Embeddings not available');
|
| 503 |
+
}
|
| 504 |
+
return this.qvac.embed.embed(text);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
/**
|
| 508 |
+
* OCR — extract text from image
|
| 509 |
+
*/
|
| 510 |
+
async ocr(imageBuffer: Buffer): Promise<string> {
|
| 511 |
+
if (!this.status.ocr) {
|
| 512 |
+
throw new Error('OCR not available');
|
| 513 |
+
}
|
| 514 |
+
return this.qvac.ocr.recognize(imageBuffer, { format: 'text' });
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
/**
|
| 518 |
+
* Semantic search over the local knowledge base
|
| 519 |
+
*/
|
| 520 |
+
async semanticSearch(query: string): Promise<RAGResult[]> {
|
| 521 |
+
if (!this.status.embed) return [];
|
| 522 |
+
|
| 523 |
+
try {
|
| 524 |
+
const queryVector = await this.qvac.embed.embed(query);
|
| 525 |
+
return this.vectorStore.search(queryVector, 5);
|
| 526 |
+
} catch {
|
| 527 |
+
return [];
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/**
|
| 532 |
+
* Add a document to the local knowledge base
|
| 533 |
+
*/
|
| 534 |
+
async addToKnowledgeBase(text: string, metadata: Record<string, any>): Promise<void> {
|
| 535 |
+
if (!this.status.embed) return;
|
| 536 |
+
|
| 537 |
+
try {
|
| 538 |
+
const vector = await this.qvac.embed.embed(text);
|
| 539 |
+
const id = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
| 540 |
+
this.vectorStore.add(id, text, vector, metadata);
|
| 541 |
+
} catch (error) {
|
| 542 |
+
console.error('[QVAC] Failed to add to knowledge base:', error);
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
/**
|
| 547 |
+
* Get current AI status
|
| 548 |
+
*/
|
| 549 |
+
getStatus(): AIStatus {
|
| 550 |
+
return { ...this.status };
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
/**
|
| 554 |
+
* Shutdown — release all resources
|
| 555 |
+
*/
|
| 556 |
+
shutdown(): void {
|
| 557 |
+
console.log('[QVAC] Shutting down AI engine');
|
| 558 |
+
this.status = {
|
| 559 |
+
llm: false,
|
| 560 |
+
embed: false,
|
| 561 |
+
transcription: false,
|
| 562 |
+
tts: false,
|
| 563 |
+
translation: false,
|
| 564 |
+
ocr: false,
|
| 565 |
+
initialized: false,
|
| 566 |
+
};
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// ── Private helpers ──
|
| 570 |
+
|
| 571 |
+
private getModelsDir(): string {
|
| 572 |
+
// In production: models are in app resources
|
| 573 |
+
// In development: models are in project root
|
| 574 |
+
if (app?.isPackaged) {
|
| 575 |
+
return path.join(process.resourcesPath, 'models');
|
| 576 |
+
}
|
| 577 |
+
return path.join(__dirname, '../../..', 'models');
|
| 578 |
+
}
|
| 579 |
+
}
|
src/main/main.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Main Process Entry Point
|
| 3 |
+
*
|
| 4 |
+
* Security Architecture:
|
| 5 |
+
* - contextIsolation: true — renderer cannot access Node.js
|
| 6 |
+
* - nodeIntegration: false — no require() in renderer
|
| 7 |
+
* - sandbox: true — OS-level process isolation
|
| 8 |
+
* - All private keys live exclusively in this process
|
| 9 |
+
* - IPC channels are explicitly allowlisted via contextBridge
|
| 10 |
+
* - CSP headers block script injection
|
| 11 |
+
* - Auto-lock after 5 minutes of inactivity
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { app, BrowserWindow, ipcMain, session, systemPreferences } from 'electron';
|
| 15 |
+
import * as path from 'path';
|
| 16 |
+
import { KeyVault } from './security/keyVault';
|
| 17 |
+
import { WalletService } from './wallet/walletService';
|
| 18 |
+
import { SecurityManager } from './security/securityManager';
|
| 19 |
+
import { QVACEngine } from './ai/qvacEngine';
|
| 20 |
+
import { TransactionGuard } from './security/transactionGuard';
|
| 21 |
+
|
| 22 |
+
// ─── Singleton state (main process only) ────────────────────────────────
|
| 23 |
+
let mainWindow: BrowserWindow | null = null;
|
| 24 |
+
let walletService: WalletService | null = null;
|
| 25 |
+
let securityManager: SecurityManager | null = null;
|
| 26 |
+
let qvacEngine: QVACEngine | null = null;
|
| 27 |
+
let transactionGuard: TransactionGuard | null = null;
|
| 28 |
+
|
| 29 |
+
const isDev = !app.isPackaged;
|
| 30 |
+
|
| 31 |
+
// ─── App Security Hardening ─────────────────────────────────────────────
|
| 32 |
+
app.on('web-contents-created', (_, contents) => {
|
| 33 |
+
// Block all navigation away from local files
|
| 34 |
+
contents.on('will-navigate', (event, url) => {
|
| 35 |
+
if (!url.startsWith('file://') && !url.startsWith('http://localhost')) {
|
| 36 |
+
console.warn('[Security] Blocked navigation to:', url);
|
| 37 |
+
event.preventDefault();
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Kill new window creation entirely
|
| 42 |
+
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
// ─── Create Main Window ─────────────────────────────────────────────────
|
| 46 |
+
function createWindow(): void {
|
| 47 |
+
mainWindow = new BrowserWindow({
|
| 48 |
+
width: 1200,
|
| 49 |
+
height: 800,
|
| 50 |
+
minWidth: 900,
|
| 51 |
+
minHeight: 600,
|
| 52 |
+
title: 'SolVox',
|
| 53 |
+
backgroundColor: '#0E0E2C',
|
| 54 |
+
titleBarStyle: 'hiddenInset',
|
| 55 |
+
webPreferences: {
|
| 56 |
+
preload: path.join(__dirname, 'preload.js'),
|
| 57 |
+
contextIsolation: true,
|
| 58 |
+
nodeIntegration: false,
|
| 59 |
+
sandbox: true,
|
| 60 |
+
webSecurity: true,
|
| 61 |
+
allowRunningInsecureContent: false,
|
| 62 |
+
navigateOnDragDrop: false,
|
| 63 |
+
},
|
| 64 |
+
show: false,
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
// Show when ready to prevent white flash
|
| 68 |
+
mainWindow.once('ready-to-show', () => {
|
| 69 |
+
mainWindow?.show();
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
// Load renderer
|
| 73 |
+
if (isDev) {
|
| 74 |
+
mainWindow.loadURL('http://localhost:5173');
|
| 75 |
+
} else {
|
| 76 |
+
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Disable devtools in production
|
| 80 |
+
if (!isDev) {
|
| 81 |
+
mainWindow.webContents.on('devtools-opened', () => {
|
| 82 |
+
mainWindow?.webContents.closeDevTools();
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
mainWindow.on('closed', () => {
|
| 87 |
+
mainWindow = null;
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// ─── Content Security Policy ────────────────────────────────────────────
|
| 92 |
+
function setupCSP(): void {
|
| 93 |
+
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
| 94 |
+
callback({
|
| 95 |
+
responseHeaders: {
|
| 96 |
+
...details.responseHeaders,
|
| 97 |
+
'Content-Security-Policy': [
|
| 98 |
+
[
|
| 99 |
+
"default-src 'self'",
|
| 100 |
+
"script-src 'self'",
|
| 101 |
+
"style-src 'self' 'unsafe-inline'",
|
| 102 |
+
"img-src 'self' data: https:",
|
| 103 |
+
"connect-src 'self' https://api.mainnet-beta.solana.com https://api.devnet.solana.com wss://api.mainnet-beta.solana.com wss://api.devnet.solana.com http://localhost:*",
|
| 104 |
+
"font-src 'self'",
|
| 105 |
+
"object-src 'none'",
|
| 106 |
+
"base-uri 'self'",
|
| 107 |
+
"form-action 'self'",
|
| 108 |
+
"frame-ancestors 'none'",
|
| 109 |
+
].join('; '),
|
| 110 |
+
],
|
| 111 |
+
},
|
| 112 |
+
});
|
| 113 |
+
});
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ─── Initialize Services ────────────────────────────────────────────────
|
| 117 |
+
async function initializeServices(): Promise<void> {
|
| 118 |
+
const keyVault = new KeyVault(app.getPath('userData'));
|
| 119 |
+
walletService = new WalletService(keyVault);
|
| 120 |
+
securityManager = new SecurityManager(keyVault, mainWindow!);
|
| 121 |
+
transactionGuard = new TransactionGuard(keyVault);
|
| 122 |
+
qvacEngine = new QVACEngine();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// ─── Register IPC Handlers ──────────────────────────────────────────────
|
| 126 |
+
function registerIPCHandlers(): void {
|
| 127 |
+
// ── Wallet Operations ──
|
| 128 |
+
ipcMain.handle('wallet:create', async () => {
|
| 129 |
+
try {
|
| 130 |
+
const result = await walletService!.createWallet();
|
| 131 |
+
return { success: true, publicKey: result.publicKey };
|
| 132 |
+
} catch (error: any) {
|
| 133 |
+
return { success: false, error: error.message };
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
ipcMain.handle('wallet:import', async (_, mnemonic: string) => {
|
| 138 |
+
try {
|
| 139 |
+
const result = await walletService!.importFromMnemonic(mnemonic);
|
| 140 |
+
return { success: true, publicKey: result.publicKey };
|
| 141 |
+
} catch (error: any) {
|
| 142 |
+
return { success: false, error: error.message };
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
ipcMain.handle('wallet:getPublicKey', async () => {
|
| 147 |
+
return walletService!.getPublicKey();
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
ipcMain.handle('wallet:getBalance', async () => {
|
| 151 |
+
try {
|
| 152 |
+
const balance = await walletService!.getBalance();
|
| 153 |
+
return { success: true, ...balance };
|
| 154 |
+
} catch (error: any) {
|
| 155 |
+
return { success: false, error: error.message };
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
ipcMain.handle('wallet:sendSOL', async (_, { to, amount }: { to: string; amount: number }) => {
|
| 160 |
+
try {
|
| 161 |
+
// Security: validate inputs
|
| 162 |
+
if (!transactionGuard!.validateAddress(to)) {
|
| 163 |
+
return { success: false, error: 'Invalid recipient address' };
|
| 164 |
+
}
|
| 165 |
+
if (!transactionGuard!.validateAmount(amount)) {
|
| 166 |
+
return { success: false, error: 'Invalid amount' };
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Security: check transaction limits
|
| 170 |
+
const limitCheck = await transactionGuard!.checkTransactionLimits(amount, 'SOL');
|
| 171 |
+
if (!limitCheck.allowed) {
|
| 172 |
+
return { success: false, error: limitCheck.reason };
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Security: check if address is whitelisted (if whitelisting is enabled)
|
| 176 |
+
const whitelistCheck = transactionGuard!.checkWhitelist(to);
|
| 177 |
+
if (!whitelistCheck.allowed) {
|
| 178 |
+
return { success: false, error: whitelistCheck.reason, requiresApproval: true };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const sig = await walletService!.sendSOL(to, amount);
|
| 182 |
+
await transactionGuard!.recordTransaction(amount, 'SOL', to);
|
| 183 |
+
return { success: true, signature: sig, explorer: `https://solscan.io/tx/${sig}` };
|
| 184 |
+
} catch (error: any) {
|
| 185 |
+
return { success: false, error: error.message };
|
| 186 |
+
}
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
ipcMain.handle('wallet:sendUSDT', async (_, { to, amount }: { to: string; amount: number }) => {
|
| 190 |
+
try {
|
| 191 |
+
if (!transactionGuard!.validateAddress(to)) {
|
| 192 |
+
return { success: false, error: 'Invalid recipient address' };
|
| 193 |
+
}
|
| 194 |
+
if (!transactionGuard!.validateAmount(amount)) {
|
| 195 |
+
return { success: false, error: 'Invalid amount' };
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
const limitCheck = await transactionGuard!.checkTransactionLimits(amount, 'USDT');
|
| 199 |
+
if (!limitCheck.allowed) {
|
| 200 |
+
return { success: false, error: limitCheck.reason };
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const whitelistCheck = transactionGuard!.checkWhitelist(to);
|
| 204 |
+
if (!whitelistCheck.allowed) {
|
| 205 |
+
return { success: false, error: whitelistCheck.reason, requiresApproval: true };
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const sig = await walletService!.sendUSDT(to, amount);
|
| 209 |
+
await transactionGuard!.recordTransaction(amount, 'USDT', to);
|
| 210 |
+
return { success: true, signature: sig, explorer: `https://solscan.io/tx/${sig}` };
|
| 211 |
+
} catch (error: any) {
|
| 212 |
+
return { success: false, error: error.message };
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
ipcMain.handle('wallet:getHistory', async (_, limit?: number) => {
|
| 217 |
+
try {
|
| 218 |
+
const history = await walletService!.getTransactionHistory(limit);
|
| 219 |
+
return { success: true, history };
|
| 220 |
+
} catch (error: any) {
|
| 221 |
+
return { success: false, error: error.message };
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
ipcMain.handle('wallet:isUnlocked', async () => {
|
| 226 |
+
return walletService!.isUnlocked();
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
ipcMain.handle('wallet:lock', async () => {
|
| 230 |
+
walletService!.lock();
|
| 231 |
+
mainWindow?.webContents.send('auth:locked');
|
| 232 |
+
return { success: true };
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
ipcMain.handle('wallet:exists', async () => {
|
| 236 |
+
return walletService!.walletExists();
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
// ── Authentication ──
|
| 240 |
+
ipcMain.handle('auth:biometric', async (_, reason?: string) => {
|
| 241 |
+
return securityManager!.promptBiometric(reason);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
ipcMain.handle('auth:unlock', async (_, pin: string) => {
|
| 245 |
+
try {
|
| 246 |
+
const result = await securityManager!.unlockWithPin(pin);
|
| 247 |
+
if (result.success) {
|
| 248 |
+
await walletService!.unlock(pin);
|
| 249 |
+
}
|
| 250 |
+
return result;
|
| 251 |
+
} catch (error: any) {
|
| 252 |
+
return { success: false, error: error.message };
|
| 253 |
+
}
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
ipcMain.handle('auth:setPin', async (_, pin: string) => {
|
| 257 |
+
try {
|
| 258 |
+
await securityManager!.setPin(pin);
|
| 259 |
+
return { success: true };
|
| 260 |
+
} catch (error: any) {
|
| 261 |
+
return { success: false, error: error.message };
|
| 262 |
+
}
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
ipcMain.handle('auth:biometricAvailable', async () => {
|
| 266 |
+
return securityManager!.isBiometricAvailable();
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// ── Security Settings ──
|
| 270 |
+
ipcMain.handle('security:getSettings', async () => {
|
| 271 |
+
return transactionGuard!.getSettings();
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
ipcMain.handle('security:updateSettings', async (_, settings: any) => {
|
| 275 |
+
return transactionGuard!.updateSettings(settings);
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
ipcMain.handle('security:addWhitelist', async (_, address: string, label: string) => {
|
| 279 |
+
return transactionGuard!.addToWhitelist(address, label);
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
ipcMain.handle('security:removeWhitelist', async (_, address: string) => {
|
| 283 |
+
return transactionGuard!.removeFromWhitelist(address);
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
ipcMain.handle('security:getWhitelist', async () => {
|
| 287 |
+
return transactionGuard!.getWhitelist();
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
ipcMain.handle('security:getAnomalies', async () => {
|
| 291 |
+
return transactionGuard!.getAnomalyLog();
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
// ── AI / QVAC Operations ──
|
| 295 |
+
ipcMain.handle('ai:initialize', async () => {
|
| 296 |
+
try {
|
| 297 |
+
await qvacEngine!.initialize();
|
| 298 |
+
return { success: true };
|
| 299 |
+
} catch (error: any) {
|
| 300 |
+
return { success: false, error: error.message };
|
| 301 |
+
}
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
ipcMain.handle('ai:processVoice', async (_, audioData: ArrayBuffer) => {
|
| 305 |
+
try {
|
| 306 |
+
const result = await qvacEngine!.processVoiceCommand(Buffer.from(audioData));
|
| 307 |
+
return { success: true, ...result };
|
| 308 |
+
} catch (error: any) {
|
| 309 |
+
return { success: false, error: error.message };
|
| 310 |
+
}
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
ipcMain.handle('ai:chat', async (_, message: string) => {
|
| 314 |
+
try {
|
| 315 |
+
const result = await qvacEngine!.chat(message);
|
| 316 |
+
return { success: true, response: result };
|
| 317 |
+
} catch (error: any) {
|
| 318 |
+
return { success: false, error: error.message };
|
| 319 |
+
}
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
ipcMain.handle('ai:parseIntent', async (_, text: string) => {
|
| 323 |
+
try {
|
| 324 |
+
const intent = await qvacEngine!.parseIntent(text);
|
| 325 |
+
return { success: true, intent };
|
| 326 |
+
} catch (error: any) {
|
| 327 |
+
return { success: false, error: error.message };
|
| 328 |
+
}
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
ipcMain.handle('ai:speak', async (_, text: string) => {
|
| 332 |
+
try {
|
| 333 |
+
const audioData = await qvacEngine!.speak(text);
|
| 334 |
+
return { success: true, audio: audioData };
|
| 335 |
+
} catch (error: any) {
|
| 336 |
+
return { success: false, error: error.message };
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
ipcMain.handle('ai:translate', async (_, text: string, from: string, to: string) => {
|
| 341 |
+
try {
|
| 342 |
+
const translated = await qvacEngine!.translate(text, from, to);
|
| 343 |
+
return { success: true, translated };
|
| 344 |
+
} catch (error: any) {
|
| 345 |
+
return { success: false, error: error.message };
|
| 346 |
+
}
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
ipcMain.handle('ai:embed', async (_, text: string) => {
|
| 350 |
+
try {
|
| 351 |
+
const vector = await qvacEngine!.embed(text);
|
| 352 |
+
return { success: true, vector };
|
| 353 |
+
} catch (error: any) {
|
| 354 |
+
return { success: false, error: error.message };
|
| 355 |
+
}
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
ipcMain.handle('ai:ocr', async (_, imageData: ArrayBuffer) => {
|
| 359 |
+
try {
|
| 360 |
+
const text = await qvacEngine!.ocr(Buffer.from(imageData));
|
| 361 |
+
return { success: true, text };
|
| 362 |
+
} catch (error: any) {
|
| 363 |
+
return { success: false, error: error.message };
|
| 364 |
+
}
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
ipcMain.handle('ai:getStatus', async () => {
|
| 368 |
+
return qvacEngine!.getStatus();
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
// ── RAG / Semantic Search ──
|
| 372 |
+
ipcMain.handle('rag:search', async (_, query: string) => {
|
| 373 |
+
try {
|
| 374 |
+
const results = await qvacEngine!.semanticSearch(query);
|
| 375 |
+
return { success: true, results };
|
| 376 |
+
} catch (error: any) {
|
| 377 |
+
return { success: false, error: error.message };
|
| 378 |
+
}
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
ipcMain.handle('rag:addDocument', async (_, text: string, metadata: any) => {
|
| 382 |
+
try {
|
| 383 |
+
await qvacEngine!.addToKnowledgeBase(text, metadata);
|
| 384 |
+
return { success: true };
|
| 385 |
+
} catch (error: any) {
|
| 386 |
+
return { success: false, error: error.message };
|
| 387 |
+
}
|
| 388 |
+
});
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// ─── App Lifecycle ──────────────────────────────────────────────────────
|
| 392 |
+
app.whenReady().then(async () => {
|
| 393 |
+
setupCSP();
|
| 394 |
+
createWindow();
|
| 395 |
+
await initializeServices();
|
| 396 |
+
registerIPCHandlers();
|
| 397 |
+
|
| 398 |
+
// Auto-lock on idle
|
| 399 |
+
securityManager!.startAutoLockTimer();
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
app.on('window-all-closed', () => {
|
| 403 |
+
// Securely wipe session data
|
| 404 |
+
walletService?.lock();
|
| 405 |
+
if (process.platform !== 'darwin') {
|
| 406 |
+
app.quit();
|
| 407 |
+
}
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
app.on('activate', () => {
|
| 411 |
+
if (BrowserWindow.getAllWindows().length === 0) {
|
| 412 |
+
createWindow();
|
| 413 |
+
}
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
app.on('before-quit', () => {
|
| 417 |
+
// Final cleanup — zero out sensitive data
|
| 418 |
+
walletService?.lock();
|
| 419 |
+
qvacEngine?.shutdown();
|
| 420 |
+
});
|
src/main/preload.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Preload Script
|
| 3 |
+
*
|
| 4 |
+
* This is the ONLY bridge between the renderer and main process.
|
| 5 |
+
* Every IPC channel is explicitly allowlisted. The renderer
|
| 6 |
+
* can NEVER access Node.js APIs, the filesystem, or private keys.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
const { contextBridge, ipcRenderer } = require('electron');
|
| 10 |
+
|
| 11 |
+
contextBridge.exposeInMainWorld('solvox', {
|
| 12 |
+
// ── Wallet Operations ──
|
| 13 |
+
wallet: {
|
| 14 |
+
create: () => ipcRenderer.invoke('wallet:create'),
|
| 15 |
+
import: (mnemonic: string) => ipcRenderer.invoke('wallet:import', mnemonic),
|
| 16 |
+
getPublicKey: () => ipcRenderer.invoke('wallet:getPublicKey'),
|
| 17 |
+
getBalance: () => ipcRenderer.invoke('wallet:getBalance'),
|
| 18 |
+
sendSOL: (to: string, amount: number) =>
|
| 19 |
+
ipcRenderer.invoke('wallet:sendSOL', { to, amount }),
|
| 20 |
+
sendUSDT: (to: string, amount: number) =>
|
| 21 |
+
ipcRenderer.invoke('wallet:sendUSDT', { to, amount }),
|
| 22 |
+
getHistory: (limit?: number) => ipcRenderer.invoke('wallet:getHistory', limit),
|
| 23 |
+
isUnlocked: () => ipcRenderer.invoke('wallet:isUnlocked'),
|
| 24 |
+
lock: () => ipcRenderer.invoke('wallet:lock'),
|
| 25 |
+
exists: () => ipcRenderer.invoke('wallet:exists'),
|
| 26 |
+
},
|
| 27 |
+
|
| 28 |
+
// ── Authentication ──
|
| 29 |
+
auth: {
|
| 30 |
+
biometric: (reason?: string) => ipcRenderer.invoke('auth:biometric', reason),
|
| 31 |
+
unlock: (pin: string) => ipcRenderer.invoke('auth:unlock', pin),
|
| 32 |
+
setPin: (pin: string) => ipcRenderer.invoke('auth:setPin', pin),
|
| 33 |
+
biometricAvailable: () => ipcRenderer.invoke('auth:biometricAvailable'),
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
// ── Security Settings ──
|
| 37 |
+
security: {
|
| 38 |
+
getSettings: () => ipcRenderer.invoke('security:getSettings'),
|
| 39 |
+
updateSettings: (settings: any) =>
|
| 40 |
+
ipcRenderer.invoke('security:updateSettings', settings),
|
| 41 |
+
addWhitelist: (address: string, label: string) =>
|
| 42 |
+
ipcRenderer.invoke('security:addWhitelist', address, label),
|
| 43 |
+
removeWhitelist: (address: string) =>
|
| 44 |
+
ipcRenderer.invoke('security:removeWhitelist', address),
|
| 45 |
+
getWhitelist: () => ipcRenderer.invoke('security:getWhitelist'),
|
| 46 |
+
getAnomalies: () => ipcRenderer.invoke('security:getAnomalies'),
|
| 47 |
+
},
|
| 48 |
+
|
| 49 |
+
// ── AI / QVAC Operations ──
|
| 50 |
+
ai: {
|
| 51 |
+
initialize: () => ipcRenderer.invoke('ai:initialize'),
|
| 52 |
+
processVoice: (audioData: ArrayBuffer) =>
|
| 53 |
+
ipcRenderer.invoke('ai:processVoice', audioData),
|
| 54 |
+
chat: (message: string) => ipcRenderer.invoke('ai:chat', message),
|
| 55 |
+
parseIntent: (text: string) => ipcRenderer.invoke('ai:parseIntent', text),
|
| 56 |
+
speak: (text: string) => ipcRenderer.invoke('ai:speak', text),
|
| 57 |
+
translate: (text: string, from: string, to: string) =>
|
| 58 |
+
ipcRenderer.invoke('ai:translate', text, from, to),
|
| 59 |
+
embed: (text: string) => ipcRenderer.invoke('ai:embed', text),
|
| 60 |
+
ocr: (imageData: ArrayBuffer) => ipcRenderer.invoke('ai:ocr', imageData),
|
| 61 |
+
getStatus: () => ipcRenderer.invoke('ai:getStatus'),
|
| 62 |
+
},
|
| 63 |
+
|
| 64 |
+
// ── RAG / Knowledge Base ──
|
| 65 |
+
rag: {
|
| 66 |
+
search: (query: string) => ipcRenderer.invoke('rag:search', query),
|
| 67 |
+
addDocument: (text: string, metadata: any) =>
|
| 68 |
+
ipcRenderer.invoke('rag:addDocument', text, metadata),
|
| 69 |
+
},
|
| 70 |
+
|
| 71 |
+
// ── Event Subscriptions (main → renderer) ──
|
| 72 |
+
on: {
|
| 73 |
+
locked: (callback: () => void) => {
|
| 74 |
+
const handler = () => callback();
|
| 75 |
+
ipcRenderer.on('auth:locked', handler);
|
| 76 |
+
return () => ipcRenderer.removeListener('auth:locked', handler);
|
| 77 |
+
},
|
| 78 |
+
balanceUpdate: (callback: (data: any) => void) => {
|
| 79 |
+
const handler = (_event: any, data: any) => callback(data);
|
| 80 |
+
ipcRenderer.on('balance:update', handler);
|
| 81 |
+
return () => ipcRenderer.removeListener('balance:update', handler);
|
| 82 |
+
},
|
| 83 |
+
aiStatus: (callback: (data: any) => void) => {
|
| 84 |
+
const handler = (_event: any, data: any) => callback(data);
|
| 85 |
+
ipcRenderer.on('ai:status', handler);
|
| 86 |
+
return () => ipcRenderer.removeListener('ai:status', handler);
|
| 87 |
+
},
|
| 88 |
+
transactionAlert: (callback: (data: any) => void) => {
|
| 89 |
+
const handler = (_event: any, data: any) => callback(data);
|
| 90 |
+
ipcRenderer.on('security:alert', handler);
|
| 91 |
+
return () => ipcRenderer.removeListener('security:alert', handler);
|
| 92 |
+
},
|
| 93 |
+
},
|
| 94 |
+
});
|
src/main/security/keyVault.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Key Vault
|
| 3 |
+
*
|
| 4 |
+
* Encrypted key storage using Electron's safeStorage API.
|
| 5 |
+
* Keys are encrypted with OS-level keychain (Keychain on macOS,
|
| 6 |
+
* DPAPI on Windows, libsecret on Linux). The encryption key
|
| 7 |
+
* is tied to the user's OS identity and cannot be extracted
|
| 8 |
+
* from the application binary.
|
| 9 |
+
*
|
| 10 |
+
* SECURITY: This module runs ONLY in the main process.
|
| 11 |
+
* Private keys NEVER cross the IPC boundary.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { safeStorage } from 'electron';
|
| 15 |
+
import * as fs from 'fs';
|
| 16 |
+
import * as path from 'path';
|
| 17 |
+
import * as crypto from 'crypto';
|
| 18 |
+
|
| 19 |
+
export interface VaultEntry {
|
| 20 |
+
data: string; // base64-encoded encrypted data
|
| 21 |
+
created: string; // ISO timestamp
|
| 22 |
+
version: number; // vault format version
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export class KeyVault {
|
| 26 |
+
private vaultPath: string;
|
| 27 |
+
private vault: Record<string, VaultEntry> = {};
|
| 28 |
+
|
| 29 |
+
constructor(userDataPath: string) {
|
| 30 |
+
this.vaultPath = path.join(userDataPath, 'solvox-vault.enc');
|
| 31 |
+
this.loadVault();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Check if OS-level encryption is available
|
| 36 |
+
*/
|
| 37 |
+
isEncryptionAvailable(): boolean {
|
| 38 |
+
return safeStorage.isEncryptionAvailable();
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Store a secret encrypted with OS keychain
|
| 43 |
+
*/
|
| 44 |
+
store(label: string, plaintext: string): void {
|
| 45 |
+
if (!this.isEncryptionAvailable()) {
|
| 46 |
+
throw new Error('OS encryption unavailable — cannot safely store secrets');
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const encrypted = safeStorage.encryptString(plaintext);
|
| 50 |
+
this.vault[label] = {
|
| 51 |
+
data: encrypted.toString('base64'),
|
| 52 |
+
created: new Date().toISOString(),
|
| 53 |
+
version: 1,
|
| 54 |
+
};
|
| 55 |
+
this.saveVault();
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Retrieve and decrypt a secret
|
| 60 |
+
*/
|
| 61 |
+
retrieve(label: string): string | null {
|
| 62 |
+
const entry = this.vault[label];
|
| 63 |
+
if (!entry) return null;
|
| 64 |
+
|
| 65 |
+
try {
|
| 66 |
+
const buf = Buffer.from(entry.data, 'base64');
|
| 67 |
+
return safeStorage.decryptString(buf);
|
| 68 |
+
} catch (error) {
|
| 69 |
+
console.error(`[KeyVault] Failed to decrypt ${label}:`, error);
|
| 70 |
+
return null;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Delete a secret from the vault
|
| 76 |
+
*/
|
| 77 |
+
delete(label: string): void {
|
| 78 |
+
delete this.vault[label];
|
| 79 |
+
this.saveVault();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Check if a secret exists
|
| 84 |
+
*/
|
| 85 |
+
has(label: string): boolean {
|
| 86 |
+
return label in this.vault;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Store a secret encrypted with a user-provided PIN
|
| 91 |
+
* Used as additional layer over safeStorage
|
| 92 |
+
*/
|
| 93 |
+
storeWithPin(label: string, plaintext: string, pin: string): void {
|
| 94 |
+
const key = this.deriveKeyFromPin(pin);
|
| 95 |
+
const iv = crypto.randomBytes(16);
|
| 96 |
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
| 97 |
+
|
| 98 |
+
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
| 99 |
+
encrypted += cipher.final('base64');
|
| 100 |
+
const authTag = cipher.getAuthTag();
|
| 101 |
+
|
| 102 |
+
const combined = JSON.stringify({
|
| 103 |
+
iv: iv.toString('base64'),
|
| 104 |
+
data: encrypted,
|
| 105 |
+
tag: authTag.toString('base64'),
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
// Double encrypt: PIN-based AES-256-GCM + OS safeStorage
|
| 109 |
+
this.store(label, combined);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Retrieve a secret that was encrypted with PIN
|
| 114 |
+
*/
|
| 115 |
+
retrieveWithPin(label: string, pin: string): string | null {
|
| 116 |
+
const combined = this.retrieve(label);
|
| 117 |
+
if (!combined) return null;
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
const { iv, data, tag } = JSON.parse(combined);
|
| 121 |
+
const key = this.deriveKeyFromPin(pin);
|
| 122 |
+
const decipher = crypto.createDecipheriv(
|
| 123 |
+
'aes-256-gcm',
|
| 124 |
+
key,
|
| 125 |
+
Buffer.from(iv, 'base64')
|
| 126 |
+
);
|
| 127 |
+
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
| 128 |
+
|
| 129 |
+
let decrypted = decipher.update(data, 'base64', 'utf8');
|
| 130 |
+
decrypted += decipher.final('utf8');
|
| 131 |
+
return decrypted;
|
| 132 |
+
} catch (error) {
|
| 133 |
+
// Wrong PIN or corrupted data
|
| 134 |
+
return null;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Derive AES-256 key from PIN using PBKDF2
|
| 140 |
+
*/
|
| 141 |
+
private deriveKeyFromPin(pin: string): Buffer {
|
| 142 |
+
// Use a fixed salt derived from the vault path for determinism
|
| 143 |
+
const salt = crypto.createHash('sha256').update(this.vaultPath).digest();
|
| 144 |
+
return crypto.pbkdf2Sync(pin, salt, 600000, 32, 'sha512');
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Store a PIN hash for verification
|
| 149 |
+
*/
|
| 150 |
+
storePinHash(pin: string): void {
|
| 151 |
+
const salt = crypto.randomBytes(32);
|
| 152 |
+
const hash = crypto.pbkdf2Sync(pin, salt, 600000, 64, 'sha512');
|
| 153 |
+
this.store('pin_hash', JSON.stringify({
|
| 154 |
+
hash: hash.toString('base64'),
|
| 155 |
+
salt: salt.toString('base64'),
|
| 156 |
+
}));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Verify a PIN against stored hash
|
| 161 |
+
*/
|
| 162 |
+
verifyPin(pin: string): boolean {
|
| 163 |
+
const stored = this.retrieve('pin_hash');
|
| 164 |
+
if (!stored) return false;
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
const { hash, salt } = JSON.parse(stored);
|
| 168 |
+
const derived = crypto.pbkdf2Sync(
|
| 169 |
+
pin,
|
| 170 |
+
Buffer.from(salt, 'base64'),
|
| 171 |
+
600000, 64, 'sha512'
|
| 172 |
+
);
|
| 173 |
+
return crypto.timingSafeEqual(derived, Buffer.from(hash, 'base64'));
|
| 174 |
+
} catch {
|
| 175 |
+
return false;
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* Wipe all vault contents securely
|
| 181 |
+
*/
|
| 182 |
+
wipeAll(): void {
|
| 183 |
+
this.vault = {};
|
| 184 |
+
this.saveVault();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// ── Private helpers ──
|
| 188 |
+
|
| 189 |
+
private loadVault(): void {
|
| 190 |
+
try {
|
| 191 |
+
if (fs.existsSync(this.vaultPath)) {
|
| 192 |
+
const raw = fs.readFileSync(this.vaultPath, 'utf8');
|
| 193 |
+
this.vault = JSON.parse(raw);
|
| 194 |
+
}
|
| 195 |
+
} catch {
|
| 196 |
+
this.vault = {};
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
private saveVault(): void {
|
| 201 |
+
const dir = path.dirname(this.vaultPath);
|
| 202 |
+
if (!fs.existsSync(dir)) {
|
| 203 |
+
fs.mkdirSync(dir, { recursive: true });
|
| 204 |
+
}
|
| 205 |
+
fs.writeFileSync(this.vaultPath, JSON.stringify(this.vault, null, 2), {
|
| 206 |
+
mode: 0o600, // Owner read/write only
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
}
|
src/main/security/securityManager.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Security Manager
|
| 3 |
+
*
|
| 4 |
+
* Handles authentication (PIN + biometric), session management,
|
| 5 |
+
* and auto-lock. All sensitive operations require authentication.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { systemPreferences, BrowserWindow } from 'electron';
|
| 9 |
+
import { KeyVault } from './keyVault';
|
| 10 |
+
|
| 11 |
+
const AUTO_LOCK_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
| 12 |
+
const MAX_PIN_ATTEMPTS = 5;
|
| 13 |
+
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes after max attempts
|
| 14 |
+
|
| 15 |
+
export class SecurityManager {
|
| 16 |
+
private keyVault: KeyVault;
|
| 17 |
+
private mainWindow: BrowserWindow;
|
| 18 |
+
private autoLockTimer: NodeJS.Timeout | null = null;
|
| 19 |
+
private failedAttempts: number = 0;
|
| 20 |
+
private lockoutUntil: number = 0;
|
| 21 |
+
|
| 22 |
+
constructor(keyVault: KeyVault, mainWindow: BrowserWindow) {
|
| 23 |
+
this.keyVault = keyVault;
|
| 24 |
+
this.mainWindow = mainWindow;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Check if biometric authentication is available
|
| 29 |
+
*/
|
| 30 |
+
isBiometricAvailable(): boolean {
|
| 31 |
+
if (process.platform === 'darwin') {
|
| 32 |
+
return systemPreferences.canPromptTouchID();
|
| 33 |
+
}
|
| 34 |
+
// Windows Hello and Linux PAM require native addons
|
| 35 |
+
// For hackathon: return false on non-macOS, use PIN fallback
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Prompt biometric authentication (TouchID on macOS)
|
| 41 |
+
*/
|
| 42 |
+
async promptBiometric(reason?: string): Promise<{ success: boolean; error?: string }> {
|
| 43 |
+
if (process.platform === 'darwin') {
|
| 44 |
+
try {
|
| 45 |
+
if (!systemPreferences.canPromptTouchID()) {
|
| 46 |
+
return { success: false, error: 'TouchID not available' };
|
| 47 |
+
}
|
| 48 |
+
await systemPreferences.promptTouchID(reason ?? 'Authenticate to SolVox');
|
| 49 |
+
this.resetAutoLockTimer();
|
| 50 |
+
return { success: true };
|
| 51 |
+
} catch (error: any) {
|
| 52 |
+
return { success: false, error: error.message };
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return { success: false, error: 'Biometric not supported on this platform' };
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Set a new PIN
|
| 61 |
+
*/
|
| 62 |
+
async setPin(pin: string): Promise<void> {
|
| 63 |
+
if (pin.length < 6) {
|
| 64 |
+
throw new Error('PIN must be at least 6 characters');
|
| 65 |
+
}
|
| 66 |
+
if (!/^\d+$/.test(pin)) {
|
| 67 |
+
throw new Error('PIN must contain only digits');
|
| 68 |
+
}
|
| 69 |
+
this.keyVault.storePinHash(pin);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Verify PIN and unlock wallet
|
| 74 |
+
*/
|
| 75 |
+
async unlockWithPin(pin: string): Promise<{ success: boolean; error?: string; remainingAttempts?: number }> {
|
| 76 |
+
// Check lockout
|
| 77 |
+
if (Date.now() < this.lockoutUntil) {
|
| 78 |
+
const remaining = Math.ceil((this.lockoutUntil - Date.now()) / 60000);
|
| 79 |
+
return {
|
| 80 |
+
success: false,
|
| 81 |
+
error: `Too many failed attempts. Try again in ${remaining} minutes.`,
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const valid = this.keyVault.verifyPin(pin);
|
| 86 |
+
if (!valid) {
|
| 87 |
+
this.failedAttempts++;
|
| 88 |
+
if (this.failedAttempts >= MAX_PIN_ATTEMPTS) {
|
| 89 |
+
this.lockoutUntil = Date.now() + LOCKOUT_DURATION;
|
| 90 |
+
this.failedAttempts = 0;
|
| 91 |
+
return {
|
| 92 |
+
success: false,
|
| 93 |
+
error: `Too many failed attempts. Locked out for 15 minutes.`,
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
return {
|
| 97 |
+
success: false,
|
| 98 |
+
error: 'Incorrect PIN',
|
| 99 |
+
remainingAttempts: MAX_PIN_ATTEMPTS - this.failedAttempts,
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Success — reset counters
|
| 104 |
+
this.failedAttempts = 0;
|
| 105 |
+
this.lockoutUntil = 0;
|
| 106 |
+
this.resetAutoLockTimer();
|
| 107 |
+
return { success: true };
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Start auto-lock timer — locks wallet after inactivity
|
| 112 |
+
*/
|
| 113 |
+
startAutoLockTimer(): void {
|
| 114 |
+
this.resetAutoLockTimer();
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Reset auto-lock timer (call on every user interaction)
|
| 119 |
+
*/
|
| 120 |
+
resetAutoLockTimer(): void {
|
| 121 |
+
if (this.autoLockTimer) {
|
| 122 |
+
clearTimeout(this.autoLockTimer);
|
| 123 |
+
}
|
| 124 |
+
this.autoLockTimer = setTimeout(() => {
|
| 125 |
+
console.log('[Security] Auto-locking wallet due to inactivity');
|
| 126 |
+
this.mainWindow.webContents.send('auth:locked');
|
| 127 |
+
}, AUTO_LOCK_TIMEOUT);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Check if PIN has been set up
|
| 132 |
+
*/
|
| 133 |
+
isPinConfigured(): boolean {
|
| 134 |
+
return this.keyVault.has('pin_hash');
|
| 135 |
+
}
|
| 136 |
+
}
|
src/main/security/transactionGuard.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Transaction Guard
|
| 3 |
+
*
|
| 4 |
+
* Security layer that enforces:
|
| 5 |
+
* - Per-transaction amount limits
|
| 6 |
+
* - Daily transaction volume limits
|
| 7 |
+
* - Address whitelisting (optional)
|
| 8 |
+
* - Anomaly detection (unusual amounts, new addresses, rapid transactions)
|
| 9 |
+
* - Transaction velocity limiting
|
| 10 |
+
* - Complete audit trail
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { KeyVault } from './keyVault';
|
| 14 |
+
import * as crypto from 'crypto';
|
| 15 |
+
|
| 16 |
+
export interface SecuritySettings {
|
| 17 |
+
maxSingleTx: number; // Max single transaction (in token units)
|
| 18 |
+
maxDailyVolume: number; // Max daily volume
|
| 19 |
+
whitelistEnabled: boolean; // Require whitelisted addresses
|
| 20 |
+
anomalyDetection: boolean; // Enable AI anomaly detection
|
| 21 |
+
requireConfirmation: boolean; // Require explicit confirmation for all txs
|
| 22 |
+
velocityLimit: number; // Max transactions per hour
|
| 23 |
+
cooldownMinutes: number; // Cooldown between large transactions
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface WhitelistEntry {
|
| 27 |
+
address: string;
|
| 28 |
+
label: string;
|
| 29 |
+
addedAt: string;
|
| 30 |
+
lastUsed?: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export interface TransactionRecord {
|
| 34 |
+
id: string;
|
| 35 |
+
amount: number;
|
| 36 |
+
token: string;
|
| 37 |
+
to: string;
|
| 38 |
+
timestamp: number;
|
| 39 |
+
anomalyScore: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface AnomalyLog {
|
| 43 |
+
id: string;
|
| 44 |
+
type: 'high_amount' | 'new_address' | 'rapid_tx' | 'unusual_time' | 'volume_spike';
|
| 45 |
+
description: string;
|
| 46 |
+
severity: 'low' | 'medium' | 'high';
|
| 47 |
+
timestamp: string;
|
| 48 |
+
transactionId?: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const DEFAULT_SETTINGS: SecuritySettings = {
|
| 52 |
+
maxSingleTx: 1000,
|
| 53 |
+
maxDailyVolume: 5000,
|
| 54 |
+
whitelistEnabled: false,
|
| 55 |
+
anomalyDetection: true,
|
| 56 |
+
requireConfirmation: true,
|
| 57 |
+
velocityLimit: 10,
|
| 58 |
+
cooldownMinutes: 1,
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
export class TransactionGuard {
|
| 62 |
+
private keyVault: KeyVault;
|
| 63 |
+
private settings: SecuritySettings;
|
| 64 |
+
private whitelist: WhitelistEntry[] = [];
|
| 65 |
+
private txHistory: TransactionRecord[] = [];
|
| 66 |
+
private anomalyLog: AnomalyLog[] = [];
|
| 67 |
+
|
| 68 |
+
constructor(keyVault: KeyVault) {
|
| 69 |
+
this.keyVault = keyVault;
|
| 70 |
+
this.settings = this.loadSettings();
|
| 71 |
+
this.whitelist = this.loadWhitelist();
|
| 72 |
+
this.txHistory = this.loadTxHistory();
|
| 73 |
+
this.anomalyLog = this.loadAnomalyLog();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Validate a Solana address format
|
| 78 |
+
*/
|
| 79 |
+
validateAddress(address: string): boolean {
|
| 80 |
+
// Base58 check: Solana addresses are 32-44 chars, base58 alphabet
|
| 81 |
+
return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Validate transaction amount
|
| 86 |
+
*/
|
| 87 |
+
validateAmount(amount: number): boolean {
|
| 88 |
+
return typeof amount === 'number' && amount > 0 && amount < 1e12 && isFinite(amount);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Check if a transaction is within configured limits
|
| 93 |
+
*/
|
| 94 |
+
async checkTransactionLimits(
|
| 95 |
+
amount: number,
|
| 96 |
+
token: string
|
| 97 |
+
): Promise<{ allowed: boolean; reason?: string; warnings?: string[] }> {
|
| 98 |
+
const warnings: string[] = [];
|
| 99 |
+
|
| 100 |
+
// Single transaction limit
|
| 101 |
+
if (amount > this.settings.maxSingleTx) {
|
| 102 |
+
return {
|
| 103 |
+
allowed: false,
|
| 104 |
+
reason: `Amount ${amount} ${token} exceeds single transaction limit of ${this.settings.maxSingleTx} ${token}`,
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Daily volume limit
|
| 109 |
+
const dailyVolume = this.getDailyVolume(token);
|
| 110 |
+
if (dailyVolume + amount > this.settings.maxDailyVolume) {
|
| 111 |
+
return {
|
| 112 |
+
allowed: false,
|
| 113 |
+
reason: `This transaction would exceed daily volume limit. Current: ${dailyVolume.toFixed(2)}, Limit: ${this.settings.maxDailyVolume} ${token}`,
|
| 114 |
+
};
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Velocity limit — max transactions per hour
|
| 118 |
+
const hourlyCount = this.getHourlyTransactionCount();
|
| 119 |
+
if (hourlyCount >= this.settings.velocityLimit) {
|
| 120 |
+
return {
|
| 121 |
+
allowed: false,
|
| 122 |
+
reason: `Transaction velocity limit reached (${this.settings.velocityLimit}/hour). Please wait.`,
|
| 123 |
+
};
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Cooldown between transactions
|
| 127 |
+
const lastTx = this.txHistory[this.txHistory.length - 1];
|
| 128 |
+
if (lastTx) {
|
| 129 |
+
const elapsed = (Date.now() - lastTx.timestamp) / 60000;
|
| 130 |
+
if (elapsed < this.settings.cooldownMinutes) {
|
| 131 |
+
return {
|
| 132 |
+
allowed: false,
|
| 133 |
+
reason: `Cooldown active. Please wait ${Math.ceil(this.settings.cooldownMinutes - elapsed)} minute(s).`,
|
| 134 |
+
};
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Anomaly detection
|
| 139 |
+
if (this.settings.anomalyDetection) {
|
| 140 |
+
const anomalies = this.detectAnomalies(amount, token);
|
| 141 |
+
warnings.push(...anomalies.map(a => a.description));
|
| 142 |
+
|
| 143 |
+
// High severity anomalies block the transaction
|
| 144 |
+
const highSeverity = anomalies.filter(a => a.severity === 'high');
|
| 145 |
+
if (highSeverity.length > 0) {
|
| 146 |
+
return {
|
| 147 |
+
allowed: false,
|
| 148 |
+
reason: `Transaction blocked by anomaly detection: ${highSeverity[0].description}`,
|
| 149 |
+
warnings,
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return { allowed: true, warnings: warnings.length > 0 ? warnings : undefined };
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Check if address is on the whitelist
|
| 159 |
+
*/
|
| 160 |
+
checkWhitelist(address: string): { allowed: boolean; reason?: string } {
|
| 161 |
+
if (!this.settings.whitelistEnabled) {
|
| 162 |
+
return { allowed: true };
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const entry = this.whitelist.find(w => w.address === address);
|
| 166 |
+
if (!entry) {
|
| 167 |
+
return {
|
| 168 |
+
allowed: false,
|
| 169 |
+
reason: `Address ${address.slice(0, 8)}...${address.slice(-4)} is not whitelisted. Add it in Security Settings first.`,
|
| 170 |
+
};
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Update last used
|
| 174 |
+
entry.lastUsed = new Date().toISOString();
|
| 175 |
+
this.saveWhitelist();
|
| 176 |
+
return { allowed: true };
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* Detect anomalous transaction patterns
|
| 181 |
+
*/
|
| 182 |
+
private detectAnomalies(amount: number, token: string): AnomalyLog[] {
|
| 183 |
+
const anomalies: AnomalyLog[] = [];
|
| 184 |
+
const now = new Date();
|
| 185 |
+
|
| 186 |
+
// 1. High amount anomaly — transaction significantly above average
|
| 187 |
+
const avgAmount = this.getAverageTransactionAmount(token);
|
| 188 |
+
if (avgAmount > 0 && amount > avgAmount * 5) {
|
| 189 |
+
const anomaly: AnomalyLog = {
|
| 190 |
+
id: crypto.randomUUID(),
|
| 191 |
+
type: 'high_amount',
|
| 192 |
+
description: `Amount ${amount} ${token} is ${(amount / avgAmount).toFixed(1)}x your average transaction (${avgAmount.toFixed(2)} ${token})`,
|
| 193 |
+
severity: amount > avgAmount * 10 ? 'high' : 'medium',
|
| 194 |
+
timestamp: now.toISOString(),
|
| 195 |
+
};
|
| 196 |
+
anomalies.push(anomaly);
|
| 197 |
+
this.anomalyLog.push(anomaly);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// 2. Unusual time anomaly — transactions at odd hours
|
| 201 |
+
const hour = now.getHours();
|
| 202 |
+
if (hour >= 1 && hour <= 5) {
|
| 203 |
+
const anomaly: AnomalyLog = {
|
| 204 |
+
id: crypto.randomUUID(),
|
| 205 |
+
type: 'unusual_time',
|
| 206 |
+
description: `Transaction at unusual hour (${hour}:00). Verify this is intentional.`,
|
| 207 |
+
severity: 'low',
|
| 208 |
+
timestamp: now.toISOString(),
|
| 209 |
+
};
|
| 210 |
+
anomalies.push(anomaly);
|
| 211 |
+
this.anomalyLog.push(anomaly);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// 3. Volume spike — daily volume suddenly much higher than usual
|
| 215 |
+
const dailyVolume = this.getDailyVolume(token);
|
| 216 |
+
const avgDailyVolume = this.getAverageDailyVolume(token);
|
| 217 |
+
if (avgDailyVolume > 0 && dailyVolume + amount > avgDailyVolume * 3) {
|
| 218 |
+
const anomaly: AnomalyLog = {
|
| 219 |
+
id: crypto.randomUUID(),
|
| 220 |
+
type: 'volume_spike',
|
| 221 |
+
description: `Daily volume spike detected. Today: ${(dailyVolume + amount).toFixed(2)} ${token} vs average ${avgDailyVolume.toFixed(2)} ${token}`,
|
| 222 |
+
severity: 'medium',
|
| 223 |
+
timestamp: now.toISOString(),
|
| 224 |
+
};
|
| 225 |
+
anomalies.push(anomaly);
|
| 226 |
+
this.anomalyLog.push(anomaly);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// 4. Rapid transactions — multiple txs in quick succession
|
| 230 |
+
const recentTxCount = this.txHistory.filter(
|
| 231 |
+
tx => Date.now() - tx.timestamp < 10 * 60 * 1000 // last 10 minutes
|
| 232 |
+
).length;
|
| 233 |
+
if (recentTxCount >= 5) {
|
| 234 |
+
const anomaly: AnomalyLog = {
|
| 235 |
+
id: crypto.randomUUID(),
|
| 236 |
+
type: 'rapid_tx',
|
| 237 |
+
description: `${recentTxCount} transactions in last 10 minutes. Possible automated activity.`,
|
| 238 |
+
severity: recentTxCount >= 8 ? 'high' : 'medium',
|
| 239 |
+
timestamp: now.toISOString(),
|
| 240 |
+
};
|
| 241 |
+
anomalies.push(anomaly);
|
| 242 |
+
this.anomalyLog.push(anomaly);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
if (anomalies.length > 0) {
|
| 246 |
+
this.saveAnomalyLog();
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
return anomalies;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* Record a completed transaction
|
| 254 |
+
*/
|
| 255 |
+
async recordTransaction(amount: number, token: string, to: string): Promise<void> {
|
| 256 |
+
const record: TransactionRecord = {
|
| 257 |
+
id: crypto.randomUUID(),
|
| 258 |
+
amount,
|
| 259 |
+
token,
|
| 260 |
+
to,
|
| 261 |
+
timestamp: Date.now(),
|
| 262 |
+
anomalyScore: 0,
|
| 263 |
+
};
|
| 264 |
+
this.txHistory.push(record);
|
| 265 |
+
|
| 266 |
+
// Keep last 1000 transactions
|
| 267 |
+
if (this.txHistory.length > 1000) {
|
| 268 |
+
this.txHistory = this.txHistory.slice(-1000);
|
| 269 |
+
}
|
| 270 |
+
this.saveTxHistory();
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// ── Whitelist Management ──
|
| 274 |
+
|
| 275 |
+
addToWhitelist(address: string, label: string): { success: boolean; error?: string } {
|
| 276 |
+
if (!this.validateAddress(address)) {
|
| 277 |
+
return { success: false, error: 'Invalid address format' };
|
| 278 |
+
}
|
| 279 |
+
if (this.whitelist.find(w => w.address === address)) {
|
| 280 |
+
return { success: false, error: 'Address already whitelisted' };
|
| 281 |
+
}
|
| 282 |
+
this.whitelist.push({
|
| 283 |
+
address,
|
| 284 |
+
label,
|
| 285 |
+
addedAt: new Date().toISOString(),
|
| 286 |
+
});
|
| 287 |
+
this.saveWhitelist();
|
| 288 |
+
return { success: true };
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
removeFromWhitelist(address: string): { success: boolean } {
|
| 292 |
+
this.whitelist = this.whitelist.filter(w => w.address !== address);
|
| 293 |
+
this.saveWhitelist();
|
| 294 |
+
return { success: true };
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
getWhitelist(): WhitelistEntry[] {
|
| 298 |
+
return [...this.whitelist];
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// ── Settings Management ──
|
| 302 |
+
|
| 303 |
+
getSettings(): SecuritySettings {
|
| 304 |
+
return { ...this.settings };
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
updateSettings(updates: Partial<SecuritySettings>): { success: boolean } {
|
| 308 |
+
this.settings = { ...this.settings, ...updates };
|
| 309 |
+
this.saveSettings();
|
| 310 |
+
return { success: true };
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
getAnomalyLog(): AnomalyLog[] {
|
| 314 |
+
return [...this.anomalyLog].reverse().slice(0, 50);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// ── Analytics Helpers ──
|
| 318 |
+
|
| 319 |
+
private getDailyVolume(token: string): number {
|
| 320 |
+
const dayStart = new Date();
|
| 321 |
+
dayStart.setHours(0, 0, 0, 0);
|
| 322 |
+
return this.txHistory
|
| 323 |
+
.filter(tx => tx.token === token && tx.timestamp >= dayStart.getTime())
|
| 324 |
+
.reduce((sum, tx) => sum + tx.amount, 0);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
private getHourlyTransactionCount(): number {
|
| 328 |
+
const hourAgo = Date.now() - 60 * 60 * 1000;
|
| 329 |
+
return this.txHistory.filter(tx => tx.timestamp >= hourAgo).length;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
private getAverageTransactionAmount(token: string): number {
|
| 333 |
+
const relevant = this.txHistory.filter(tx => tx.token === token);
|
| 334 |
+
if (relevant.length === 0) return 0;
|
| 335 |
+
return relevant.reduce((sum, tx) => sum + tx.amount, 0) / relevant.length;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
private getAverageDailyVolume(token: string): number {
|
| 339 |
+
if (this.txHistory.length === 0) return 0;
|
| 340 |
+
const firstTx = this.txHistory[0].timestamp;
|
| 341 |
+
const days = Math.max(1, (Date.now() - firstTx) / (24 * 60 * 60 * 1000));
|
| 342 |
+
const totalVolume = this.txHistory
|
| 343 |
+
.filter(tx => tx.token === token)
|
| 344 |
+
.reduce((sum, tx) => sum + tx.amount, 0);
|
| 345 |
+
return totalVolume / days;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
// ── Persistence (using keyVault for encrypted storage) ──
|
| 349 |
+
|
| 350 |
+
private loadSettings(): SecuritySettings {
|
| 351 |
+
const raw = this.keyVault.retrieve('security_settings');
|
| 352 |
+
if (raw) {
|
| 353 |
+
try {
|
| 354 |
+
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
| 355 |
+
} catch {
|
| 356 |
+
return { ...DEFAULT_SETTINGS };
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
return { ...DEFAULT_SETTINGS };
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
private saveSettings(): void {
|
| 363 |
+
this.keyVault.store('security_settings', JSON.stringify(this.settings));
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
private loadWhitelist(): WhitelistEntry[] {
|
| 367 |
+
const raw = this.keyVault.retrieve('whitelist');
|
| 368 |
+
if (raw) {
|
| 369 |
+
try { return JSON.parse(raw); } catch { return []; }
|
| 370 |
+
}
|
| 371 |
+
return [];
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
private saveWhitelist(): void {
|
| 375 |
+
this.keyVault.store('whitelist', JSON.stringify(this.whitelist));
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
private loadTxHistory(): TransactionRecord[] {
|
| 379 |
+
const raw = this.keyVault.retrieve('tx_history');
|
| 380 |
+
if (raw) {
|
| 381 |
+
try { return JSON.parse(raw); } catch { return []; }
|
| 382 |
+
}
|
| 383 |
+
return [];
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
private saveTxHistory(): void {
|
| 387 |
+
this.keyVault.store('tx_history', JSON.stringify(this.txHistory));
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
private loadAnomalyLog(): AnomalyLog[] {
|
| 391 |
+
const raw = this.keyVault.retrieve('anomaly_log');
|
| 392 |
+
if (raw) {
|
| 393 |
+
try { return JSON.parse(raw); } catch { return []; }
|
| 394 |
+
}
|
| 395 |
+
return [];
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
private saveAnomalyLog(): void {
|
| 399 |
+
this.keyVault.store('anomaly_log', JSON.stringify(this.anomalyLog));
|
| 400 |
+
}
|
| 401 |
+
}
|
src/main/wallet/walletService.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox — Wallet Service
|
| 3 |
+
*
|
| 4 |
+
* Non-custodial Solana wallet with SOL + USDT (SPL) support.
|
| 5 |
+
* Private keys are encrypted at rest (via KeyVault) and only
|
| 6 |
+
* decrypted into memory when the wallet is unlocked.
|
| 7 |
+
*
|
| 8 |
+
* SECURITY:
|
| 9 |
+
* - Keypair lives ONLY in main process memory
|
| 10 |
+
* - Auto-zeroed on lock
|
| 11 |
+
* - BIP39 mnemonic → ed25519 derivation (Solana standard path)
|
| 12 |
+
* - Never serialized in plaintext
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import {
|
| 16 |
+
Connection,
|
| 17 |
+
Keypair,
|
| 18 |
+
PublicKey,
|
| 19 |
+
Transaction,
|
| 20 |
+
SystemProgram,
|
| 21 |
+
LAMPORTS_PER_SOL,
|
| 22 |
+
sendAndConfirmTransaction,
|
| 23 |
+
clusterApiUrl,
|
| 24 |
+
ConfirmedSignatureInfo,
|
| 25 |
+
} from '@solana/web3.js';
|
| 26 |
+
import {
|
| 27 |
+
getOrCreateAssociatedTokenAccount,
|
| 28 |
+
createTransferInstruction,
|
| 29 |
+
getMint,
|
| 30 |
+
TOKEN_PROGRAM_ID,
|
| 31 |
+
} from '@solana/spl-token';
|
| 32 |
+
import * as bip39 from 'bip39';
|
| 33 |
+
import { derivePath } from 'ed25519-hd-key';
|
| 34 |
+
import { KeyVault } from '../security/keyVault';
|
| 35 |
+
|
| 36 |
+
const SOLANA_DERIVATION_PATH = "m/44'/501'/0'/0'";
|
| 37 |
+
const USDT_MINT_MAINNET = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB';
|
| 38 |
+
const USDT_MINT_DEVNET = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; // Devnet test USDT
|
| 39 |
+
|
| 40 |
+
// RPC endpoints with fallbacks
|
| 41 |
+
const RPC_ENDPOINTS = {
|
| 42 |
+
'mainnet-beta': [
|
| 43 |
+
'https://api.mainnet-beta.solana.com',
|
| 44 |
+
'https://solana-mainnet.rpc.extrnode.com',
|
| 45 |
+
],
|
| 46 |
+
'devnet': [
|
| 47 |
+
'https://api.devnet.solana.com',
|
| 48 |
+
],
|
| 49 |
+
'testnet': [
|
| 50 |
+
'https://api.testnet.solana.com',
|
| 51 |
+
],
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
export type NetworkType = 'mainnet-beta' | 'devnet' | 'testnet';
|
| 55 |
+
|
| 56 |
+
export interface WalletBalance {
|
| 57 |
+
sol: number;
|
| 58 |
+
usdt: number;
|
| 59 |
+
lamports: number;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export interface TransactionHistoryEntry {
|
| 63 |
+
signature: string;
|
| 64 |
+
timestamp: number | null;
|
| 65 |
+
type: 'send' | 'receive' | 'unknown';
|
| 66 |
+
amount: number;
|
| 67 |
+
fee: number;
|
| 68 |
+
status: 'success' | 'failed';
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export class WalletService {
|
| 72 |
+
private keyVault: KeyVault;
|
| 73 |
+
private connection: Connection | null = null;
|
| 74 |
+
private keypair: Keypair | null = null;
|
| 75 |
+
private network: NetworkType = 'devnet'; // Default to devnet for safety
|
| 76 |
+
private publicKeyStr: string | null = null;
|
| 77 |
+
|
| 78 |
+
constructor(keyVault: KeyVault) {
|
| 79 |
+
this.keyVault = keyVault;
|
| 80 |
+
this.initConnection();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
private initConnection(): void {
|
| 84 |
+
const endpoints = RPC_ENDPOINTS[this.network];
|
| 85 |
+
this.connection = new Connection(endpoints[0], {
|
| 86 |
+
commitment: 'confirmed',
|
| 87 |
+
confirmTransactionInitialTimeout: 60000,
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Create a new wallet with fresh mnemonic
|
| 93 |
+
* Returns ONLY the public key — mnemonic stored encrypted in vault
|
| 94 |
+
*/
|
| 95 |
+
async createWallet(): Promise<{ publicKey: string; mnemonicWords: number }> {
|
| 96 |
+
const mnemonic = bip39.generateMnemonic(256); // 24 words
|
| 97 |
+
const seed = await bip39.mnemonicToSeed(mnemonic);
|
| 98 |
+
const { key } = derivePath(SOLANA_DERIVATION_PATH, seed.toString('hex'));
|
| 99 |
+
|
| 100 |
+
this.keypair = Keypair.fromSeed(Uint8Array.from(key));
|
| 101 |
+
this.publicKeyStr = this.keypair.publicKey.toBase58();
|
| 102 |
+
|
| 103 |
+
// Store mnemonic encrypted with PIN (set during onboarding)
|
| 104 |
+
// For now store with safeStorage; PIN encryption added at setPin time
|
| 105 |
+
this.keyVault.store('mnemonic', mnemonic);
|
| 106 |
+
this.keyVault.store('pubkey', this.publicKeyStr);
|
| 107 |
+
this.keyVault.store('network', this.network);
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
publicKey: this.publicKeyStr,
|
| 111 |
+
mnemonicWords: mnemonic.split(' ').length,
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Import wallet from mnemonic phrase
|
| 117 |
+
*/
|
| 118 |
+
async importFromMnemonic(mnemonic: string): Promise<{ publicKey: string }> {
|
| 119 |
+
if (!bip39.validateMnemonic(mnemonic)) {
|
| 120 |
+
throw new Error('Invalid mnemonic phrase');
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const seed = await bip39.mnemonicToSeed(mnemonic);
|
| 124 |
+
const { key } = derivePath(SOLANA_DERIVATION_PATH, seed.toString('hex'));
|
| 125 |
+
|
| 126 |
+
this.keypair = Keypair.fromSeed(Uint8Array.from(key));
|
| 127 |
+
this.publicKeyStr = this.keypair.publicKey.toBase58();
|
| 128 |
+
|
| 129 |
+
this.keyVault.store('mnemonic', mnemonic);
|
| 130 |
+
this.keyVault.store('pubkey', this.publicKeyStr);
|
| 131 |
+
|
| 132 |
+
return { publicKey: this.publicKeyStr };
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Unlock wallet — loads keypair into memory
|
| 137 |
+
*/
|
| 138 |
+
async unlock(pin: string): Promise<void> {
|
| 139 |
+
// Try PIN-encrypted mnemonic first, fall back to safeStorage-only
|
| 140 |
+
let mnemonic = this.keyVault.retrieveWithPin('mnemonic_pin', pin);
|
| 141 |
+
if (!mnemonic) {
|
| 142 |
+
mnemonic = this.keyVault.retrieve('mnemonic');
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (!mnemonic) {
|
| 146 |
+
throw new Error('No wallet found. Please create or import a wallet.');
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const seed = await bip39.mnemonicToSeed(mnemonic);
|
| 150 |
+
const { key } = derivePath(SOLANA_DERIVATION_PATH, seed.toString('hex'));
|
| 151 |
+
this.keypair = Keypair.fromSeed(Uint8Array.from(key));
|
| 152 |
+
this.publicKeyStr = this.keypair.publicKey.toBase58();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* Lock wallet — zero out keypair from memory
|
| 157 |
+
*/
|
| 158 |
+
lock(): void {
|
| 159 |
+
if (this.keypair) {
|
| 160 |
+
// Best-effort zeroing of the secret key buffer
|
| 161 |
+
const sk = this.keypair.secretKey;
|
| 162 |
+
for (let i = 0; i < sk.length; i++) {
|
| 163 |
+
sk[i] = 0;
|
| 164 |
+
}
|
| 165 |
+
this.keypair = null;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/**
|
| 170 |
+
* Check if wallet is unlocked (keypair in memory)
|
| 171 |
+
*/
|
| 172 |
+
isUnlocked(): boolean {
|
| 173 |
+
return this.keypair !== null;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* Check if a wallet has been created/imported
|
| 178 |
+
*/
|
| 179 |
+
walletExists(): boolean {
|
| 180 |
+
return this.keyVault.has('pubkey');
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* Get public key (safe to expose)
|
| 185 |
+
*/
|
| 186 |
+
getPublicKey(): string | null {
|
| 187 |
+
if (this.publicKeyStr) return this.publicKeyStr;
|
| 188 |
+
return this.keyVault.retrieve('pubkey');
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Get SOL + USDT balance
|
| 193 |
+
*/
|
| 194 |
+
async getBalance(): Promise<WalletBalance> {
|
| 195 |
+
const pubkey = this.getPublicKey();
|
| 196 |
+
if (!pubkey) throw new Error('No wallet configured');
|
| 197 |
+
|
| 198 |
+
const publicKey = new PublicKey(pubkey);
|
| 199 |
+
const lamports = await this.connection!.getBalance(publicKey);
|
| 200 |
+
|
| 201 |
+
let usdtBalance = 0;
|
| 202 |
+
try {
|
| 203 |
+
const usdtMint = this.getUSDTMint();
|
| 204 |
+
const tokenAccounts = await this.connection!.getTokenAccountsByOwner(
|
| 205 |
+
publicKey,
|
| 206 |
+
{ mint: new PublicKey(usdtMint) }
|
| 207 |
+
);
|
| 208 |
+
if (tokenAccounts.value.length > 0) {
|
| 209 |
+
const info = await this.connection!.getTokenAccountBalance(
|
| 210 |
+
tokenAccounts.value[0].pubkey
|
| 211 |
+
);
|
| 212 |
+
usdtBalance = Number(info.value.uiAmount) || 0;
|
| 213 |
+
}
|
| 214 |
+
} catch {
|
| 215 |
+
// USDT account may not exist yet
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
sol: lamports / LAMPORTS_PER_SOL,
|
| 220 |
+
usdt: usdtBalance,
|
| 221 |
+
lamports,
|
| 222 |
+
};
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/**
|
| 226 |
+
* Send SOL
|
| 227 |
+
*/
|
| 228 |
+
async sendSOL(to: string, amountSOL: number): Promise<string> {
|
| 229 |
+
this.requireUnlocked();
|
| 230 |
+
|
| 231 |
+
const transaction = new Transaction().add(
|
| 232 |
+
SystemProgram.transfer({
|
| 233 |
+
fromPubkey: this.keypair!.publicKey,
|
| 234 |
+
toPubkey: new PublicKey(to),
|
| 235 |
+
lamports: Math.round(amountSOL * LAMPORTS_PER_SOL),
|
| 236 |
+
})
|
| 237 |
+
);
|
| 238 |
+
|
| 239 |
+
const signature = await sendAndConfirmTransaction(
|
| 240 |
+
this.connection!,
|
| 241 |
+
transaction,
|
| 242 |
+
[this.keypair!]
|
| 243 |
+
);
|
| 244 |
+
|
| 245 |
+
return signature;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Send USDT (SPL Token)
|
| 250 |
+
*/
|
| 251 |
+
async sendUSDT(to: string, amount: number): Promise<string> {
|
| 252 |
+
this.requireUnlocked();
|
| 253 |
+
|
| 254 |
+
const usdtMint = new PublicKey(this.getUSDTMint());
|
| 255 |
+
const mintInfo = await getMint(this.connection!, usdtMint);
|
| 256 |
+
const decimals = mintInfo.decimals; // USDT = 6 decimals
|
| 257 |
+
|
| 258 |
+
// Get or create associated token accounts
|
| 259 |
+
const fromTokenAccount = await getOrCreateAssociatedTokenAccount(
|
| 260 |
+
this.connection!,
|
| 261 |
+
this.keypair!,
|
| 262 |
+
usdtMint,
|
| 263 |
+
this.keypair!.publicKey
|
| 264 |
+
);
|
| 265 |
+
|
| 266 |
+
const toTokenAccount = await getOrCreateAssociatedTokenAccount(
|
| 267 |
+
this.connection!,
|
| 268 |
+
this.keypair!,
|
| 269 |
+
usdtMint,
|
| 270 |
+
new PublicKey(to)
|
| 271 |
+
);
|
| 272 |
+
|
| 273 |
+
const transaction = new Transaction().add(
|
| 274 |
+
createTransferInstruction(
|
| 275 |
+
fromTokenAccount.address,
|
| 276 |
+
toTokenAccount.address,
|
| 277 |
+
this.keypair!.publicKey,
|
| 278 |
+
BigInt(Math.round(amount * 10 ** decimals)),
|
| 279 |
+
[],
|
| 280 |
+
TOKEN_PROGRAM_ID
|
| 281 |
+
)
|
| 282 |
+
);
|
| 283 |
+
|
| 284 |
+
return sendAndConfirmTransaction(this.connection!, transaction, [this.keypair!]);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* Get recent transaction history
|
| 289 |
+
*/
|
| 290 |
+
async getTransactionHistory(limit: number = 10): Promise<TransactionHistoryEntry[]> {
|
| 291 |
+
const pubkey = this.getPublicKey();
|
| 292 |
+
if (!pubkey) throw new Error('No wallet configured');
|
| 293 |
+
|
| 294 |
+
const publicKey = new PublicKey(pubkey);
|
| 295 |
+
const signatures = await this.connection!.getSignaturesForAddress(publicKey, {
|
| 296 |
+
limit,
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
return signatures.map((sig: ConfirmedSignatureInfo) => ({
|
| 300 |
+
signature: sig.signature,
|
| 301 |
+
timestamp: sig.blockTime ? sig.blockTime * 1000 : null,
|
| 302 |
+
type: 'unknown' as const,
|
| 303 |
+
amount: 0,
|
| 304 |
+
fee: 0,
|
| 305 |
+
status: sig.err ? 'failed' as const : 'success' as const,
|
| 306 |
+
}));
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/**
|
| 310 |
+
* Switch network
|
| 311 |
+
*/
|
| 312 |
+
setNetwork(network: NetworkType): void {
|
| 313 |
+
this.network = network;
|
| 314 |
+
this.initConnection();
|
| 315 |
+
this.keyVault.store('network', network);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/**
|
| 319 |
+
* Get current network
|
| 320 |
+
*/
|
| 321 |
+
getNetwork(): NetworkType {
|
| 322 |
+
return this.network;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// ── Private helpers ──
|
| 326 |
+
|
| 327 |
+
private requireUnlocked(): void {
|
| 328 |
+
if (!this.keypair) {
|
| 329 |
+
throw new Error('Wallet is locked. Please unlock first.');
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
private getUSDTMint(): string {
|
| 334 |
+
return this.network === 'mainnet-beta' ? USDT_MINT_MAINNET : USDT_MINT_DEVNET;
|
| 335 |
+
}
|
| 336 |
+
}
|
src/renderer/App.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import './types';
|
| 3 |
+
import LockScreen from './pages/LockScreen';
|
| 4 |
+
import OnboardingScreen from './pages/OnboardingScreen';
|
| 5 |
+
import Dashboard from './pages/Dashboard';
|
| 6 |
+
import SendPage from './pages/SendPage';
|
| 7 |
+
import HistoryPage from './pages/HistoryPage';
|
| 8 |
+
import VoicePage from './pages/VoicePage';
|
| 9 |
+
import SecurityPage from './pages/SecurityPage';
|
| 10 |
+
import SettingsPage from './pages/SettingsPage';
|
| 11 |
+
import Sidebar from './components/Sidebar';
|
| 12 |
+
import TopBar from './components/TopBar';
|
| 13 |
+
|
| 14 |
+
type Page = 'dashboard' | 'send' | 'history' | 'voice' | 'security' | 'settings';
|
| 15 |
+
type AppState = 'loading' | 'onboarding' | 'locked' | 'unlocked';
|
| 16 |
+
|
| 17 |
+
export default function App() {
|
| 18 |
+
const [appState, setAppState] = useState<AppState>('loading');
|
| 19 |
+
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
| 20 |
+
const [publicKey, setPublicKey] = useState<string | null>(null);
|
| 21 |
+
const [balance, setBalance] = useState({ sol: 0, usdt: 0 });
|
| 22 |
+
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 23 |
+
|
| 24 |
+
// Check wallet state on mount
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
checkWalletState();
|
| 27 |
+
}, []);
|
| 28 |
+
|
| 29 |
+
// Listen for lock events
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (!window.solvox) return;
|
| 32 |
+
const cleanup = window.solvox.on.locked(() => {
|
| 33 |
+
setAppState('locked');
|
| 34 |
+
});
|
| 35 |
+
return cleanup;
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
const checkWalletState = async () => {
|
| 39 |
+
try {
|
| 40 |
+
if (!window.solvox) {
|
| 41 |
+
// Development mode — show dashboard
|
| 42 |
+
setAppState('unlocked');
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
const exists = await window.solvox.wallet.exists();
|
| 46 |
+
if (!exists) {
|
| 47 |
+
setAppState('onboarding');
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
const unlocked = await window.solvox.wallet.isUnlocked();
|
| 51 |
+
setAppState(unlocked ? 'unlocked' : 'locked');
|
| 52 |
+
if (unlocked) {
|
| 53 |
+
const pk = await window.solvox.wallet.getPublicKey();
|
| 54 |
+
setPublicKey(pk);
|
| 55 |
+
refreshBalance();
|
| 56 |
+
}
|
| 57 |
+
} catch {
|
| 58 |
+
setAppState('onboarding');
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const refreshBalance = async () => {
|
| 63 |
+
try {
|
| 64 |
+
if (!window.solvox) return;
|
| 65 |
+
const result = await window.solvox.wallet.getBalance();
|
| 66 |
+
if (result.success) {
|
| 67 |
+
setBalance({ sol: result.sol || 0, usdt: result.usdt || 0 });
|
| 68 |
+
}
|
| 69 |
+
} catch {}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleUnlock = async (pk: string) => {
|
| 73 |
+
setPublicKey(pk);
|
| 74 |
+
setAppState('unlocked');
|
| 75 |
+
refreshBalance();
|
| 76 |
+
// Initialize AI in background
|
| 77 |
+
if (window.solvox) {
|
| 78 |
+
window.solvox.ai.initialize().then(result => {
|
| 79 |
+
if (result.success) {
|
| 80 |
+
window.solvox.ai.getStatus().then(setAiStatus);
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const handleOnboardingComplete = (pk: string) => {
|
| 87 |
+
setPublicKey(pk);
|
| 88 |
+
setAppState('unlocked');
|
| 89 |
+
refreshBalance();
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handleLock = async () => {
|
| 93 |
+
if (window.solvox) {
|
| 94 |
+
await window.solvox.wallet.lock();
|
| 95 |
+
}
|
| 96 |
+
setAppState('locked');
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Render based on app state
|
| 100 |
+
if (appState === 'loading') {
|
| 101 |
+
return <LoadingScreen />;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (appState === 'onboarding') {
|
| 105 |
+
return <OnboardingScreen onComplete={handleOnboardingComplete} />;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (appState === 'locked') {
|
| 109 |
+
return <LockScreen onUnlock={handleUnlock} />;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const renderPage = () => {
|
| 113 |
+
switch (currentPage) {
|
| 114 |
+
case 'dashboard':
|
| 115 |
+
return <Dashboard balance={balance} publicKey={publicKey} onRefresh={refreshBalance} />;
|
| 116 |
+
case 'send':
|
| 117 |
+
return <SendPage balance={balance} onSent={refreshBalance} />;
|
| 118 |
+
case 'history':
|
| 119 |
+
return <HistoryPage />;
|
| 120 |
+
case 'voice':
|
| 121 |
+
return <VoicePage aiStatus={aiStatus} />;
|
| 122 |
+
case 'security':
|
| 123 |
+
return <SecurityPage />;
|
| 124 |
+
case 'settings':
|
| 125 |
+
return <SettingsPage onLock={handleLock} />;
|
| 126 |
+
default:
|
| 127 |
+
return <Dashboard balance={balance} publicKey={publicKey} onRefresh={refreshBalance} />;
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<div className="flex h-screen bg-sol-dark">
|
| 133 |
+
<Sidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
| 134 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 135 |
+
<TopBar
|
| 136 |
+
publicKey={publicKey}
|
| 137 |
+
balance={balance}
|
| 138 |
+
aiStatus={aiStatus}
|
| 139 |
+
onLock={handleLock}
|
| 140 |
+
/>
|
| 141 |
+
<main className="flex-1 overflow-y-auto p-6">
|
| 142 |
+
{renderPage()}
|
| 143 |
+
</main>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function LoadingScreen() {
|
| 150 |
+
return (
|
| 151 |
+
<div className="h-screen flex flex-col items-center justify-center bg-sol-dark">
|
| 152 |
+
<div className="text-6xl font-bold gradient-text mb-4">SolVox</div>
|
| 153 |
+
<div className="text-sol-muted text-lg">Initializing local AI engine...</div>
|
| 154 |
+
<div className="mt-8 flex space-x-2">
|
| 155 |
+
{[0, 1, 2, 3, 4].map(i => (
|
| 156 |
+
<div
|
| 157 |
+
key={i}
|
| 158 |
+
className="w-2 h-8 bg-sol-purple rounded-full waveform-bar"
|
| 159 |
+
style={{ '--delay': `${0.3 + i * 0.1}s`, '--max-height': '32px' } as any}
|
| 160 |
+
/>
|
| 161 |
+
))}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
);
|
| 165 |
+
}
|
src/renderer/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface SidebarProps {
|
| 4 |
+
currentPage: string;
|
| 5 |
+
onNavigate: (page: any) => void;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const navItems = [
|
| 9 |
+
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' },
|
| 10 |
+
{ id: 'voice', label: 'Voice AI', icon: '🎤' },
|
| 11 |
+
{ id: 'send', label: 'Send', icon: '📤' },
|
| 12 |
+
{ id: 'history', label: 'History', icon: '📋' },
|
| 13 |
+
{ id: 'security', label: 'Security', icon: '🛡️' },
|
| 14 |
+
{ id: 'settings', label: 'Settings', icon: '⚙️' },
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
export default function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
| 18 |
+
return (
|
| 19 |
+
<aside className="w-64 bg-sol-card border-r border-sol-border flex flex-col">
|
| 20 |
+
{/* Logo */}
|
| 21 |
+
<div className="p-6 border-b border-sol-border">
|
| 22 |
+
<h1 className="text-2xl font-bold gradient-text">SolVox</h1>
|
| 23 |
+
<p className="text-xs text-sol-muted mt-1">Voice-First AI Wallet</p>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
{/* Navigation */}
|
| 27 |
+
<nav className="flex-1 p-4 space-y-1">
|
| 28 |
+
{navItems.map(item => (
|
| 29 |
+
<button
|
| 30 |
+
key={item.id}
|
| 31 |
+
onClick={() => onNavigate(item.id)}
|
| 32 |
+
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200 ${
|
| 33 |
+
currentPage === item.id
|
| 34 |
+
? 'bg-sol-purple/20 text-sol-purple border border-sol-purple/30'
|
| 35 |
+
: 'text-sol-muted hover:bg-sol-border/30 hover:text-sol-text'
|
| 36 |
+
}`}
|
| 37 |
+
>
|
| 38 |
+
<span className="text-lg">{item.icon}</span>
|
| 39 |
+
<span className="font-medium text-sm">{item.label}</span>
|
| 40 |
+
</button>
|
| 41 |
+
))}
|
| 42 |
+
</nav>
|
| 43 |
+
|
| 44 |
+
{/* QVAC Badge */}
|
| 45 |
+
<div className="p-4 border-t border-sol-border">
|
| 46 |
+
<div className="glass rounded-xl p-4 text-center">
|
| 47 |
+
<div className="text-xs text-sol-muted mb-1">Powered by</div>
|
| 48 |
+
<div className="text-sm font-bold text-tether-green">QVAC SDK</div>
|
| 49 |
+
<div className="text-xs text-sol-muted mt-1">100% Local AI</div>
|
| 50 |
+
<div className="flex justify-center gap-1 mt-2">
|
| 51 |
+
<span className="w-2 h-2 rounded-full bg-sol-green animate-pulse" />
|
| 52 |
+
<span className="text-xs text-sol-green">All AI runs on-device</span>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
</aside>
|
| 57 |
+
);
|
| 58 |
+
}
|
src/renderer/components/TopBar.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface TopBarProps {
|
| 4 |
+
publicKey: string | null;
|
| 5 |
+
balance: { sol: number; usdt: number };
|
| 6 |
+
aiStatus: any;
|
| 7 |
+
onLock: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function TopBar({ publicKey, balance, aiStatus, onLock }: TopBarProps) {
|
| 11 |
+
const shortAddress = publicKey
|
| 12 |
+
? `${publicKey.slice(0, 6)}...${publicKey.slice(-4)}`
|
| 13 |
+
: '—';
|
| 14 |
+
|
| 15 |
+
const copyAddress = () => {
|
| 16 |
+
if (publicKey) {
|
| 17 |
+
navigator.clipboard.writeText(publicKey);
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<header className="h-16 border-b border-sol-border flex items-center justify-between px-6 bg-sol-card/50">
|
| 23 |
+
{/* Left: Wallet Address */}
|
| 24 |
+
<div className="flex items-center gap-4">
|
| 25 |
+
<button
|
| 26 |
+
onClick={copyAddress}
|
| 27 |
+
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-sol-dark/50 border border-sol-border hover:border-sol-purple transition-colors"
|
| 28 |
+
title="Click to copy address"
|
| 29 |
+
>
|
| 30 |
+
<span className="w-2 h-2 rounded-full bg-sol-green" />
|
| 31 |
+
<span className="text-sm font-mono text-sol-muted">{shortAddress}</span>
|
| 32 |
+
<span className="text-xs">📋</span>
|
| 33 |
+
</button>
|
| 34 |
+
<div className="text-xs text-sol-muted">Devnet</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{/* Center: Balance */}
|
| 38 |
+
<div className="flex items-center gap-6">
|
| 39 |
+
<div className="text-center">
|
| 40 |
+
<div className="text-xs text-sol-muted">SOL</div>
|
| 41 |
+
<div className="text-sm font-bold text-sol-purple">
|
| 42 |
+
{balance.sol.toFixed(4)}
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="w-px h-8 bg-sol-border" />
|
| 46 |
+
<div className="text-center">
|
| 47 |
+
<div className="text-xs text-sol-muted">USDT</div>
|
| 48 |
+
<div className="text-sm font-bold text-tether-green">
|
| 49 |
+
{balance.usdt.toFixed(2)}
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
{/* Right: AI Status + Lock */}
|
| 55 |
+
<div className="flex items-center gap-3">
|
| 56 |
+
{/* AI Status Indicators */}
|
| 57 |
+
<div className="flex items-center gap-1.5">
|
| 58 |
+
<StatusDot active={aiStatus?.llm} label="LLM" />
|
| 59 |
+
<StatusDot active={aiStatus?.transcription} label="STT" />
|
| 60 |
+
<StatusDot active={aiStatus?.tts} label="TTS" />
|
| 61 |
+
<StatusDot active={aiStatus?.embed} label="EMB" />
|
| 62 |
+
<StatusDot active={aiStatus?.translation} label="NMT" />
|
| 63 |
+
<StatusDot active={aiStatus?.ocr} label="OCR" />
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<button
|
| 67 |
+
onClick={onLock}
|
| 68 |
+
className="px-3 py-1.5 rounded-lg bg-danger/10 text-danger text-sm hover:bg-danger/20 transition-colors"
|
| 69 |
+
>
|
| 70 |
+
🔒 Lock
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
</header>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function StatusDot({ active, label }: { active: boolean; label: string }) {
|
| 78 |
+
return (
|
| 79 |
+
<div className="flex flex-col items-center" title={`${label}: ${active ? 'Active' : 'Offline'}`}>
|
| 80 |
+
<div className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-sol-green' : 'bg-sol-muted/30'}`} />
|
| 81 |
+
<span className="text-[8px] text-sol-muted mt-0.5">{label}</span>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
}
|
src/renderer/index.css
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
* {
|
| 6 |
+
margin: 0;
|
| 7 |
+
padding: 0;
|
| 8 |
+
box-sizing: border-box;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
body {
|
| 12 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 13 |
+
background: #0E0E2C;
|
| 14 |
+
color: #E0E0FF;
|
| 15 |
+
overflow: hidden;
|
| 16 |
+
height: 100vh;
|
| 17 |
+
-webkit-app-region: drag;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
button, input, select, textarea, a {
|
| 21 |
+
-webkit-app-region: no-drag;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* Custom scrollbar */
|
| 25 |
+
::-webkit-scrollbar {
|
| 26 |
+
width: 6px;
|
| 27 |
+
}
|
| 28 |
+
::-webkit-scrollbar-track {
|
| 29 |
+
background: transparent;
|
| 30 |
+
}
|
| 31 |
+
::-webkit-scrollbar-thumb {
|
| 32 |
+
background: #2D2D5E;
|
| 33 |
+
border-radius: 3px;
|
| 34 |
+
}
|
| 35 |
+
::-webkit-scrollbar-thumb:hover {
|
| 36 |
+
background: #9945FF;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* Waveform animation bars */
|
| 40 |
+
.waveform-bar {
|
| 41 |
+
animation: waveform var(--delay, 0.5s) ease-in-out infinite alternate;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes waveform {
|
| 45 |
+
0% { height: 4px; }
|
| 46 |
+
100% { height: var(--max-height, 24px); }
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Glow effects */
|
| 50 |
+
.glow-purple {
|
| 51 |
+
box-shadow: 0 0 20px rgba(153, 69, 255, 0.3);
|
| 52 |
+
}
|
| 53 |
+
.glow-green {
|
| 54 |
+
box-shadow: 0 0 20px rgba(20, 241, 149, 0.3);
|
| 55 |
+
}
|
| 56 |
+
.glow-tether {
|
| 57 |
+
box-shadow: 0 0 20px rgba(38, 161, 123, 0.3);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* Gradient text */
|
| 61 |
+
.gradient-text {
|
| 62 |
+
background: linear-gradient(135deg, #9945FF, #14F195);
|
| 63 |
+
-webkit-background-clip: text;
|
| 64 |
+
-webkit-text-fill-color: transparent;
|
| 65 |
+
background-clip: text;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* Glass effect */
|
| 69 |
+
.glass {
|
| 70 |
+
background: rgba(26, 26, 62, 0.6);
|
| 71 |
+
backdrop-filter: blur(20px);
|
| 72 |
+
border: 1px solid rgba(45, 45, 94, 0.5);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Pulse recording indicator */
|
| 76 |
+
@keyframes recording-pulse {
|
| 77 |
+
0%, 100% { opacity: 1; transform: scale(1); }
|
| 78 |
+
50% { opacity: 0.5; transform: scale(1.1); }
|
| 79 |
+
}
|
| 80 |
+
.recording-pulse {
|
| 81 |
+
animation: recording-pulse 1.5s ease-in-out infinite;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Slide transitions */
|
| 85 |
+
.slide-enter {
|
| 86 |
+
animation: slide-up 0.3s ease-out;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
@keyframes slide-up {
|
| 90 |
+
from { transform: translateY(20px); opacity: 0; }
|
| 91 |
+
to { transform: translateY(0); opacity: 1; }
|
| 92 |
+
}
|
src/renderer/index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta http-equiv="Content-Security-Policy"
|
| 7 |
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.mainnet-beta.solana.com https://api.devnet.solana.com; object-src 'none'; base-uri 'self';">
|
| 8 |
+
<title>SolVox — Voice-First AI Wallet</title>
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body class="bg-sol-dark text-sol-text">
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="./main.tsx"></script>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
src/renderer/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
);
|
src/renderer/pages/Dashboard.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface DashboardProps {
|
| 4 |
+
balance: { sol: number; usdt: number };
|
| 5 |
+
publicKey: string | null;
|
| 6 |
+
onRefresh: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function Dashboard({ balance, publicKey, onRefresh }: DashboardProps) {
|
| 10 |
+
const [recentTxs, setRecentTxs] = useState<any[]>([]);
|
| 11 |
+
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
loadData();
|
| 15 |
+
}, []);
|
| 16 |
+
|
| 17 |
+
const loadData = async () => {
|
| 18 |
+
if (!window.solvox) return;
|
| 19 |
+
try {
|
| 20 |
+
const [historyResult, statusResult] = await Promise.all([
|
| 21 |
+
window.solvox.wallet.getHistory(5),
|
| 22 |
+
window.solvox.ai.getStatus(),
|
| 23 |
+
]);
|
| 24 |
+
if (historyResult.success) setRecentTxs(historyResult.history || []);
|
| 25 |
+
setAiStatus(statusResult);
|
| 26 |
+
} catch {}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const totalUSD = balance.sol * 170 + balance.usdt; // Rough SOL price estimate
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="space-y-6 slide-enter">
|
| 33 |
+
{/* Balance Cards */}
|
| 34 |
+
<div className="grid grid-cols-3 gap-4">
|
| 35 |
+
<div className="glass rounded-2xl p-6 glow-purple">
|
| 36 |
+
<div className="text-sm text-sol-muted mb-1">Total Portfolio</div>
|
| 37 |
+
<div className="text-3xl font-bold gradient-text">
|
| 38 |
+
${totalUSD.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
| 39 |
+
</div>
|
| 40 |
+
<button
|
| 41 |
+
onClick={onRefresh}
|
| 42 |
+
className="mt-3 text-xs text-sol-muted hover:text-sol-purple transition-colors"
|
| 43 |
+
>
|
| 44 |
+
↻ Refresh
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="glass rounded-2xl p-6">
|
| 49 |
+
<div className="flex items-center gap-2 mb-2">
|
| 50 |
+
<div className="w-8 h-8 rounded-full bg-sol-purple/20 flex items-center justify-center">
|
| 51 |
+
<span className="text-sol-purple font-bold text-sm">◎</span>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="text-sm text-sol-muted">SOL</div>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="text-2xl font-bold">{balance.sol.toFixed(4)}</div>
|
| 56 |
+
<div className="text-xs text-sol-muted mt-1">
|
| 57 |
+
≈ ${(balance.sol * 170).toFixed(2)}
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div className="glass rounded-2xl p-6">
|
| 62 |
+
<div className="flex items-center gap-2 mb-2">
|
| 63 |
+
<div className="w-8 h-8 rounded-full bg-tether-green/20 flex items-center justify-center">
|
| 64 |
+
<span className="text-tether-green font-bold text-sm">₮</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div className="text-sm text-sol-muted">USDT</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="text-2xl font-bold">{balance.usdt.toFixed(2)}</div>
|
| 69 |
+
<div className="text-xs text-sol-muted mt-1">
|
| 70 |
+
≈ ${balance.usdt.toFixed(2)}
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{/* Quick Actions */}
|
| 76 |
+
<div className="grid grid-cols-4 gap-3">
|
| 77 |
+
{[
|
| 78 |
+
{ icon: '📤', label: 'Send', color: 'sol-purple' },
|
| 79 |
+
{ icon: '📥', label: 'Receive', color: 'sol-green' },
|
| 80 |
+
{ icon: '🎤', label: 'Voice AI', color: 'tether-green' },
|
| 81 |
+
{ icon: '🔍', label: 'Scan QR', color: 'warning' },
|
| 82 |
+
].map(action => (
|
| 83 |
+
<button
|
| 84 |
+
key={action.label}
|
| 85 |
+
className={`glass rounded-xl p-4 text-center hover:border-${action.color} transition-all group`}
|
| 86 |
+
>
|
| 87 |
+
<div className="text-2xl mb-1">{action.icon}</div>
|
| 88 |
+
<div className="text-xs font-medium text-sol-muted group-hover:text-sol-text">
|
| 89 |
+
{action.label}
|
| 90 |
+
</div>
|
| 91 |
+
</button>
|
| 92 |
+
))}
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* AI Status & Recent Activity */}
|
| 96 |
+
<div className="grid grid-cols-2 gap-4">
|
| 97 |
+
{/* AI Engine Status */}
|
| 98 |
+
<div className="glass rounded-2xl p-6">
|
| 99 |
+
<h3 className="text-lg font-semibold mb-4">🧠 AI Engine (QVAC)</h3>
|
| 100 |
+
<div className="space-y-3">
|
| 101 |
+
{[
|
| 102 |
+
{ name: 'LLM (Llama 3.2)', active: aiStatus?.llm, pkg: '@qvac/llm-llamacpp' },
|
| 103 |
+
{ name: 'Speech-to-Text', active: aiStatus?.transcription, pkg: '@qvac/transcription-whispercpp' },
|
| 104 |
+
{ name: 'Text-to-Speech', active: aiStatus?.tts, pkg: '@qvac/tts-onnx' },
|
| 105 |
+
{ name: 'Embeddings', active: aiStatus?.embed, pkg: '@qvac/embed-llamacpp' },
|
| 106 |
+
{ name: 'Translation', active: aiStatus?.translation, pkg: '@qvac/translation-nmtcpp' },
|
| 107 |
+
{ name: 'OCR', active: aiStatus?.ocr, pkg: '@qvac/ocr-onnx' },
|
| 108 |
+
].map(model => (
|
| 109 |
+
<div key={model.name} className="flex items-center justify-between">
|
| 110 |
+
<div>
|
| 111 |
+
<div className="text-sm">{model.name}</div>
|
| 112 |
+
<div className="text-xs text-sol-muted font-mono">{model.pkg}</div>
|
| 113 |
+
</div>
|
| 114 |
+
<div className={`flex items-center gap-1.5 text-xs ${model.active ? 'text-sol-green' : 'text-sol-muted'}`}>
|
| 115 |
+
<span className={`w-2 h-2 rounded-full ${model.active ? 'bg-sol-green' : 'bg-sol-muted/30'}`} />
|
| 116 |
+
{model.active ? 'Active' : 'Loading...'}
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
<div className="mt-4 pt-3 border-t border-sol-border">
|
| 122 |
+
<div className="flex items-center gap-2 text-xs text-sol-muted">
|
| 123 |
+
<span className="w-2 h-2 rounded-full bg-tether-green" />
|
| 124 |
+
All inference runs 100% locally via Vulkan GPU
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* Recent Transactions */}
|
| 130 |
+
<div className="glass rounded-2xl p-6">
|
| 131 |
+
<h3 className="text-lg font-semibold mb-4">📋 Recent Activity</h3>
|
| 132 |
+
{recentTxs.length === 0 ? (
|
| 133 |
+
<div className="text-center py-8">
|
| 134 |
+
<div className="text-4xl mb-2">📭</div>
|
| 135 |
+
<div className="text-sol-muted text-sm">No transactions yet</div>
|
| 136 |
+
<div className="text-xs text-sol-muted mt-1">
|
| 137 |
+
Try saying "Send 1 SOL to..."
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<div className="space-y-3">
|
| 142 |
+
{recentTxs.map((tx, i) => (
|
| 143 |
+
<div key={i} className="flex items-center justify-between py-2 border-b border-sol-border/30 last:border-0">
|
| 144 |
+
<div className="flex items-center gap-3">
|
| 145 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
| 146 |
+
tx.status === 'success' ? 'bg-sol-green/20' : 'bg-danger/20'
|
| 147 |
+
}`}>
|
| 148 |
+
{tx.status === 'success' ? '✓' : '✗'}
|
| 149 |
+
</div>
|
| 150 |
+
<div>
|
| 151 |
+
<div className="text-sm font-mono">
|
| 152 |
+
{tx.signature?.slice(0, 12)}...
|
| 153 |
+
</div>
|
| 154 |
+
<div className="text-xs text-sol-muted">
|
| 155 |
+
{tx.timestamp ? new Date(tx.timestamp).toLocaleDateString() : 'Pending'}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
<div className={`text-sm ${tx.status === 'success' ? 'text-sol-green' : 'text-danger'}`}>
|
| 160 |
+
{tx.status}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{/* Wallet Address */}
|
| 170 |
+
<div className="glass rounded-2xl p-4">
|
| 171 |
+
<div className="flex items-center justify-between">
|
| 172 |
+
<div>
|
| 173 |
+
<div className="text-xs text-sol-muted mb-1">Your Wallet Address</div>
|
| 174 |
+
<div className="text-sm font-mono text-sol-muted">{publicKey || '—'}</div>
|
| 175 |
+
</div>
|
| 176 |
+
<button
|
| 177 |
+
onClick={() => publicKey && navigator.clipboard.writeText(publicKey)}
|
| 178 |
+
className="px-4 py-2 rounded-lg bg-sol-purple/20 text-sol-purple text-sm hover:bg-sol-purple/30 transition-colors"
|
| 179 |
+
>
|
| 180 |
+
📋 Copy
|
| 181 |
+
</button>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
src/renderer/pages/HistoryPage.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
export default function HistoryPage() {
|
| 4 |
+
const [transactions, setTransactions] = useState<any[]>([]);
|
| 5 |
+
const [loading, setLoading] = useState(true);
|
| 6 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 7 |
+
const [ragResults, setRagResults] = useState<any[]>([]);
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
loadHistory();
|
| 11 |
+
}, []);
|
| 12 |
+
|
| 13 |
+
const loadHistory = async () => {
|
| 14 |
+
setLoading(true);
|
| 15 |
+
try {
|
| 16 |
+
if (window.solvox) {
|
| 17 |
+
const result = await window.solvox.wallet.getHistory(20);
|
| 18 |
+
if (result.success) {
|
| 19 |
+
setTransactions(result.history || []);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
} catch {}
|
| 23 |
+
setLoading(false);
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const handleSearch = async () => {
|
| 27 |
+
if (!searchQuery.trim()) return;
|
| 28 |
+
try {
|
| 29 |
+
if (window.solvox) {
|
| 30 |
+
const result = await window.solvox.rag.search(searchQuery);
|
| 31 |
+
if (result.success) {
|
| 32 |
+
setRagResults(result.results || []);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
} catch {}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="space-y-6 slide-enter">
|
| 40 |
+
<div className="flex items-center justify-between">
|
| 41 |
+
<h2 className="text-2xl font-bold">📋 Transaction History</h2>
|
| 42 |
+
<button
|
| 43 |
+
onClick={loadHistory}
|
| 44 |
+
className="px-4 py-2 rounded-lg bg-sol-purple/20 text-sol-purple text-sm hover:bg-sol-purple/30 transition-colors"
|
| 45 |
+
>
|
| 46 |
+
↻ Refresh
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Semantic Search (RAG) */}
|
| 51 |
+
<div className="glass rounded-xl p-4">
|
| 52 |
+
<div className="flex gap-2">
|
| 53 |
+
<input
|
| 54 |
+
value={searchQuery}
|
| 55 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 56 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
| 57 |
+
placeholder='Search transactions with AI... (e.g. "last payment to Alice")'
|
| 58 |
+
className="flex-1 px-4 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 59 |
+
/>
|
| 60 |
+
<button
|
| 61 |
+
onClick={handleSearch}
|
| 62 |
+
className="px-4 py-2 rounded-lg bg-tether-green text-white text-sm hover:bg-tether-green/90 transition-colors"
|
| 63 |
+
>
|
| 64 |
+
🔍 AI Search
|
| 65 |
+
</button>
|
| 66 |
+
</div>
|
| 67 |
+
<div className="text-xs text-sol-muted mt-2">
|
| 68 |
+
Powered by @qvac/embed-llamacpp — semantic search runs 100% locally
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{ragResults.length > 0 && (
|
| 72 |
+
<div className="mt-3 space-y-2">
|
| 73 |
+
<div className="text-sm font-semibold text-sol-purple">AI Search Results:</div>
|
| 74 |
+
{ragResults.map((r, i) => (
|
| 75 |
+
<div key={i} className="bg-sol-dark rounded-lg p-3 text-sm">
|
| 76 |
+
<div>{r.text}</div>
|
| 77 |
+
<div className="text-xs text-sol-muted mt-1">
|
| 78 |
+
Relevance: {(r.score * 100).toFixed(1)}%
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
))}
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* Transaction List */}
|
| 87 |
+
<div className="glass rounded-2xl overflow-hidden">
|
| 88 |
+
{loading ? (
|
| 89 |
+
<div className="p-12 text-center text-sol-muted">Loading transactions...</div>
|
| 90 |
+
) : transactions.length === 0 ? (
|
| 91 |
+
<div className="p-12 text-center">
|
| 92 |
+
<div className="text-5xl mb-4">📭</div>
|
| 93 |
+
<div className="text-lg font-semibold mb-1">No Transactions Yet</div>
|
| 94 |
+
<div className="text-sol-muted text-sm">
|
| 95 |
+
Your transaction history will appear here once you send or receive tokens.
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
) : (
|
| 99 |
+
<table className="w-full">
|
| 100 |
+
<thead>
|
| 101 |
+
<tr className="border-b border-sol-border text-sm text-sol-muted">
|
| 102 |
+
<th className="text-left px-6 py-3">Status</th>
|
| 103 |
+
<th className="text-left px-6 py-3">Signature</th>
|
| 104 |
+
<th className="text-left px-6 py-3">Date</th>
|
| 105 |
+
<th className="text-right px-6 py-3">Details</th>
|
| 106 |
+
</tr>
|
| 107 |
+
</thead>
|
| 108 |
+
<tbody>
|
| 109 |
+
{transactions.map((tx, i) => (
|
| 110 |
+
<tr key={i} className="border-b border-sol-border/30 hover:bg-sol-dark/50 transition-colors">
|
| 111 |
+
<td className="px-6 py-4">
|
| 112 |
+
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs ${
|
| 113 |
+
tx.status === 'success'
|
| 114 |
+
? 'bg-sol-green/20 text-sol-green'
|
| 115 |
+
: 'bg-danger/20 text-danger'
|
| 116 |
+
}`}>
|
| 117 |
+
{tx.status === 'success' ? '✓' : '✗'} {tx.status}
|
| 118 |
+
</span>
|
| 119 |
+
</td>
|
| 120 |
+
<td className="px-6 py-4 font-mono text-xs text-sol-muted">
|
| 121 |
+
{tx.signature?.slice(0, 20)}...
|
| 122 |
+
</td>
|
| 123 |
+
<td className="px-6 py-4 text-sm text-sol-muted">
|
| 124 |
+
{tx.timestamp
|
| 125 |
+
? new Date(tx.timestamp).toLocaleString()
|
| 126 |
+
: 'Pending'}
|
| 127 |
+
</td>
|
| 128 |
+
<td className="px-6 py-4 text-right">
|
| 129 |
+
<a
|
| 130 |
+
href={`https://solscan.io/tx/${tx.signature}`}
|
| 131 |
+
target="_blank"
|
| 132 |
+
rel="noopener noreferrer"
|
| 133 |
+
className="text-sol-purple text-xs hover:underline"
|
| 134 |
+
>
|
| 135 |
+
View →
|
| 136 |
+
</a>
|
| 137 |
+
</td>
|
| 138 |
+
</tr>
|
| 139 |
+
))}
|
| 140 |
+
</tbody>
|
| 141 |
+
</table>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
}
|
src/renderer/pages/LockScreen.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface LockScreenProps {
|
| 4 |
+
onUnlock: (publicKey: string) => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function LockScreen({ onUnlock }: LockScreenProps) {
|
| 8 |
+
const [pin, setPin] = useState('');
|
| 9 |
+
const [error, setError] = useState('');
|
| 10 |
+
const [loading, setLoading] = useState(false);
|
| 11 |
+
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
| 12 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
inputRef.current?.focus();
|
| 16 |
+
checkBiometric();
|
| 17 |
+
}, []);
|
| 18 |
+
|
| 19 |
+
const checkBiometric = async () => {
|
| 20 |
+
if (window.solvox) {
|
| 21 |
+
const available = await window.solvox.auth.biometricAvailable();
|
| 22 |
+
setBiometricAvailable(available);
|
| 23 |
+
if (available) {
|
| 24 |
+
handleBiometric();
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const handleBiometric = async () => {
|
| 30 |
+
if (!window.solvox) return;
|
| 31 |
+
setLoading(true);
|
| 32 |
+
const result = await window.solvox.auth.biometric('Unlock SolVox');
|
| 33 |
+
if (result.success) {
|
| 34 |
+
const pk = await window.solvox.wallet.getPublicKey();
|
| 35 |
+
if (pk) onUnlock(pk);
|
| 36 |
+
} else {
|
| 37 |
+
setError(result.error || 'Biometric failed');
|
| 38 |
+
}
|
| 39 |
+
setLoading(false);
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const handlePinSubmit = async (e: React.FormEvent) => {
|
| 43 |
+
e.preventDefault();
|
| 44 |
+
if (pin.length < 6) {
|
| 45 |
+
setError('PIN must be at least 6 digits');
|
| 46 |
+
return;
|
| 47 |
+
}
|
| 48 |
+
setLoading(true);
|
| 49 |
+
setError('');
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
if (window.solvox) {
|
| 53 |
+
const result = await window.solvox.auth.unlock(pin);
|
| 54 |
+
if (result.success) {
|
| 55 |
+
const pk = await window.solvox.wallet.getPublicKey();
|
| 56 |
+
if (pk) onUnlock(pk);
|
| 57 |
+
} else {
|
| 58 |
+
setError(result.error || 'Unlock failed');
|
| 59 |
+
if (result.remainingAttempts !== undefined) {
|
| 60 |
+
setError(`${result.error} (${result.remainingAttempts} attempts remaining)`);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
} else {
|
| 64 |
+
// Dev mode
|
| 65 |
+
onUnlock('DevModePublicKey123456789');
|
| 66 |
+
}
|
| 67 |
+
} catch (err: any) {
|
| 68 |
+
setError(err.message);
|
| 69 |
+
}
|
| 70 |
+
setLoading(false);
|
| 71 |
+
setPin('');
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="h-screen flex flex-col items-center justify-center bg-sol-dark">
|
| 76 |
+
<div className="w-full max-w-sm">
|
| 77 |
+
{/* Logo */}
|
| 78 |
+
<div className="text-center mb-12">
|
| 79 |
+
<h1 className="text-5xl font-bold gradient-text mb-2">SolVox</h1>
|
| 80 |
+
<p className="text-sol-muted">Voice-First AI Wallet</p>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
{/* Lock Icon */}
|
| 84 |
+
<div className="text-center mb-8">
|
| 85 |
+
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-sol-card border-2 border-sol-border">
|
| 86 |
+
<span className="text-4xl">🔒</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* PIN Form */}
|
| 91 |
+
<form onSubmit={handlePinSubmit} className="space-y-4">
|
| 92 |
+
<div>
|
| 93 |
+
<input
|
| 94 |
+
ref={inputRef}
|
| 95 |
+
type="password"
|
| 96 |
+
inputMode="numeric"
|
| 97 |
+
pattern="[0-9]*"
|
| 98 |
+
value={pin}
|
| 99 |
+
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
| 100 |
+
placeholder="Enter PIN"
|
| 101 |
+
maxLength={12}
|
| 102 |
+
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-center text-2xl tracking-[0.5em] font-mono text-sol-text focus:border-sol-purple focus:outline-none focus:ring-1 focus:ring-sol-purple/50 transition-colors"
|
| 103 |
+
disabled={loading}
|
| 104 |
+
/>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{error && (
|
| 108 |
+
<div className="text-danger text-sm text-center bg-danger/10 rounded-lg p-2">
|
| 109 |
+
{error}
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
<button
|
| 114 |
+
type="submit"
|
| 115 |
+
disabled={loading || pin.length < 6}
|
| 116 |
+
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
| 117 |
+
>
|
| 118 |
+
{loading ? 'Unlocking...' : 'Unlock'}
|
| 119 |
+
</button>
|
| 120 |
+
</form>
|
| 121 |
+
|
| 122 |
+
{/* Biometric */}
|
| 123 |
+
{biometricAvailable && (
|
| 124 |
+
<button
|
| 125 |
+
onClick={handleBiometric}
|
| 126 |
+
className="w-full mt-4 py-3 rounded-xl border border-sol-border text-sol-muted hover:text-sol-text hover:border-sol-purple transition-colors"
|
| 127 |
+
>
|
| 128 |
+
🫰 Use Touch ID
|
| 129 |
+
</button>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
{/* Security Badge */}
|
| 133 |
+
<div className="mt-8 text-center">
|
| 134 |
+
<div className="inline-flex items-center gap-2 text-xs text-sol-muted">
|
| 135 |
+
<span className="w-2 h-2 rounded-full bg-sol-green" />
|
| 136 |
+
All data encrypted locally • No cloud
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
);
|
| 142 |
+
}
|
src/renderer/pages/OnboardingScreen.tsx
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface OnboardingScreenProps {
|
| 4 |
+
onComplete: (publicKey: string) => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
type Step = 'welcome' | 'create_or_import' | 'create' | 'import' | 'pin' | 'done';
|
| 8 |
+
|
| 9 |
+
export default function OnboardingScreen({ onComplete }: OnboardingScreenProps) {
|
| 10 |
+
const [step, setStep] = useState<Step>('welcome');
|
| 11 |
+
const [mnemonic, setMnemonic] = useState('');
|
| 12 |
+
const [pin, setPin] = useState('');
|
| 13 |
+
const [pinConfirm, setPinConfirm] = useState('');
|
| 14 |
+
const [publicKey, setPublicKey] = useState('');
|
| 15 |
+
const [error, setError] = useState('');
|
| 16 |
+
const [loading, setLoading] = useState(false);
|
| 17 |
+
|
| 18 |
+
const handleCreateWallet = async () => {
|
| 19 |
+
setLoading(true);
|
| 20 |
+
setError('');
|
| 21 |
+
try {
|
| 22 |
+
if (window.solvox) {
|
| 23 |
+
const result = await window.solvox.wallet.create();
|
| 24 |
+
if (result.success && result.publicKey) {
|
| 25 |
+
setPublicKey(result.publicKey);
|
| 26 |
+
setStep('pin');
|
| 27 |
+
} else {
|
| 28 |
+
setError(result.error || 'Failed to create wallet');
|
| 29 |
+
}
|
| 30 |
+
} else {
|
| 31 |
+
setPublicKey('DevMode123');
|
| 32 |
+
setStep('pin');
|
| 33 |
+
}
|
| 34 |
+
} catch (err: any) {
|
| 35 |
+
setError(err.message);
|
| 36 |
+
}
|
| 37 |
+
setLoading(false);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleImportWallet = async () => {
|
| 41 |
+
if (!mnemonic.trim()) {
|
| 42 |
+
setError('Please enter your recovery phrase');
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
const words = mnemonic.trim().split(/\s+/);
|
| 46 |
+
if (words.length !== 12 && words.length !== 24) {
|
| 47 |
+
setError('Recovery phrase must be 12 or 24 words');
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
setLoading(true);
|
| 51 |
+
setError('');
|
| 52 |
+
try {
|
| 53 |
+
if (window.solvox) {
|
| 54 |
+
const result = await window.solvox.wallet.import(mnemonic.trim());
|
| 55 |
+
if (result.success && result.publicKey) {
|
| 56 |
+
setPublicKey(result.publicKey);
|
| 57 |
+
setStep('pin');
|
| 58 |
+
} else {
|
| 59 |
+
setError(result.error || 'Invalid recovery phrase');
|
| 60 |
+
}
|
| 61 |
+
} else {
|
| 62 |
+
setPublicKey('DevImported123');
|
| 63 |
+
setStep('pin');
|
| 64 |
+
}
|
| 65 |
+
} catch (err: any) {
|
| 66 |
+
setError(err.message);
|
| 67 |
+
}
|
| 68 |
+
setLoading(false);
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const handleSetPin = async () => {
|
| 72 |
+
if (pin.length < 6) {
|
| 73 |
+
setError('PIN must be at least 6 digits');
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
if (pin !== pinConfirm) {
|
| 77 |
+
setError('PINs do not match');
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
setLoading(true);
|
| 81 |
+
setError('');
|
| 82 |
+
try {
|
| 83 |
+
if (window.solvox) {
|
| 84 |
+
const result = await window.solvox.auth.setPin(pin);
|
| 85 |
+
if (!result.success) {
|
| 86 |
+
setError(result.error || 'Failed to set PIN');
|
| 87 |
+
setLoading(false);
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
setStep('done');
|
| 92 |
+
} catch (err: any) {
|
| 93 |
+
setError(err.message);
|
| 94 |
+
}
|
| 95 |
+
setLoading(false);
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="h-screen flex items-center justify-center bg-sol-dark">
|
| 100 |
+
<div className="w-full max-w-lg p-8">
|
| 101 |
+
{step === 'welcome' && (
|
| 102 |
+
<div className="text-center slide-enter">
|
| 103 |
+
<h1 className="text-6xl font-bold gradient-text mb-4">SolVox</h1>
|
| 104 |
+
<p className="text-xl text-sol-muted mb-2">Voice-First Private AI Wallet</p>
|
| 105 |
+
<p className="text-sm text-sol-muted mb-12">
|
| 106 |
+
Powered by QVAC • 100% Local AI • Zero Cloud Dependencies
|
| 107 |
+
</p>
|
| 108 |
+
<div className="grid grid-cols-3 gap-4 mb-12">
|
| 109 |
+
{[
|
| 110 |
+
{ icon: '🎤', label: 'Voice Control', desc: 'Talk to your wallet' },
|
| 111 |
+
{ icon: '🧠', label: 'Local AI', desc: 'All AI runs on-device' },
|
| 112 |
+
{ icon: '🔒', label: 'Self-Custody', desc: 'Your keys, your coins' },
|
| 113 |
+
].map(f => (
|
| 114 |
+
<div key={f.label} className="glass rounded-xl p-4 text-center">
|
| 115 |
+
<div className="text-3xl mb-2">{f.icon}</div>
|
| 116 |
+
<div className="text-sm font-semibold">{f.label}</div>
|
| 117 |
+
<div className="text-xs text-sol-muted mt-1">{f.desc}</div>
|
| 118 |
+
</div>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
<button
|
| 122 |
+
onClick={() => setStep('create_or_import')}
|
| 123 |
+
className="px-8 py-4 rounded-xl bg-sol-purple text-white font-bold text-lg hover:bg-sol-purple/90 transition-all glow-purple"
|
| 124 |
+
>
|
| 125 |
+
Get Started
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{step === 'create_or_import' && (
|
| 131 |
+
<div className="space-y-6 slide-enter">
|
| 132 |
+
<h2 className="text-3xl font-bold text-center mb-8">Set Up Your Wallet</h2>
|
| 133 |
+
<button
|
| 134 |
+
onClick={handleCreateWallet}
|
| 135 |
+
disabled={loading}
|
| 136 |
+
className="w-full p-6 rounded-xl glass border-2 border-transparent hover:border-sol-purple transition-all text-left group"
|
| 137 |
+
>
|
| 138 |
+
<div className="flex items-center gap-4">
|
| 139 |
+
<span className="text-4xl">✨</span>
|
| 140 |
+
<div>
|
| 141 |
+
<div className="text-lg font-semibold group-hover:text-sol-purple transition-colors">
|
| 142 |
+
Create New Wallet
|
| 143 |
+
</div>
|
| 144 |
+
<div className="text-sm text-sol-muted">
|
| 145 |
+
Generate a fresh wallet with a new recovery phrase
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</button>
|
| 150 |
+
<button
|
| 151 |
+
onClick={() => setStep('import')}
|
| 152 |
+
className="w-full p-6 rounded-xl glass border-2 border-transparent hover:border-tether-green transition-all text-left group"
|
| 153 |
+
>
|
| 154 |
+
<div className="flex items-center gap-4">
|
| 155 |
+
<span className="text-4xl">📥</span>
|
| 156 |
+
<div>
|
| 157 |
+
<div className="text-lg font-semibold group-hover:text-tether-green transition-colors">
|
| 158 |
+
Import Existing Wallet
|
| 159 |
+
</div>
|
| 160 |
+
<div className="text-sm text-sol-muted">
|
| 161 |
+
Use a 12 or 24 word recovery phrase
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</button>
|
| 166 |
+
{error && <div className="text-danger text-sm text-center">{error}</div>}
|
| 167 |
+
</div>
|
| 168 |
+
)}
|
| 169 |
+
|
| 170 |
+
{step === 'import' && (
|
| 171 |
+
<div className="space-y-6 slide-enter">
|
| 172 |
+
<h2 className="text-2xl font-bold text-center">Import Recovery Phrase</h2>
|
| 173 |
+
<p className="text-sm text-sol-muted text-center">
|
| 174 |
+
Enter your 12 or 24 word recovery phrase. This stays on your device — never sent anywhere.
|
| 175 |
+
</p>
|
| 176 |
+
<textarea
|
| 177 |
+
value={mnemonic}
|
| 178 |
+
onChange={(e) => setMnemonic(e.target.value)}
|
| 179 |
+
placeholder="word1 word2 word3 ..."
|
| 180 |
+
rows={4}
|
| 181 |
+
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-sol-text font-mono text-sm focus:border-sol-purple focus:outline-none resize-none"
|
| 182 |
+
/>
|
| 183 |
+
{error && <div className="text-danger text-sm">{error}</div>}
|
| 184 |
+
<div className="flex gap-3">
|
| 185 |
+
<button
|
| 186 |
+
onClick={() => { setStep('create_or_import'); setError(''); }}
|
| 187 |
+
className="flex-1 py-3 rounded-xl border border-sol-border text-sol-muted hover:text-sol-text transition-colors"
|
| 188 |
+
>
|
| 189 |
+
Back
|
| 190 |
+
</button>
|
| 191 |
+
<button
|
| 192 |
+
onClick={handleImportWallet}
|
| 193 |
+
disabled={loading}
|
| 194 |
+
className="flex-1 py-3 rounded-xl bg-tether-green text-white font-semibold hover:bg-tether-green/90 disabled:opacity-50 transition-all"
|
| 195 |
+
>
|
| 196 |
+
{loading ? 'Importing...' : 'Import'}
|
| 197 |
+
</button>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{step === 'pin' && (
|
| 203 |
+
<div className="space-y-6 slide-enter">
|
| 204 |
+
<h2 className="text-2xl font-bold text-center">Set Your PIN</h2>
|
| 205 |
+
<p className="text-sm text-sol-muted text-center">
|
| 206 |
+
This PIN encrypts your wallet. You'll need it every time you open SolVox.
|
| 207 |
+
</p>
|
| 208 |
+
<input
|
| 209 |
+
type="password"
|
| 210 |
+
inputMode="numeric"
|
| 211 |
+
value={pin}
|
| 212 |
+
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
| 213 |
+
placeholder="Enter PIN (min 6 digits)"
|
| 214 |
+
maxLength={12}
|
| 215 |
+
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-center text-2xl tracking-[0.5em] font-mono focus:border-sol-purple focus:outline-none"
|
| 216 |
+
/>
|
| 217 |
+
<input
|
| 218 |
+
type="password"
|
| 219 |
+
inputMode="numeric"
|
| 220 |
+
value={pinConfirm}
|
| 221 |
+
onChange={(e) => setPinConfirm(e.target.value.replace(/\D/g, ''))}
|
| 222 |
+
placeholder="Confirm PIN"
|
| 223 |
+
maxLength={12}
|
| 224 |
+
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-center text-2xl tracking-[0.5em] font-mono focus:border-sol-purple focus:outline-none"
|
| 225 |
+
/>
|
| 226 |
+
{error && <div className="text-danger text-sm text-center">{error}</div>}
|
| 227 |
+
<button
|
| 228 |
+
onClick={handleSetPin}
|
| 229 |
+
disabled={loading || pin.length < 6}
|
| 230 |
+
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 transition-all"
|
| 231 |
+
>
|
| 232 |
+
{loading ? 'Setting up...' : 'Continue'}
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
|
| 237 |
+
{step === 'done' && (
|
| 238 |
+
<div className="text-center space-y-6 slide-enter">
|
| 239 |
+
<div className="text-6xl mb-4">🎉</div>
|
| 240 |
+
<h2 className="text-3xl font-bold">You're All Set!</h2>
|
| 241 |
+
<p className="text-sol-muted">
|
| 242 |
+
Your wallet is created and secured. Your AI assistant runs 100% locally — no data ever leaves your device.
|
| 243 |
+
</p>
|
| 244 |
+
<div className="glass rounded-xl p-4 font-mono text-xs text-sol-muted break-all">
|
| 245 |
+
{publicKey}
|
| 246 |
+
</div>
|
| 247 |
+
<button
|
| 248 |
+
onClick={() => onComplete(publicKey)}
|
| 249 |
+
className="px-8 py-4 rounded-xl bg-sol-purple text-white font-bold text-lg hover:bg-sol-purple/90 transition-all glow-purple"
|
| 250 |
+
>
|
| 251 |
+
Open SolVox
|
| 252 |
+
</button>
|
| 253 |
+
</div>
|
| 254 |
+
)}
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
);
|
| 258 |
+
}
|
src/renderer/pages/SecurityPage.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
export default function SecurityPage() {
|
| 4 |
+
const [settings, setSettings] = useState<any>({});
|
| 5 |
+
const [whitelist, setWhitelist] = useState<any[]>([]);
|
| 6 |
+
const [anomalies, setAnomalies] = useState<any[]>([]);
|
| 7 |
+
const [newAddress, setNewAddress] = useState('');
|
| 8 |
+
const [newLabel, setNewLabel] = useState('');
|
| 9 |
+
const [error, setError] = useState('');
|
| 10 |
+
const [saved, setSaved] = useState(false);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
loadSecurity();
|
| 14 |
+
}, []);
|
| 15 |
+
|
| 16 |
+
const loadSecurity = async () => {
|
| 17 |
+
if (!window.solvox) return;
|
| 18 |
+
try {
|
| 19 |
+
const [settingsResult, whitelistResult, anomalyResult] = await Promise.all([
|
| 20 |
+
window.solvox.security.getSettings(),
|
| 21 |
+
window.solvox.security.getWhitelist(),
|
| 22 |
+
window.solvox.security.getAnomalies(),
|
| 23 |
+
]);
|
| 24 |
+
setSettings(settingsResult);
|
| 25 |
+
setWhitelist(whitelistResult);
|
| 26 |
+
setAnomalies(anomalyResult);
|
| 27 |
+
} catch {}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const handleUpdateSettings = async (key: string, value: any) => {
|
| 31 |
+
const updated = { ...settings, [key]: value };
|
| 32 |
+
setSettings(updated);
|
| 33 |
+
if (window.solvox) {
|
| 34 |
+
await window.solvox.security.updateSettings(updated);
|
| 35 |
+
setSaved(true);
|
| 36 |
+
setTimeout(() => setSaved(false), 2000);
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleAddWhitelist = async () => {
|
| 41 |
+
if (!newAddress.trim() || !newLabel.trim()) {
|
| 42 |
+
setError('Address and label are required');
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
if (window.solvox) {
|
| 46 |
+
const result = await window.solvox.security.addWhitelist(newAddress.trim(), newLabel.trim());
|
| 47 |
+
if (result.success) {
|
| 48 |
+
setNewAddress('');
|
| 49 |
+
setNewLabel('');
|
| 50 |
+
setError('');
|
| 51 |
+
loadSecurity();
|
| 52 |
+
} else {
|
| 53 |
+
setError(result.error || 'Failed to add');
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleRemoveWhitelist = async (address: string) => {
|
| 59 |
+
if (window.solvox) {
|
| 60 |
+
await window.solvox.security.removeWhitelist(address);
|
| 61 |
+
loadSecurity();
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="space-y-6 max-w-3xl slide-enter">
|
| 67 |
+
<h2 className="text-2xl font-bold">🛡️ Security Center</h2>
|
| 68 |
+
|
| 69 |
+
{saved && (
|
| 70 |
+
<div className="bg-sol-green/20 text-sol-green rounded-xl p-3 text-sm text-center">
|
| 71 |
+
✓ Settings saved
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
|
| 75 |
+
{/* Transaction Limits */}
|
| 76 |
+
<div className="glass rounded-2xl p-6">
|
| 77 |
+
<h3 className="text-lg font-semibold mb-4">Transaction Limits</h3>
|
| 78 |
+
<div className="grid grid-cols-2 gap-4">
|
| 79 |
+
<div>
|
| 80 |
+
<label className="text-sm text-sol-muted block mb-1">Max Single Transaction</label>
|
| 81 |
+
<div className="flex items-center gap-2">
|
| 82 |
+
<input
|
| 83 |
+
type="number"
|
| 84 |
+
value={settings.maxSingleTx || 1000}
|
| 85 |
+
onChange={(e) => handleUpdateSettings('maxSingleTx', Number(e.target.value))}
|
| 86 |
+
className="flex-1 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 87 |
+
/>
|
| 88 |
+
<span className="text-sol-muted text-sm">tokens</span>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
<div>
|
| 92 |
+
<label className="text-sm text-sol-muted block mb-1">Max Daily Volume</label>
|
| 93 |
+
<div className="flex items-center gap-2">
|
| 94 |
+
<input
|
| 95 |
+
type="number"
|
| 96 |
+
value={settings.maxDailyVolume || 5000}
|
| 97 |
+
onChange={(e) => handleUpdateSettings('maxDailyVolume', Number(e.target.value))}
|
| 98 |
+
className="flex-1 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 99 |
+
/>
|
| 100 |
+
<span className="text-sol-muted text-sm">tokens/day</span>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<label className="text-sm text-sol-muted block mb-1">Max Transactions/Hour</label>
|
| 105 |
+
<input
|
| 106 |
+
type="number"
|
| 107 |
+
value={settings.velocityLimit || 10}
|
| 108 |
+
onChange={(e) => handleUpdateSettings('velocityLimit', Number(e.target.value))}
|
| 109 |
+
className="w-full px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
<div>
|
| 113 |
+
<label className="text-sm text-sol-muted block mb-1">Cooldown (minutes)</label>
|
| 114 |
+
<input
|
| 115 |
+
type="number"
|
| 116 |
+
value={settings.cooldownMinutes || 1}
|
| 117 |
+
onChange={(e) => handleUpdateSettings('cooldownMinutes', Number(e.target.value))}
|
| 118 |
+
className="w-full px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Security Toggles */}
|
| 125 |
+
<div className="glass rounded-2xl p-6">
|
| 126 |
+
<h3 className="text-lg font-semibold mb-4">Security Features</h3>
|
| 127 |
+
<div className="space-y-4">
|
| 128 |
+
{[
|
| 129 |
+
{
|
| 130 |
+
key: 'whitelistEnabled',
|
| 131 |
+
label: 'Address Whitelisting',
|
| 132 |
+
desc: 'Only allow transactions to pre-approved addresses',
|
| 133 |
+
icon: '📋',
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
key: 'anomalyDetection',
|
| 137 |
+
label: 'AI Anomaly Detection',
|
| 138 |
+
desc: 'Detect unusual transaction patterns using local AI',
|
| 139 |
+
icon: '🧠',
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
key: 'requireConfirmation',
|
| 143 |
+
label: 'Transaction Confirmation',
|
| 144 |
+
desc: 'Always require explicit confirmation before sending',
|
| 145 |
+
icon: '✅',
|
| 146 |
+
},
|
| 147 |
+
].map(toggle => (
|
| 148 |
+
<div key={toggle.key} className="flex items-center justify-between py-2">
|
| 149 |
+
<div className="flex items-center gap-3">
|
| 150 |
+
<span className="text-2xl">{toggle.icon}</span>
|
| 151 |
+
<div>
|
| 152 |
+
<div className="text-sm font-semibold">{toggle.label}</div>
|
| 153 |
+
<div className="text-xs text-sol-muted">{toggle.desc}</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<button
|
| 157 |
+
onClick={() => handleUpdateSettings(toggle.key, !settings[toggle.key])}
|
| 158 |
+
className={`relative w-12 h-6 rounded-full transition-colors ${
|
| 159 |
+
settings[toggle.key] ? 'bg-sol-green' : 'bg-sol-border'
|
| 160 |
+
}`}
|
| 161 |
+
>
|
| 162 |
+
<span
|
| 163 |
+
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform ${
|
| 164 |
+
settings[toggle.key] ? 'translate-x-6' : 'translate-x-0.5'
|
| 165 |
+
}`}
|
| 166 |
+
/>
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
))}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Address Whitelist */}
|
| 174 |
+
<div className="glass rounded-2xl p-6">
|
| 175 |
+
<h3 className="text-lg font-semibold mb-4">📋 Address Whitelist</h3>
|
| 176 |
+
|
| 177 |
+
{/* Add New */}
|
| 178 |
+
<div className="flex gap-2 mb-4">
|
| 179 |
+
<input
|
| 180 |
+
value={newLabel}
|
| 181 |
+
onChange={(e) => setNewLabel(e.target.value)}
|
| 182 |
+
placeholder="Label (e.g. Alice)"
|
| 183 |
+
className="w-32 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 184 |
+
/>
|
| 185 |
+
<input
|
| 186 |
+
value={newAddress}
|
| 187 |
+
onChange={(e) => setNewAddress(e.target.value)}
|
| 188 |
+
placeholder="Solana address"
|
| 189 |
+
className="flex-1 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm font-mono focus:border-sol-purple focus:outline-none"
|
| 190 |
+
/>
|
| 191 |
+
<button
|
| 192 |
+
onClick={handleAddWhitelist}
|
| 193 |
+
className="px-4 py-2 rounded-lg bg-sol-purple text-white text-sm hover:bg-sol-purple/90 transition-colors"
|
| 194 |
+
>
|
| 195 |
+
+ Add
|
| 196 |
+
</button>
|
| 197 |
+
</div>
|
| 198 |
+
{error && <div className="text-danger text-xs mb-3">{error}</div>}
|
| 199 |
+
|
| 200 |
+
{/* Whitelist Entries */}
|
| 201 |
+
{whitelist.length === 0 ? (
|
| 202 |
+
<div className="text-center py-6 text-sol-muted text-sm">
|
| 203 |
+
No whitelisted addresses yet
|
| 204 |
+
</div>
|
| 205 |
+
) : (
|
| 206 |
+
<div className="space-y-2">
|
| 207 |
+
{whitelist.map((entry, i) => (
|
| 208 |
+
<div key={i} className="flex items-center justify-between bg-sol-dark rounded-lg p-3">
|
| 209 |
+
<div>
|
| 210 |
+
<div className="text-sm font-semibold">{entry.label}</div>
|
| 211 |
+
<div className="text-xs font-mono text-sol-muted">
|
| 212 |
+
{entry.address.slice(0, 12)}...{entry.address.slice(-8)}
|
| 213 |
+
</div>
|
| 214 |
+
<div className="text-xs text-sol-muted mt-0.5">
|
| 215 |
+
Added: {new Date(entry.addedAt).toLocaleDateString()}
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
<button
|
| 219 |
+
onClick={() => handleRemoveWhitelist(entry.address)}
|
| 220 |
+
className="text-danger text-xs hover:underline"
|
| 221 |
+
>
|
| 222 |
+
Remove
|
| 223 |
+
</button>
|
| 224 |
+
</div>
|
| 225 |
+
))}
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
{/* Anomaly Log */}
|
| 231 |
+
<div className="glass rounded-2xl p-6">
|
| 232 |
+
<h3 className="text-lg font-semibold mb-4">🔍 Anomaly Detection Log</h3>
|
| 233 |
+
<p className="text-xs text-sol-muted mb-3">
|
| 234 |
+
Powered by AI-driven pattern analysis — runs locally via QVAC
|
| 235 |
+
</p>
|
| 236 |
+
{anomalies.length === 0 ? (
|
| 237 |
+
<div className="text-center py-6 text-sol-muted text-sm">
|
| 238 |
+
No anomalies detected — all clear ✓
|
| 239 |
+
</div>
|
| 240 |
+
) : (
|
| 241 |
+
<div className="space-y-2">
|
| 242 |
+
{anomalies.map((anomaly, i) => (
|
| 243 |
+
<div key={i} className={`rounded-lg p-3 ${
|
| 244 |
+
anomaly.severity === 'high' ? 'bg-danger/10 border border-danger/30' :
|
| 245 |
+
anomaly.severity === 'medium' ? 'bg-warning/10 border border-warning/30' :
|
| 246 |
+
'bg-sol-dark border border-sol-border'
|
| 247 |
+
}`}>
|
| 248 |
+
<div className="flex items-center gap-2 mb-1">
|
| 249 |
+
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
| 250 |
+
anomaly.severity === 'high' ? 'bg-danger/20 text-danger' :
|
| 251 |
+
anomaly.severity === 'medium' ? 'bg-warning/20 text-warning' :
|
| 252 |
+
'bg-sol-muted/20 text-sol-muted'
|
| 253 |
+
}`}>
|
| 254 |
+
{anomaly.severity.toUpperCase()}
|
| 255 |
+
</span>
|
| 256 |
+
<span className="text-xs text-sol-muted">{anomaly.type}</span>
|
| 257 |
+
</div>
|
| 258 |
+
<div className="text-sm">{anomaly.description}</div>
|
| 259 |
+
<div className="text-xs text-sol-muted mt-1">
|
| 260 |
+
{new Date(anomaly.timestamp).toLocaleString()}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
))}
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
);
|
| 269 |
+
}
|
src/renderer/pages/SendPage.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface SendPageProps {
|
| 4 |
+
balance: { sol: number; usdt: number };
|
| 5 |
+
onSent: () => void;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function SendPage({ balance, onSent }: SendPageProps) {
|
| 9 |
+
const [token, setToken] = useState<'SOL' | 'USDT'>('SOL');
|
| 10 |
+
const [to, setTo] = useState('');
|
| 11 |
+
const [amount, setAmount] = useState('');
|
| 12 |
+
const [memo, setMemo] = useState('');
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const [error, setError] = useState('');
|
| 15 |
+
const [success, setSuccess] = useState<{ signature: string; explorer: string } | null>(null);
|
| 16 |
+
const [step, setStep] = useState<'form' | 'confirm' | 'result'>('form');
|
| 17 |
+
|
| 18 |
+
const maxAmount = token === 'SOL' ? balance.sol : balance.usdt;
|
| 19 |
+
|
| 20 |
+
const handleConfirm = () => {
|
| 21 |
+
setError('');
|
| 22 |
+
if (!to.trim()) {
|
| 23 |
+
setError('Recipient address is required');
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) {
|
| 27 |
+
setError('Invalid Solana address');
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
const amountNum = parseFloat(amount);
|
| 31 |
+
if (!amountNum || amountNum <= 0) {
|
| 32 |
+
setError('Enter a valid amount');
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
if (amountNum > maxAmount) {
|
| 36 |
+
setError(`Insufficient balance. Max: ${maxAmount.toFixed(token === 'SOL' ? 4 : 2)} ${token}`);
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
setStep('confirm');
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const handleSend = async () => {
|
| 43 |
+
setLoading(true);
|
| 44 |
+
setError('');
|
| 45 |
+
try {
|
| 46 |
+
let result;
|
| 47 |
+
if (window.solvox) {
|
| 48 |
+
if (token === 'SOL') {
|
| 49 |
+
result = await window.solvox.wallet.sendSOL(to.trim(), parseFloat(amount));
|
| 50 |
+
} else {
|
| 51 |
+
result = await window.solvox.wallet.sendUSDT(to.trim(), parseFloat(amount));
|
| 52 |
+
}
|
| 53 |
+
} else {
|
| 54 |
+
result = { success: true, signature: 'dev_sig_123', explorer: '#' };
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
if (result.success) {
|
| 58 |
+
setSuccess({ signature: result.signature!, explorer: result.explorer! });
|
| 59 |
+
setStep('result');
|
| 60 |
+
onSent();
|
| 61 |
+
} else {
|
| 62 |
+
setError(result.error || 'Transaction failed');
|
| 63 |
+
setStep('form');
|
| 64 |
+
}
|
| 65 |
+
} catch (err: any) {
|
| 66 |
+
setError(err.message);
|
| 67 |
+
setStep('form');
|
| 68 |
+
}
|
| 69 |
+
setLoading(false);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const reset = () => {
|
| 73 |
+
setTo('');
|
| 74 |
+
setAmount('');
|
| 75 |
+
setMemo('');
|
| 76 |
+
setError('');
|
| 77 |
+
setSuccess(null);
|
| 78 |
+
setStep('form');
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<div className="max-w-lg mx-auto slide-enter">
|
| 83 |
+
<h2 className="text-2xl font-bold mb-6">Send {token}</h2>
|
| 84 |
+
|
| 85 |
+
{step === 'form' && (
|
| 86 |
+
<div className="glass rounded-2xl p-6 space-y-5">
|
| 87 |
+
{/* Token Selector */}
|
| 88 |
+
<div className="flex gap-2">
|
| 89 |
+
{(['SOL', 'USDT'] as const).map(t => (
|
| 90 |
+
<button
|
| 91 |
+
key={t}
|
| 92 |
+
onClick={() => setToken(t)}
|
| 93 |
+
className={`flex-1 py-2 rounded-xl font-semibold transition-all ${
|
| 94 |
+
token === t
|
| 95 |
+
? t === 'SOL' ? 'bg-sol-purple text-white' : 'bg-tether-green text-white'
|
| 96 |
+
: 'bg-sol-dark text-sol-muted border border-sol-border'
|
| 97 |
+
}`}
|
| 98 |
+
>
|
| 99 |
+
{t === 'SOL' ? '◎' : '₮'} {t}
|
| 100 |
+
</button>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{/* Recipient */}
|
| 105 |
+
<div>
|
| 106 |
+
<label className="text-sm text-sol-muted block mb-1">Recipient Address</label>
|
| 107 |
+
<input
|
| 108 |
+
value={to}
|
| 109 |
+
onChange={(e) => setTo(e.target.value)}
|
| 110 |
+
placeholder="Enter Solana address"
|
| 111 |
+
className="w-full px-4 py-3 bg-sol-dark border border-sol-border rounded-xl font-mono text-sm focus:border-sol-purple focus:outline-none"
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
{/* Amount */}
|
| 116 |
+
<div>
|
| 117 |
+
<div className="flex justify-between items-center mb-1">
|
| 118 |
+
<label className="text-sm text-sol-muted">Amount</label>
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => setAmount(maxAmount.toString())}
|
| 121 |
+
className="text-xs text-sol-purple hover:underline"
|
| 122 |
+
>
|
| 123 |
+
Max: {maxAmount.toFixed(token === 'SOL' ? 4 : 2)} {token}
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="relative">
|
| 127 |
+
<input
|
| 128 |
+
type="number"
|
| 129 |
+
value={amount}
|
| 130 |
+
onChange={(e) => setAmount(e.target.value)}
|
| 131 |
+
placeholder="0.00"
|
| 132 |
+
step="any"
|
| 133 |
+
min="0"
|
| 134 |
+
className="w-full px-4 py-3 bg-sol-dark border border-sol-border rounded-xl text-lg font-bold focus:border-sol-purple focus:outline-none pr-16"
|
| 135 |
+
/>
|
| 136 |
+
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sol-muted font-semibold">
|
| 137 |
+
{token}
|
| 138 |
+
</span>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{error && (
|
| 143 |
+
<div className="text-danger text-sm bg-danger/10 rounded-lg p-3">{error}</div>
|
| 144 |
+
)}
|
| 145 |
+
|
| 146 |
+
<button
|
| 147 |
+
onClick={handleConfirm}
|
| 148 |
+
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 transition-all"
|
| 149 |
+
>
|
| 150 |
+
Review Transaction
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
)}
|
| 154 |
+
|
| 155 |
+
{step === 'confirm' && (
|
| 156 |
+
<div className="glass rounded-2xl p-6 space-y-4">
|
| 157 |
+
<h3 className="text-lg font-semibold text-center">Confirm Transaction</h3>
|
| 158 |
+
<div className="bg-sol-dark rounded-xl p-4 space-y-3">
|
| 159 |
+
<div className="flex justify-between">
|
| 160 |
+
<span className="text-sol-muted">Token</span>
|
| 161 |
+
<span className="font-semibold">{token}</span>
|
| 162 |
+
</div>
|
| 163 |
+
<div className="flex justify-between">
|
| 164 |
+
<span className="text-sol-muted">Amount</span>
|
| 165 |
+
<span className="font-bold text-lg">{amount} {token}</span>
|
| 166 |
+
</div>
|
| 167 |
+
<div className="flex justify-between">
|
| 168 |
+
<span className="text-sol-muted">To</span>
|
| 169 |
+
<span className="font-mono text-xs">{to.slice(0, 12)}...{to.slice(-8)}</span>
|
| 170 |
+
</div>
|
| 171 |
+
<div className="flex justify-between">
|
| 172 |
+
<span className="text-sol-muted">Network Fee</span>
|
| 173 |
+
<span className="text-sm">~0.000005 SOL</span>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
{/* Security Warning */}
|
| 178 |
+
<div className="bg-warning/10 border border-warning/30 rounded-xl p-3 text-sm">
|
| 179 |
+
<div className="font-semibold text-warning mb-1">⚠️ Verify Details</div>
|
| 180 |
+
<div className="text-sol-muted text-xs">
|
| 181 |
+
Please double-check the recipient address and amount. Blockchain transactions are irreversible.
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{error && <div className="text-danger text-sm">{error}</div>}
|
| 186 |
+
|
| 187 |
+
<div className="flex gap-3">
|
| 188 |
+
<button
|
| 189 |
+
onClick={() => setStep('form')}
|
| 190 |
+
className="flex-1 py-3 rounded-xl border border-sol-border text-sol-muted hover:text-sol-text transition-colors"
|
| 191 |
+
>
|
| 192 |
+
Cancel
|
| 193 |
+
</button>
|
| 194 |
+
<button
|
| 195 |
+
onClick={handleSend}
|
| 196 |
+
disabled={loading}
|
| 197 |
+
className="flex-1 py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 transition-all"
|
| 198 |
+
>
|
| 199 |
+
{loading ? 'Sending...' : `Send ${token}`}
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
)}
|
| 204 |
+
|
| 205 |
+
{step === 'result' && success && (
|
| 206 |
+
<div className="glass rounded-2xl p-6 text-center space-y-4 slide-enter">
|
| 207 |
+
<div className="text-6xl mb-2">✅</div>
|
| 208 |
+
<h3 className="text-2xl font-bold">Transaction Sent!</h3>
|
| 209 |
+
<p className="text-sol-muted">
|
| 210 |
+
{amount} {token} sent successfully
|
| 211 |
+
</p>
|
| 212 |
+
<div className="bg-sol-dark rounded-xl p-3 font-mono text-xs text-sol-muted break-all">
|
| 213 |
+
{success.signature}
|
| 214 |
+
</div>
|
| 215 |
+
<a
|
| 216 |
+
href={success.explorer}
|
| 217 |
+
target="_blank"
|
| 218 |
+
rel="noopener noreferrer"
|
| 219 |
+
className="block text-sol-purple text-sm hover:underline"
|
| 220 |
+
>
|
| 221 |
+
View on Solscan →
|
| 222 |
+
</a>
|
| 223 |
+
<button
|
| 224 |
+
onClick={reset}
|
| 225 |
+
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 transition-all"
|
| 226 |
+
>
|
| 227 |
+
Send Another
|
| 228 |
+
</button>
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
);
|
| 233 |
+
}
|
src/renderer/pages/SettingsPage.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface SettingsPageProps {
|
| 4 |
+
onLock: () => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function SettingsPage({ onLock }: SettingsPageProps) {
|
| 8 |
+
const [network, setNetwork] = useState('devnet');
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="space-y-6 max-w-2xl slide-enter">
|
| 12 |
+
<h2 className="text-2xl font-bold">⚙️ Settings</h2>
|
| 13 |
+
|
| 14 |
+
{/* Network */}
|
| 15 |
+
<div className="glass rounded-2xl p-6">
|
| 16 |
+
<h3 className="text-lg font-semibold mb-4">Network</h3>
|
| 17 |
+
<div className="flex gap-2">
|
| 18 |
+
{['devnet', 'mainnet-beta', 'testnet'].map(net => (
|
| 19 |
+
<button
|
| 20 |
+
key={net}
|
| 21 |
+
onClick={() => setNetwork(net)}
|
| 22 |
+
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
| 23 |
+
network === net
|
| 24 |
+
? 'bg-sol-purple text-white'
|
| 25 |
+
: 'bg-sol-dark border border-sol-border text-sol-muted hover:text-sol-text'
|
| 26 |
+
}`}
|
| 27 |
+
>
|
| 28 |
+
{net === 'mainnet-beta' ? 'Mainnet' : net.charAt(0).toUpperCase() + net.slice(1)}
|
| 29 |
+
</button>
|
| 30 |
+
))}
|
| 31 |
+
</div>
|
| 32 |
+
<p className="text-xs text-sol-muted mt-2">
|
| 33 |
+
{network === 'devnet' && '⚠️ Devnet — test tokens only. No real value.'}
|
| 34 |
+
{network === 'mainnet-beta' && '🔴 Mainnet — real tokens. Transactions are irreversible.'}
|
| 35 |
+
{network === 'testnet' && '⚠️ Testnet — for development purposes.'}
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* AI Models */}
|
| 40 |
+
<div className="glass rounded-2xl p-6">
|
| 41 |
+
<h3 className="text-lg font-semibold mb-4">🧠 AI Models (QVAC)</h3>
|
| 42 |
+
<p className="text-sm text-sol-muted mb-4">
|
| 43 |
+
SolVox uses 6 QVAC AI packages, all running locally on your device.
|
| 44 |
+
</p>
|
| 45 |
+
<div className="space-y-3">
|
| 46 |
+
{[
|
| 47 |
+
{ name: 'LLM', model: 'Llama 3.2 3B Instruct (Q4_K_M)', size: '~2.0 GB', pkg: '@qvac/llm-llamacpp' },
|
| 48 |
+
{ name: 'Embeddings', model: 'Nomic Embed Text v1.5', size: '~260 MB', pkg: '@qvac/embed-llamacpp' },
|
| 49 |
+
{ name: 'Speech-to-Text', model: 'Whisper Base.en', size: '~150 MB', pkg: '@qvac/transcription-whispercpp' },
|
| 50 |
+
{ name: 'Text-to-Speech', model: 'Amy (en_US, medium)', size: '~75 MB', pkg: '@qvac/tts-onnx' },
|
| 51 |
+
{ name: 'Translation', model: 'OPUS MT (EN↔ES)', size: '~50 MB', pkg: '@qvac/translation-nmtcpp' },
|
| 52 |
+
{ name: 'OCR', model: 'PaddleOCR v4', size: '~30 MB', pkg: '@qvac/ocr-onnx' },
|
| 53 |
+
].map(m => (
|
| 54 |
+
<div key={m.name} className="flex items-center justify-between bg-sol-dark rounded-xl p-3">
|
| 55 |
+
<div>
|
| 56 |
+
<div className="text-sm font-semibold">{m.name}</div>
|
| 57 |
+
<div className="text-xs text-sol-muted">{m.model}</div>
|
| 58 |
+
<div className="text-xs text-sol-muted font-mono">{m.pkg}</div>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="text-right">
|
| 61 |
+
<div className="text-xs text-sol-muted">{m.size}</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
))}
|
| 65 |
+
</div>
|
| 66 |
+
<div className="mt-4 pt-3 border-t border-sol-border">
|
| 67 |
+
<p className="text-xs text-sol-muted">
|
| 68 |
+
Total model size: ~2.6 GB | All models are stored locally in the <code className="text-sol-purple">models/</code> directory.
|
| 69 |
+
Models run on any GPU via Vulkan API — no CUDA required.
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
{/* About */}
|
| 75 |
+
<div className="glass rounded-2xl p-6">
|
| 76 |
+
<h3 className="text-lg font-semibold mb-4">About SolVox</h3>
|
| 77 |
+
<div className="space-y-2 text-sm text-sol-muted">
|
| 78 |
+
<p><strong className="text-sol-text">SolVox</strong> is a voice-first, privacy-preserving AI wallet for the Solana blockchain.</p>
|
| 79 |
+
<p>
|
| 80 |
+
Powered by <strong className="text-tether-green">Tether's QVAC SDK</strong> — a complete platform for running AI models
|
| 81 |
+
directly on any device, without routing data through a centralized cloud.
|
| 82 |
+
</p>
|
| 83 |
+
<div className="flex flex-wrap gap-2 mt-3">
|
| 84 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Electron</span>
|
| 85 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">React</span>
|
| 86 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">TypeScript</span>
|
| 87 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">QVAC SDK</span>
|
| 88 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Solana</span>
|
| 89 |
+
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Vulkan</span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div className="mt-4 pt-3 border-t border-sol-border text-xs text-sol-muted">
|
| 93 |
+
Built for the Colosseum Frontier Hackathon • Tether QVAC Track
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Danger Zone */}
|
| 98 |
+
<div className="rounded-2xl border-2 border-danger/30 p-6">
|
| 99 |
+
<h3 className="text-lg font-semibold text-danger mb-4">⚠️ Danger Zone</h3>
|
| 100 |
+
<div className="space-y-3">
|
| 101 |
+
<button
|
| 102 |
+
onClick={onLock}
|
| 103 |
+
className="w-full py-3 rounded-xl border border-danger text-danger hover:bg-danger/10 transition-colors font-medium"
|
| 104 |
+
>
|
| 105 |
+
🔒 Lock Wallet Now
|
| 106 |
+
</button>
|
| 107 |
+
<p className="text-xs text-sol-muted text-center">
|
| 108 |
+
Locking zeroes your private key from memory. You'll need your PIN to unlock.
|
| 109 |
+
</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
}
|
src/renderer/pages/VoicePage.tsx
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface VoicePageProps {
|
| 4 |
+
aiStatus: any;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
interface Message {
|
| 8 |
+
id: string;
|
| 9 |
+
role: 'user' | 'assistant' | 'system';
|
| 10 |
+
text: string;
|
| 11 |
+
intent?: any;
|
| 12 |
+
timestamp: Date;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function VoicePage({ aiStatus }: VoicePageProps) {
|
| 16 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 17 |
+
{
|
| 18 |
+
id: '0',
|
| 19 |
+
role: 'assistant',
|
| 20 |
+
text: 'Hello! I\'m your SolVox AI assistant. I can help you send SOL and USDT, check your balance, view transactions, and more. Try saying "What is my balance?" or type a command below.',
|
| 21 |
+
timestamp: new Date(),
|
| 22 |
+
},
|
| 23 |
+
]);
|
| 24 |
+
const [input, setInput] = useState('');
|
| 25 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 26 |
+
const [isProcessing, setIsProcessing] = useState(false);
|
| 27 |
+
const [waveformData, setWaveformData] = useState<number[]>(new Array(32).fill(4));
|
| 28 |
+
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 29 |
+
const audioChunks = useRef<Blob[]>([]);
|
| 30 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 31 |
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 32 |
+
const animFrameRef = useRef<number | null>(null);
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 36 |
+
}, [messages]);
|
| 37 |
+
|
| 38 |
+
// ── Voice Recording ──
|
| 39 |
+
const startRecording = async () => {
|
| 40 |
+
try {
|
| 41 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 42 |
+
audio: {
|
| 43 |
+
sampleRate: 16000,
|
| 44 |
+
channelCount: 1,
|
| 45 |
+
echoCancellation: true,
|
| 46 |
+
noiseSuppression: true,
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
// Waveform visualization
|
| 51 |
+
const audioContext = new AudioContext();
|
| 52 |
+
const source = audioContext.createMediaStreamSource(stream);
|
| 53 |
+
const analyser = audioContext.createAnalyser();
|
| 54 |
+
analyser.fftSize = 64;
|
| 55 |
+
source.connect(analyser);
|
| 56 |
+
analyserRef.current = analyser;
|
| 57 |
+
|
| 58 |
+
const updateWaveform = () => {
|
| 59 |
+
if (!analyserRef.current) return;
|
| 60 |
+
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
| 61 |
+
analyserRef.current.getByteFrequencyData(dataArray);
|
| 62 |
+
const normalized = Array.from(dataArray).slice(0, 32).map(v => Math.max(4, v / 8));
|
| 63 |
+
setWaveformData(normalized);
|
| 64 |
+
animFrameRef.current = requestAnimationFrame(updateWaveform);
|
| 65 |
+
};
|
| 66 |
+
updateWaveform();
|
| 67 |
+
|
| 68 |
+
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
| 69 |
+
audioChunks.current = [];
|
| 70 |
+
|
| 71 |
+
recorder.ondataavailable = (e) => {
|
| 72 |
+
if (e.data.size > 0) audioChunks.current.push(e.data);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
recorder.onstop = async () => {
|
| 76 |
+
stream.getTracks().forEach(t => t.stop());
|
| 77 |
+
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
| 78 |
+
setWaveformData(new Array(32).fill(4));
|
| 79 |
+
|
| 80 |
+
const blob = new Blob(audioChunks.current, { type: 'audio/webm' });
|
| 81 |
+
const buffer = await blob.arrayBuffer();
|
| 82 |
+
processVoice(buffer);
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
mediaRecorder.current = recorder;
|
| 86 |
+
recorder.start();
|
| 87 |
+
setIsRecording(true);
|
| 88 |
+
} catch (err) {
|
| 89 |
+
addMessage('system', 'Microphone access denied. Please enable microphone permissions.');
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const stopRecording = () => {
|
| 94 |
+
if (mediaRecorder.current && isRecording) {
|
| 95 |
+
mediaRecorder.current.stop();
|
| 96 |
+
setIsRecording(false);
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
// ── Process Voice Command ──
|
| 101 |
+
const processVoice = async (audioData: ArrayBuffer) => {
|
| 102 |
+
setIsProcessing(true);
|
| 103 |
+
try {
|
| 104 |
+
if (window.solvox) {
|
| 105 |
+
const result = await window.solvox.ai.processVoice(audioData);
|
| 106 |
+
if (result.success) {
|
| 107 |
+
addMessage('user', result.transcription || '[voice input]');
|
| 108 |
+
addMessage('assistant', result.response || 'Done.', result.intent);
|
| 109 |
+
|
| 110 |
+
// Play audio response if available
|
| 111 |
+
if (result.audio) {
|
| 112 |
+
playAudio(result.audio);
|
| 113 |
+
}
|
| 114 |
+
} else {
|
| 115 |
+
addMessage('system', `Voice processing failed: ${result.error}`);
|
| 116 |
+
}
|
| 117 |
+
} else {
|
| 118 |
+
addMessage('user', '[voice input - dev mode]');
|
| 119 |
+
addMessage('assistant', 'Voice processing requires QVAC models. In dev mode, use text commands.');
|
| 120 |
+
}
|
| 121 |
+
} catch (err: any) {
|
| 122 |
+
addMessage('system', `Error: ${err.message}`);
|
| 123 |
+
}
|
| 124 |
+
setIsProcessing(false);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
// ── Text Chat ──
|
| 128 |
+
const handleSendMessage = async () => {
|
| 129 |
+
if (!input.trim()) return;
|
| 130 |
+
const text = input.trim();
|
| 131 |
+
setInput('');
|
| 132 |
+
|
| 133 |
+
addMessage('user', text);
|
| 134 |
+
setIsProcessing(true);
|
| 135 |
+
|
| 136 |
+
try {
|
| 137 |
+
if (window.solvox) {
|
| 138 |
+
// First parse intent
|
| 139 |
+
const intentResult = await window.solvox.ai.parseIntent(text);
|
| 140 |
+
const intent = intentResult.success ? intentResult.intent : null;
|
| 141 |
+
|
| 142 |
+
// Then get AI response
|
| 143 |
+
const chatResult = await window.solvox.ai.chat(text);
|
| 144 |
+
if (chatResult.success) {
|
| 145 |
+
addMessage('assistant', chatResult.response!, intent);
|
| 146 |
+
} else {
|
| 147 |
+
addMessage('assistant', chatResult.error || 'Sorry, I could not process that.');
|
| 148 |
+
}
|
| 149 |
+
} else {
|
| 150 |
+
// Dev mode fallback
|
| 151 |
+
addMessage('assistant', `[Dev mode] You said: "${text}". QVAC models needed for AI responses.`);
|
| 152 |
+
}
|
| 153 |
+
} catch (err: any) {
|
| 154 |
+
addMessage('system', `Error: ${err.message}`);
|
| 155 |
+
}
|
| 156 |
+
setIsProcessing(false);
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
// ── Helpers ──
|
| 160 |
+
const addMessage = (role: 'user' | 'assistant' | 'system', text: string, intent?: any) => {
|
| 161 |
+
setMessages(prev => [...prev, {
|
| 162 |
+
id: Date.now().toString(),
|
| 163 |
+
role,
|
| 164 |
+
text,
|
| 165 |
+
intent,
|
| 166 |
+
timestamp: new Date(),
|
| 167 |
+
}]);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
const playAudio = (audioData: ArrayBuffer) => {
|
| 171 |
+
try {
|
| 172 |
+
const blob = new Blob([audioData], { type: 'audio/wav' });
|
| 173 |
+
const url = URL.createObjectURL(blob);
|
| 174 |
+
const audio = new Audio(url);
|
| 175 |
+
audio.play().catch(() => {});
|
| 176 |
+
audio.onended = () => URL.revokeObjectURL(url);
|
| 177 |
+
} catch {}
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
return (
|
| 181 |
+
<div className="flex flex-col h-full slide-enter">
|
| 182 |
+
{/* Header */}
|
| 183 |
+
<div className="flex items-center justify-between mb-4">
|
| 184 |
+
<div>
|
| 185 |
+
<h2 className="text-2xl font-bold">🎤 Voice AI Assistant</h2>
|
| 186 |
+
<p className="text-sm text-sol-muted">
|
| 187 |
+
Powered by 6 QVAC packages • All local, all private
|
| 188 |
+
</p>
|
| 189 |
+
</div>
|
| 190 |
+
<div className="flex items-center gap-2">
|
| 191 |
+
{aiStatus?.llm ? (
|
| 192 |
+
<span className="px-3 py-1 rounded-full bg-sol-green/20 text-sol-green text-xs">
|
| 193 |
+
AI Online
|
| 194 |
+
</span>
|
| 195 |
+
) : (
|
| 196 |
+
<span className="px-3 py-1 rounded-full bg-warning/20 text-warning text-xs">
|
| 197 |
+
Loading Models...
|
| 198 |
+
</span>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Chat Messages */}
|
| 204 |
+
<div className="flex-1 overflow-y-auto glass rounded-2xl p-4 mb-4 space-y-4">
|
| 205 |
+
{messages.map(msg => (
|
| 206 |
+
<div
|
| 207 |
+
key={msg.id}
|
| 208 |
+
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 209 |
+
>
|
| 210 |
+
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
| 211 |
+
msg.role === 'user'
|
| 212 |
+
? 'bg-sol-purple text-white'
|
| 213 |
+
: msg.role === 'system'
|
| 214 |
+
? 'bg-warning/10 text-warning border border-warning/30'
|
| 215 |
+
: 'bg-sol-dark border border-sol-border'
|
| 216 |
+
}`}>
|
| 217 |
+
<p className="text-sm whitespace-pre-wrap">{msg.text}</p>
|
| 218 |
+
{msg.intent && (
|
| 219 |
+
<div className="mt-2 pt-2 border-t border-sol-border/30">
|
| 220 |
+
<div className="text-xs font-mono text-sol-muted">
|
| 221 |
+
Intent: {msg.intent.action} | Confidence: {(msg.intent.confidence * 100).toFixed(0)}%
|
| 222 |
+
{msg.intent.amount && ` | Amount: ${msg.intent.amount} ${msg.intent.token || ''}`}
|
| 223 |
+
{msg.intent.to && ` | To: ${msg.intent.to}`}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
<div className="text-xs text-sol-muted/50 mt-1">
|
| 228 |
+
{msg.timestamp.toLocaleTimeString()}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
))}
|
| 233 |
+
{isProcessing && (
|
| 234 |
+
<div className="flex justify-start">
|
| 235 |
+
<div className="bg-sol-dark border border-sol-border rounded-2xl px-4 py-3">
|
| 236 |
+
<div className="flex space-x-1.5">
|
| 237 |
+
<div className="w-2 h-2 bg-sol-purple rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 238 |
+
<div className="w-2 h-2 bg-sol-purple rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 239 |
+
<div className="w-2 h-2 bg-sol-purple rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
<div ref={messagesEndRef} />
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* Waveform Visualizer (visible during recording) */}
|
| 248 |
+
{isRecording && (
|
| 249 |
+
<div className="flex items-center justify-center gap-0.5 h-12 mb-4">
|
| 250 |
+
{waveformData.map((h, i) => (
|
| 251 |
+
<div
|
| 252 |
+
key={i}
|
| 253 |
+
className="w-1.5 bg-sol-purple rounded-full transition-all duration-75"
|
| 254 |
+
style={{ height: `${h}px` }}
|
| 255 |
+
/>
|
| 256 |
+
))}
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
|
| 260 |
+
{/* Input Area */}
|
| 261 |
+
<div className="flex items-center gap-3">
|
| 262 |
+
{/* Voice Button */}
|
| 263 |
+
<button
|
| 264 |
+
onMouseDown={startRecording}
|
| 265 |
+
onMouseUp={stopRecording}
|
| 266 |
+
onMouseLeave={stopRecording}
|
| 267 |
+
onTouchStart={startRecording}
|
| 268 |
+
onTouchEnd={stopRecording}
|
| 269 |
+
disabled={isProcessing}
|
| 270 |
+
className={`w-14 h-14 rounded-full flex items-center justify-center transition-all ${
|
| 271 |
+
isRecording
|
| 272 |
+
? 'bg-danger recording-pulse scale-110'
|
| 273 |
+
: 'bg-sol-purple hover:bg-sol-purple/80 glow-purple'
|
| 274 |
+
} disabled:opacity-50`}
|
| 275 |
+
title="Hold to speak"
|
| 276 |
+
>
|
| 277 |
+
<span className="text-2xl">{isRecording ? '⏹' : '🎤'}</span>
|
| 278 |
+
</button>
|
| 279 |
+
|
| 280 |
+
{/* Text Input */}
|
| 281 |
+
<div className="flex-1 relative">
|
| 282 |
+
<input
|
| 283 |
+
value={input}
|
| 284 |
+
onChange={(e) => setInput(e.target.value)}
|
| 285 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
| 286 |
+
placeholder="Type a command or hold 🎤 to speak..."
|
| 287 |
+
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-sm focus:border-sol-purple focus:outline-none pr-16"
|
| 288 |
+
disabled={isProcessing}
|
| 289 |
+
/>
|
| 290 |
+
<button
|
| 291 |
+
onClick={handleSendMessage}
|
| 292 |
+
disabled={!input.trim() || isProcessing}
|
| 293 |
+
className="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-sol-purple text-white text-sm disabled:opacity-30 hover:bg-sol-purple/80 transition-all"
|
| 294 |
+
>
|
| 295 |
+
Send
|
| 296 |
+
</button>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
);
|
| 301 |
+
}
|
src/renderer/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SolVox Type Definitions for the Preload Bridge
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export interface SolvoxAPI {
|
| 6 |
+
wallet: {
|
| 7 |
+
create: () => Promise<{ success: boolean; publicKey?: string; error?: string }>;
|
| 8 |
+
import: (mnemonic: string) => Promise<{ success: boolean; publicKey?: string; error?: string }>;
|
| 9 |
+
getPublicKey: () => Promise<string | null>;
|
| 10 |
+
getBalance: () => Promise<{ success: boolean; sol?: number; usdt?: number; error?: string }>;
|
| 11 |
+
sendSOL: (to: string, amount: number) => Promise<{ success: boolean; signature?: string; explorer?: string; error?: string }>;
|
| 12 |
+
sendUSDT: (to: string, amount: number) => Promise<{ success: boolean; signature?: string; explorer?: string; error?: string }>;
|
| 13 |
+
getHistory: (limit?: number) => Promise<{ success: boolean; history?: any[]; error?: string }>;
|
| 14 |
+
isUnlocked: () => Promise<boolean>;
|
| 15 |
+
lock: () => Promise<{ success: boolean }>;
|
| 16 |
+
exists: () => Promise<boolean>;
|
| 17 |
+
};
|
| 18 |
+
auth: {
|
| 19 |
+
biometric: (reason?: string) => Promise<{ success: boolean; error?: string }>;
|
| 20 |
+
unlock: (pin: string) => Promise<{ success: boolean; error?: string; remainingAttempts?: number }>;
|
| 21 |
+
setPin: (pin: string) => Promise<{ success: boolean; error?: string }>;
|
| 22 |
+
biometricAvailable: () => Promise<boolean>;
|
| 23 |
+
};
|
| 24 |
+
security: {
|
| 25 |
+
getSettings: () => Promise<any>;
|
| 26 |
+
updateSettings: (settings: any) => Promise<{ success: boolean }>;
|
| 27 |
+
addWhitelist: (address: string, label: string) => Promise<{ success: boolean; error?: string }>;
|
| 28 |
+
removeWhitelist: (address: string) => Promise<{ success: boolean }>;
|
| 29 |
+
getWhitelist: () => Promise<any[]>;
|
| 30 |
+
getAnomalies: () => Promise<any[]>;
|
| 31 |
+
};
|
| 32 |
+
ai: {
|
| 33 |
+
initialize: () => Promise<{ success: boolean; error?: string }>;
|
| 34 |
+
processVoice: (audioData: ArrayBuffer) => Promise<{ success: boolean; transcription?: string; intent?: any; response?: string; audio?: ArrayBuffer; error?: string }>;
|
| 35 |
+
chat: (message: string) => Promise<{ success: boolean; response?: string; error?: string }>;
|
| 36 |
+
parseIntent: (text: string) => Promise<{ success: boolean; intent?: any; error?: string }>;
|
| 37 |
+
speak: (text: string) => Promise<{ success: boolean; audio?: ArrayBuffer; error?: string }>;
|
| 38 |
+
translate: (text: string, from: string, to: string) => Promise<{ success: boolean; translated?: string; error?: string }>;
|
| 39 |
+
embed: (text: string) => Promise<{ success: boolean; vector?: number[]; error?: string }>;
|
| 40 |
+
ocr: (imageData: ArrayBuffer) => Promise<{ success: boolean; text?: string; error?: string }>;
|
| 41 |
+
getStatus: () => Promise<any>;
|
| 42 |
+
};
|
| 43 |
+
rag: {
|
| 44 |
+
search: (query: string) => Promise<{ success: boolean; results?: any[]; error?: string }>;
|
| 45 |
+
addDocument: (text: string, metadata: any) => Promise<{ success: boolean; error?: string }>;
|
| 46 |
+
};
|
| 47 |
+
on: {
|
| 48 |
+
locked: (callback: () => void) => () => void;
|
| 49 |
+
balanceUpdate: (callback: (data: any) => void) => () => void;
|
| 50 |
+
aiStatus: (callback: (data: any) => void) => () => void;
|
| 51 |
+
transactionAlert: (callback: (data: any) => void) => () => void;
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
declare global {
|
| 56 |
+
interface Window {
|
| 57 |
+
solvox: SolvoxAPI;
|
| 58 |
+
}
|
| 59 |
+
}
|
tailwind.config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
module.exports = {
|
| 3 |
+
content: ['./src/renderer/**/*.{ts,tsx,html}'],
|
| 4 |
+
darkMode: 'class',
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
'sol-purple': '#9945FF',
|
| 9 |
+
'sol-green': '#14F195',
|
| 10 |
+
'sol-dark': '#0E0E2C',
|
| 11 |
+
'sol-card': '#1A1A3E',
|
| 12 |
+
'sol-border': '#2D2D5E',
|
| 13 |
+
'sol-text': '#E0E0FF',
|
| 14 |
+
'sol-muted': '#8888AA',
|
| 15 |
+
'tether-green': '#26A17B',
|
| 16 |
+
'danger': '#FF4466',
|
| 17 |
+
'warning': '#FFB830',
|
| 18 |
+
},
|
| 19 |
+
fontFamily: {
|
| 20 |
+
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
| 21 |
+
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
| 22 |
+
},
|
| 23 |
+
animation: {
|
| 24 |
+
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
| 25 |
+
'waveform': 'waveform 0.5s ease-in-out infinite alternate',
|
| 26 |
+
'slide-up': 'slide-up 0.3s ease-out',
|
| 27 |
+
'fade-in': 'fade-in 0.2s ease-out',
|
| 28 |
+
},
|
| 29 |
+
keyframes: {
|
| 30 |
+
'pulse-glow': {
|
| 31 |
+
'0%, 100%': { boxShadow: '0 0 20px rgba(153, 69, 255, 0.3)' },
|
| 32 |
+
'50%': { boxShadow: '0 0 40px rgba(153, 69, 255, 0.6)' },
|
| 33 |
+
},
|
| 34 |
+
'waveform': {
|
| 35 |
+
'0%': { height: '4px' },
|
| 36 |
+
'100%': { height: '24px' },
|
| 37 |
+
},
|
| 38 |
+
'slide-up': {
|
| 39 |
+
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
| 40 |
+
'100%': { transform: 'translateY(0)', opacity: '1' },
|
| 41 |
+
},
|
| 42 |
+
'fade-in': {
|
| 43 |
+
'0%': { opacity: '0' },
|
| 44 |
+
'100%': { opacity: '1' },
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
plugins: [],
|
| 50 |
+
};
|
tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"moduleResolution": "bundler",
|
| 7 |
+
"jsx": "react-jsx",
|
| 8 |
+
"strict": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"forceConsistentCasingInFileNames": true,
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
"baseUrl": ".",
|
| 16 |
+
"paths": {
|
| 17 |
+
"@/*": ["src/renderer/*"]
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx"],
|
| 21 |
+
"exclude": ["node_modules"]
|
| 22 |
+
}
|
tsconfig.main.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"module": "commonjs",
|
| 5 |
+
"lib": ["ES2022"],
|
| 6 |
+
"outDir": "dist/main",
|
| 7 |
+
"rootDir": "src/main",
|
| 8 |
+
"strict": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"forceConsistentCasingInFileNames": true,
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"declaration": true,
|
| 14 |
+
"declarationMap": true,
|
| 15 |
+
"sourceMap": true
|
| 16 |
+
},
|
| 17 |
+
"include": ["src/main/**/*.ts"],
|
| 18 |
+
"exclude": ["node_modules"]
|
| 19 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
root: 'src/renderer',
|
| 8 |
+
base: './',
|
| 9 |
+
build: {
|
| 10 |
+
outDir: '../../dist/renderer',
|
| 11 |
+
emptyOutDir: true,
|
| 12 |
+
},
|
| 13 |
+
resolve: {
|
| 14 |
+
alias: {
|
| 15 |
+
'@': path.resolve(__dirname, 'src/renderer'),
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
server: {
|
| 19 |
+
port: 5173,
|
| 20 |
+
},
|
| 21 |
+
});
|