Compare commits

...

22 commits

Author SHA1 Message Date
Gulshan Yadav
0b249a1196 feat: add configuration files for MCP and update project documentation
Some checks failed
CI / Check (push) Failing after 2s
CI / Test (push) Failing after 2s
CI / Build (Linux x86_64) (push) Has been skipped
Security Audit / Vulnerability Scan (push) Failing after 2s
Security Audit / License & Security Policy (push) Failing after 2s
Security Audit / Static Analysis (Clippy) (push) Failing after 2s
Security Audit / Secret Detection (push) Failing after 2s
Security Audit / Check Outdated Dependencies (push) Failing after 2s
Security Audit / Unsafe Code Audit (push) Failing after 2s
Security Audit / Property-Based Testing (push) Failing after 2s
Security Audit / WASM Module Security (push) Failing after 2s
CI / Benchmarks (push) Has been skipped
CI / CI Success (push) Failing after 1s
2026-03-30 08:24:08 +05:30
Gulshan Yadav
d9fd97bb96 chore: migrate from GitHub to Forgejo (git.misar.io)
Some checks failed
CI / Check (push) Failing after 2s
CI / Test (push) Failing after 2s
CI / Build (Linux x86_64) (push) Has been skipped
Security Audit / Vulnerability Scan (push) Failing after 2s
Security Audit / License & Security Policy (push) Failing after 2s
Security Audit / Static Analysis (Clippy) (push) Failing after 2s
Security Audit / Secret Detection (push) Failing after 2s
Security Audit / Check Outdated Dependencies (push) Failing after 2s
Security Audit / Unsafe Code Audit (push) Failing after 2s
Security Audit / Property-Based Testing (push) Failing after 2s
Security Audit / WASM Module Security (push) Failing after 2s
CI / Benchmarks (push) Has been skipped
CI / CI Success (push) Failing after 1s
- Move .github/workflows/ to .forgejo/workflows/ (identical YAML, runner labels changed to self-hosted)
- Drop macOS/Windows CI matrix legs (no macOS/Windows runners on self-hosted act_runner)
- Update Cargo.toml repository URL to git.misar.io/misaradmin/synor
- Remove .github/dependabot.yml (not applicable on Forgejo)
2026-03-30 08:20:39 +05:30
Gulshan Yadav
563bfa3909 docs: add desktop wallet README and update main docs for v0.1.1
Some checks failed
CI / Check (ubuntu-latest) (push) Failing after 2s
CI / Test (ubuntu-latest) (push) Failing after 2s
Security Audit / Vulnerability Scan (push) Failing after 2s
Security Audit / License & Security Policy (push) Failing after 2s
Security Audit / Static Analysis (Clippy) (push) Failing after 2s
Security Audit / Secret Detection (push) Failing after 2s
Security Audit / Check Outdated Dependencies (push) Failing after 2s
Security Audit / Unsafe Code Audit (push) Failing after 2s
Security Audit / Property-Based Testing (push) Failing after 2s
Security Audit / WASM Module Security (push) Failing after 2s
CI / Check (macos-latest) (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / CI Success (push) Has been cancelled
- Create comprehensive README for desktop-wallet with features, installation,
  development guide, project structure, and changelog
- Update main README with v0.1.1 version references
- Add link to desktop wallet README from main docs
- Document QR code feature, browser preview mode, and bug fixes
2026-02-02 17:07:13 +05:30
Gulshan Yadav
e572578c8c chore: bump desktop wallet version to 0.1.1
Some checks failed
Release Desktop Wallet / Build Wallet (macos-latest) (push) Has been cancelled
Release Desktop Wallet / Build Wallet (windows-latest) (push) Has been cancelled
Release Desktop Wallet / Build Wallet (Linux) (push) Has been cancelled
2026-02-02 16:48:44 +05:30
Gulshan Yadav
099cb8942b feat: add QR code generation on Receive page
- Add qrcode.react dependency for client-side QR generation
- Replace placeholder icon with actual QRCodeSVG component
- QR code encodes the primary wallet address
2026-02-02 16:45:43 +05:30
Gulshan Yadav
9cdccece34 fix: Tauri 2.0 plugin config and icon bit depth
- Remove deprecated empty plugin configs (store, dialog, etc.)
- Remove fs plugin scope (now handled by capabilities)
- Convert icons from 16-bit to 8-bit RGBA for Tauri compatibility
2026-02-02 16:12:03 +05:30
Gulshan Yadav
681f40cb5e fix: desktop wallet navigation and build issues
- Add escape options on unlock page (import/reset wallet)
- Add confirmation modal for wallet reset with warning
- Fix Rust build errors by adding once_cell and md5 dependencies
- Remove duplicate Arc import in commands.rs
- Browser mock mode improvements for 24-word mnemonic
2026-02-02 15:37:41 +05:30
Gulshan Yadav
b6522c21ef desktop to web wallet version 2026-02-02 15:16:29 +05:30
Gulshan Yadav
f08eb965c2 a 2026-02-02 15:00:13 +05:30
Gulshan Yadav
c32622f34f expansion of desktop wallet features. 2026-02-02 14:30:07 +05:30
Gulshan Yadav
81347ab15d feat(wallet): add comprehensive desktop wallet features
Add all-in-one desktop wallet with extensive feature set:

Infrastructure:
- Storage: IPFS-based decentralized file storage with upload/download
- Hosting: Domain registration and static site hosting
- Compute: GPU/CPU job marketplace for distributed computing
- Database: Multi-model database services (KV, Document, Vector, etc.)

Financial Features:
- Privacy: Confidential transactions with Pedersen commitments
- Bridge: Cross-chain transfers (Ethereum, Bitcoin, IBC/Cosmos)
- Governance: DAO proposals, voting, and delegation
- ZK-Rollup: L2 scaling with deposits, withdrawals, and transfers

UI/UX Improvements:
- Add ErrorBoundary component for graceful error handling
- Add LoadingStates components (spinners, skeletons, overlays)
- Add Animation components (FadeIn, SlideIn, CountUp, etc.)
- Update navigation with new feature sections

Testing:
- Add Playwright E2E smoke tests
- Test route accessibility and page rendering
- Verify build process and asset loading

Build:
- Fix TypeScript compilation errors
- Update Tauri plugin dependencies
- Successfully build macOS app bundle and DMG
2026-02-02 11:35:21 +05:30
Gulshan Yadav
63c52b26b2 feat(desktop-wallet): add comprehensive wallet features
Add 10 major features to complete the desktop wallet:
- Staking: Stake SYN tokens for rewards with pool management
- DEX/Swap: Built-in token swap interface with liquidity pools
- Address Book: Save and manage frequently used addresses
- DApp Browser: Interact with decentralized applications
- Hardware Wallet: Ledger/Trezor support for secure signing
- Multi-sig Wallets: Require multiple signatures for transactions
- Price Charts: Market data and real-time price tracking
- Notifications: Push notifications for transactions and alerts
- QR Scanner: Generate and parse payment QR codes
- Backup/Export: Encrypted wallet backup and recovery

Includes Tauri backend commands for all features, Zustand stores
for state management, and complete UI pages with navigation.
2026-02-02 09:57:55 +05:30
Gulshan Yadav
d81b5fe81b feat(desktop-wallet): add NFT support
Add complete NFT (non-fungible token) functionality:

Backend (Rust/Tauri):
- nft_create_collection: Deploy new NFT collection contract
- nft_mint, nft_batch_mint: Mint single or multiple NFTs
- nft_transfer: Transfer NFT ownership
- nft_burn: Permanently destroy NFT
- nft_list_owned: List all NFTs owned by address
- nft_get_collection_info, nft_get_token_info: Query metadata
- nft_set_approval_for_all, nft_set_base_uri: Collection management

Frontend (React/TypeScript):
- NFT Zustand store with collection tracking
- NftsDashboard page with 5 tabs:
  - Gallery: Visual grid of owned NFTs with modal details
  - Collections: Track and manage NFT collections
  - Create: Deploy new collection with royalties and soulbound options
  - Mint: Mint new NFTs with metadata URIs
  - Transfer: Send NFTs to other addresses
- Navigation sidebar updated with NFTs link
- Route added for /nfts path

Features:
- Royalty configuration in basis points (e.g., 250 = 2.5%)
- Soulbound token support (non-transferable)
- Batch minting up to 100 NFTs
- Collection import by contract address
- NFT burn with confirmation dialog
2026-02-02 09:23:07 +05:30
Gulshan Yadav
a5e4fc1c21 feat(desktop-wallet): add smart contracts and tokens UI
Add complete frontend and backend implementation for smart contracts
and tokens management:

Backend (Rust/Tauri):
- Add contract_deploy, contract_call, contract_read, contract_get_info commands
- Add token_create, token_transfer, token_get_info, token_get_balance commands
- Add token_list_balances, token_mint, token_burn commands
- Add new error variants for validation and contract errors

Frontend (React/TypeScript):
- Add contracts Zustand store with persistence
- Add tokens Zustand store with persistence
- Add ContractsDashboard page with deploy/interact tabs
- Add TokensDashboard page with balances/create/transfer/manage tabs
- Update navigation sidebar with Contracts and Tokens links
- Add routes for new pages
2026-02-02 09:03:58 +05:30
Gulshan Yadav
88b09914c3 fix: resolve synor-compute clippy warnings
- Replace .clone() with dereference for Copy types
- Use #[derive(Default)] with #[default] attribute instead of manual impl
2026-02-02 08:00:01 +05:30
Gulshan Yadav
e24ce116d7 fix: resolve remaining clippy warnings
- Prefix unused parameters with underscore in stub function
- Replace .map().flatten() with .and_then()
2026-02-02 07:09:15 +05:30
Gulshan Yadav
7c7137c4f6 fix: resolve clippy warnings for Rust 1.93
- Replace manual modulo checks with .is_multiple_of()
- Use enumerate() instead of manual loop counters
- Use iterator .take() instead of index-based loops
- Use slice literals instead of unnecessary vec![]
- Allow too_many_arguments in IBC and bridge crates (protocol requirements)
- Allow assertions on constants in integration tests
2026-02-02 06:18:16 +05:30
Gulshan Yadav
dcd1cccc67 style: apply cargo fmt formatting 2026-02-02 05:58:22 +05:30
Gulshan Yadav
5126c33113 ui/ux 2026-02-02 05:12:01 +05:30
Gulshan Yadav
5cd6fdcb35 desktop wallet enhancements 2026-02-02 04:54:53 +05:30
Gulshan Yadav
01ff14c0a9 fix: use native ARM64 runner instead of cross-compilation
Some checks failed
Release / Build Release (x86_64-unknown-linux-gnu) (push) Failing after 2s
Release / Build Release (aarch64-unknown-linux-gnu) (push) Has been cancelled
Release / Build Release (aarch64-apple-darwin) (push) Has been cancelled
Release / Build Release (x86_64-apple-darwin) (push) Has been cancelled
Release / Build Release (x86_64-pc-windows-msvc) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Release / Publish to crates.io (push) Has been cancelled
- Use ubuntu-24.04-arm runner for native ARM64 builds
- Remove cross-compilation setup (OpenSSL issues in Docker)
- Native builds are more reliable and faster
2026-02-02 03:48:26 +05:30
Gulshan Yadav
b141158868 fix: use cross tool for ARM64 Linux cross-compilation
- Install and use cross-rs/cross for ARM64 builds
- Cross provides Docker containers with proper ARM64 sysroots
- Fixes OpenSSL not found error in cross-compilation
2026-02-02 03:32:14 +05:30
309 changed files with 41226 additions and 3379 deletions

18
.claude/settings.json Normal file
View file

@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"https://git.misar.io/api/v1/orgs/misaradmin\" -H \"Authorization: token 724b7d6bf7c8696d5c374057667cd8fc11d1f23d\")",
"Bash(curl -s -w \"\\\\n%{http_code}\" \"https://git.misar.io/api/v1/repos/search?q=synor&token=724b7d6bf7c8696d5c374057667cd8fc11d1f23d\")",
"Bash(ssh -i ~/.ssh/id_ed25519 -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@89.167.20.246 \"docker exec forgejo gitea admin user generate-access-token --username misaradmin --token-name claude-migration --raw 2>/dev/null || docker exec gitea gitea admin user generate-access-token --username misaradmin --token-name claude-migration --raw 2>/dev/null || echo ''FAILED''\")",
"Bash(ssh -i ~/.ssh/id_rsa -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@89.167.20.246 \"docker exec forgejo gitea admin user generate-access-token --username misaradmin --token-name claude-migration --raw 2>&1\")",
"Bash(ssh -i ~/.ssh/id_rsa -o ConnectTimeout=10 root@89.167.20.246 \"docker exec -u git forgejo gitea admin user generate-access-token --username misaradmin --token-name claude-migration --raw 2>&1\")",
"Bash(FORGEJO_TOKEN=\"a4b95d0ee9bcd156467205ddb863643d6d388004\" curl -s -X POST \"https://git.misar.io/api/v1/orgs/misaradmin/repos\" -H \"Authorization: token $FORGEJO_TOKEN\" -H \"Content-Type: application/json\" -d '{\"\"\"\"name\"\"\"\":\"\"\"\"synor\"\"\"\",\"\"\"\"description\"\"\"\":\"\"\"\"Synor Blockchain — Quantum-secure decentralized cloud computing platform\"\"\"\",\"\"\"\"private\"\"\"\":false,\"\"\"\"auto_init\"\"\"\":false}')",
"Bash(python3 -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\(''''Created:'''', r.get\\(''''html_url'''',''''ERROR''''\\), r.get\\(''''message'''',''''''''\\)\\)\")",
"Bash(ssh -i ~/.ssh/id_rsa root@89.167.20.246 \"docker exec -u git forgejo gitea admin user generate-access-token --username misaradmin --token-name claude-migration-full --raw --scopes all 2>&1\")",
"Bash(FORGEJO_TOKEN=\"ea8924c339ab375965e6970ce84e1431b07a479e\" curl -s -X POST \"https://git.misar.io/api/v1/user/repos\" -H \"Authorization: token $FORGEJO_TOKEN\" -H \"Content-Type: application/json\" -d '{\"\"\"\"name\"\"\"\":\"\"\"\"synor\"\"\"\",\"\"\"\"description\"\"\"\":\"\"\"\"Synor Blockchain — Quantum-secure decentralized cloud computing platform\"\"\"\",\"\"\"\"private\"\"\"\":false,\"\"\"\"auto_init\"\"\"\":false}')"
],
"additionalDirectories": [
"/Users/researchfellow/Desktop/G1 Technologies/synor.cc/.forgejo"
]
}
}

View file

@ -13,12 +13,8 @@ env:
jobs:
check:
name: Check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
name: Check
runs-on: self-hosted
steps:
- name: Checkout repository
@ -56,12 +52,8 @@ jobs:
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
name: Test
runs-on: self-hosted
steps:
- name: Checkout repository
@ -70,8 +62,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libclang-dev llvm-dev
@ -100,18 +91,9 @@ jobs:
run: cargo test --workspace --all-features
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
name: Build (Linux x86_64)
runs-on: self-hosted
needs: [check, test]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
include:
- os: ubuntu-latest
artifact-name: synor-linux-x86_64
- os: macos-latest
artifact-name: synor-macos-x86_64
steps:
- name: Checkout repository
@ -120,8 +102,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libclang-dev llvm-dev
@ -160,14 +141,14 @@ jobs:
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
name: synor-linux-x86_64
path: artifacts/
retention-days: 7
if-no-files-found: warn
bench:
name: Benchmarks
runs-on: ubuntu-latest
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [check, test]
@ -214,10 +195,9 @@ jobs:
retention-days: 30
if-no-files-found: ignore
# Summary job for branch protection
ci-success:
name: CI Success
runs-on: ubuntu-latest
runs-on: self-hosted
needs: [check, test, build]
if: always()
steps:

View file

@ -0,0 +1,109 @@
name: Release Desktop Wallet
on:
push:
tags:
- 'wallet-v*'
workflow_dispatch:
inputs:
draft:
description: 'Create as draft release'
required: false
default: true
type: boolean
env:
CARGO_TERM_COLOR: always
permissions:
contents: write
jobs:
# Build Linux AppImage on self-hosted runner
build-linux:
name: Build Wallet (Linux)
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
run: npm install -g pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libclang-dev \
llvm-dev
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-cargo-wallet-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-wallet-
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('apps/desktop-wallet/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
working-directory: apps/desktop-wallet
run: pnpm install
- name: Build Tauri app (Linux)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop-wallet
tagName: wallet-v__VERSION__
releaseName: 'Synor Wallet v__VERSION__'
releaseBody: |
## Synor Desktop Wallet
A secure desktop wallet for the Synor blockchain network with post-quantum cryptography support (Dilithium3).
### Installation
**Linux:**
- Download the `.AppImage` file
- Make it executable: `chmod +x Synor*.AppImage`
- Run: `./Synor*.AppImage`
### Features
- 24-word BIP39 mnemonic generation
- Post-quantum Dilithium3 signatures
- OS keychain integration
- System tray support
### Security Note
Always verify the checksums of downloaded files.
releaseDraft: ${{ github.event.inputs.draft || true }}
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

View file

@ -15,32 +15,17 @@ permissions:
jobs:
build-release:
name: Build Release (${{ matrix.target }})
runs-on: ${{ matrix.os }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- target: x86_64-unknown-linux-gnu
artifact-name: synor-linux-x86_64
archive-ext: tar.gz
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- target: aarch64-unknown-linux-gnu
artifact-name: synor-linux-aarch64
archive-ext: tar.gz
cross: true
- os: macos-latest
target: x86_64-apple-darwin
artifact-name: synor-macos-x86_64
archive-ext: tar.gz
- os: macos-latest
target: aarch64-apple-darwin
artifact-name: synor-macos-aarch64
archive-ext: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact-name: synor-windows-x86_64
archive-ext: zip
steps:
- name: Checkout repository
@ -53,17 +38,10 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools
if: matrix.cross
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Install system dependencies (Linux)
if: runner.os == 'Linux' && !matrix.cross
run: |
sudo apt-get update
sudo apt-get install -y libclang-dev llvm-dev
sudo apt-get install -y libclang-dev llvm-dev gcc-aarch64-linux-gnu
- name: Cache cargo registry
uses: actions/cache@v4
@ -85,89 +63,37 @@ jobs:
restore-keys: |
${{ runner.os }}-${{ matrix.target }}-cargo-target-release-
- name: Build release binaries (Unix)
if: runner.os != 'Windows'
- name: Build release binaries
env:
TARGET: ${{ matrix.target }}
CROSS: ${{ matrix.cross }}
run: |
if [[ "$CROSS" == "true" ]]; then
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
export CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
fi
cargo build --release --workspace --target "$TARGET"
run: cargo build --release --workspace --target "$TARGET"
- name: Build release binaries (Windows)
if: runner.os == 'Windows'
env:
TARGET: ${{ matrix.target }}
run: cargo build --release --workspace --target "$env:TARGET"
- name: Prepare release archive (Unix)
if: runner.os != 'Windows'
- name: Prepare release archive
env:
TARGET: ${{ matrix.target }}
ARTIFACT_NAME: ${{ matrix.artifact-name }}
run: |
mkdir -p release
# Copy binaries
cp "target/$TARGET/release/synord" release/ 2>/dev/null || true
cp "target/$TARGET/release/synor-cli" release/ 2>/dev/null || true
cp "target/$TARGET/release/synor-faucet" release/ 2>/dev/null || true
cp "target/$TARGET/release/synor-explorer" release/ 2>/dev/null || true
# Copy documentation
cp README.md release/ 2>/dev/null || true
cp LICENSE* release/ 2>/dev/null || true
cp CHANGELOG.md release/ 2>/dev/null || true
# Create archive
cd release
tar czvf "../$ARTIFACT_NAME.tar.gz" *
- name: Prepare release archive (Windows)
if: runner.os == 'Windows'
env:
TARGET: ${{ matrix.target }}
ARTIFACT_NAME: ${{ matrix.artifact-name }}
run: |
New-Item -ItemType Directory -Force -Path release
# Copy binaries
Copy-Item "target/$env:TARGET/release/synord.exe" release/ -ErrorAction SilentlyContinue
Copy-Item "target/$env:TARGET/release/synor-cli.exe" release/ -ErrorAction SilentlyContinue
Copy-Item "target/$env:TARGET/release/synor-faucet.exe" release/ -ErrorAction SilentlyContinue
Copy-Item "target/$env:TARGET/release/synor-explorer.exe" release/ -ErrorAction SilentlyContinue
# Copy documentation
Copy-Item README.md release/ -ErrorAction SilentlyContinue
Copy-Item LICENSE* release/ -ErrorAction SilentlyContinue
Copy-Item CHANGELOG.md release/ -ErrorAction SilentlyContinue
# Create archive
Compress-Archive -Path release/* -DestinationPath "$env:ARTIFACT_NAME.zip"
- name: Upload release artifact (Unix)
if: runner.os != 'Windows'
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.tar.gz
retention-days: 1
- name: Upload release artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip
retention-days: 1
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
name: Create Forgejo Release
runs-on: self-hosted
needs: build-release
steps:
@ -186,10 +112,9 @@ jobs:
env:
GIT_REF: ${{ github.ref }}
run: |
# Get the current tag from the ref (safe - only used after validation)
CURRENT_TAG="${GIT_REF#refs/tags/}"
# Validate tag format (only allow v followed by semver-like pattern)
# Validate tag format
if [[ ! "$CURRENT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "Invalid tag format: $CURRENT_TAG"
exit 1
@ -197,7 +122,6 @@ jobs:
echo "current_tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
# Get the previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "## What's Changed" > CHANGELOG_BODY.md
@ -206,8 +130,6 @@ jobs:
if [ -n "$PREVIOUS_TAG" ]; then
echo "Changes since $PREVIOUS_TAG:" >> CHANGELOG_BODY.md
echo "" >> CHANGELOG_BODY.md
# Generate changelog from commits (commit messages are from our own repo)
git log "$PREVIOUS_TAG..$CURRENT_TAG" --pretty=format:"- %s (%h)" --no-merges >> CHANGELOG_BODY.md
else
echo "Initial release" >> CHANGELOG_BODY.md
@ -230,10 +152,10 @@ jobs:
echo "" >> CHANGELOG_BODY.md
echo '```' >> CHANGELOG_BODY.md
cd artifacts
find . \( -name "*.tar.gz" -o -name "*.zip" \) -exec sha256sum {} \; | sed 's|./[^/]*/||' >> ../CHANGELOG_BODY.md
find . -name "*.tar.gz" -exec sha256sum {} \; | sed 's|./[^/]*/||' >> ../CHANGELOG_BODY.md
echo '```' >> CHANGELOG_BODY.md
- name: Create GitHub Release
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: Synor ${{ steps.changelog.outputs.current_tag }}
@ -242,14 +164,12 @@ jobs:
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
files: |
artifacts/**/*.tar.gz
artifacts/**/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Optional: Publish to crates.io
publish-crates:
name: Publish to crates.io
runs-on: ubuntu-latest
runs-on: self-hosted
needs: create-release
if: ${{ !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') && !contains(github.ref, 'rc') }}
@ -269,16 +189,12 @@ jobs:
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
# Publish crates in dependency order
# Skip if CARGO_REGISTRY_TOKEN is not set
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "CARGO_REGISTRY_TOKEN not set, skipping crates.io publish"
exit 0
fi
echo "Publishing to crates.io..."
# Add --dry-run to test first, remove for actual publish
# cargo publish -p synor-types --dry-run
# cargo publish -p synor-crypto --dry-run
# ... etc
# Uncomment when ready to publish:
# cargo publish -p synor-types
# cargo publish -p synor-crypto
echo "Crate publishing configured but commented out - uncomment when ready"

View file

@ -21,12 +21,12 @@ jobs:
# ============================================================================
cargo-audit:
name: Vulnerability Scan
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit --locked
@ -39,7 +39,7 @@ jobs:
# ============================================================================
cargo-deny:
name: License & Security Policy
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
@ -53,12 +53,12 @@ jobs:
# ============================================================================
clippy:
name: Static Analysis (Clippy)
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@ -75,7 +75,7 @@ jobs:
# ============================================================================
secrets-scan:
name: Secret Detection
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
@ -91,12 +91,12 @@ jobs:
# ============================================================================
outdated:
name: Check Outdated Dependencies
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-outdated
run: cargo install cargo-outdated --locked
@ -110,12 +110,12 @@ jobs:
# ============================================================================
geiger:
name: Unsafe Code Audit
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-geiger
run: cargo install cargo-geiger --locked
@ -129,14 +129,14 @@ jobs:
# ============================================================================
property-tests:
name: Property-Based Testing
runs-on: ubuntu-latest
runs-on: self-hosted
env:
PROPTEST_CASES: "500"
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
- name: Run property tests
run: cargo test --release proptest -- --test-threads=1
@ -146,12 +146,12 @@ jobs:
# ============================================================================
wasm-audit:
name: WASM Module Security
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

View file

@ -1,59 +0,0 @@
version: 2
updates:
# Rust/Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 10
reviewers:
- "synorcc/core-team"
labels:
- "dependencies"
- "rust"
commit-message:
prefix: "deps(cargo)"
groups:
# Group minor and patch updates together
rust-minor-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Keep major updates separate for careful review
rust-major:
patterns:
- "*"
update-types:
- "major"
ignore:
# Ignore pre-release versions
- dependency-name: "*"
update-types: ["version-update:semver-prerelease"]
# GitHub Actions dependencies
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 5
reviewers:
- "synorcc/core-team"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci(actions)"
groups:
# Group all GitHub Actions updates together
github-actions:
patterns:
- "*"

View file

@ -1,195 +0,0 @@
name: Release Desktop Wallet
on:
push:
tags:
- 'wallet-v*'
workflow_dispatch:
inputs:
draft:
description: 'Create as draft release'
required: false
default: true
type: boolean
env:
CARGO_TERM_COLOR: always
permissions:
contents: write
jobs:
build-tauri:
name: Build Wallet (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
target: aarch64-apple-darwin
artifact-suffix: macos-aarch64
- platform: macos-latest
target: x86_64-apple-darwin
artifact-suffix: macos-x86_64
- platform: windows-latest
target: x86_64-pc-windows-msvc
artifact-suffix: windows-x86_64
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
run: npm install -g pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install dependencies (macOS)
if: matrix.platform == 'macos-latest'
run: |
brew install rocksdb
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-wallet-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.target }}-cargo-wallet-
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('apps/desktop-wallet/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
working-directory: apps/desktop-wallet
run: pnpm install
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Note: Code signing requires Apple Developer certificates configured in GitHub secrets
# Set APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY,
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID to enable code signing
with:
projectPath: apps/desktop-wallet
tagName: wallet-v__VERSION__
releaseName: 'Synor Wallet v__VERSION__'
releaseBody: |
## Synor Desktop Wallet
A secure desktop wallet for the Synor blockchain network with post-quantum cryptography support (Dilithium3).
### Installation
**macOS:**
- Download the `.dmg` file for your architecture (Intel or Apple Silicon)
- Open the DMG and drag Synor Wallet to Applications
- First launch: Right-click → Open (to bypass Gatekeeper if not code-signed)
**Windows:**
- Download the `.msi` installer
- Run the installer and follow the prompts
- Or download the `.exe` for portable installation
### Features
- 24-word BIP39 mnemonic generation
- Post-quantum Dilithium3 signatures
- OS keychain integration (macOS Keychain, Windows Credential Manager)
- System tray support
- Auto-updates (when signed)
### Security Note
Always verify the checksums of downloaded files.
releaseDraft: ${{ github.event.inputs.draft || true }}
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
args: --target ${{ matrix.target }}
# Build Linux AppImage separately (needs different runner config)
build-linux:
name: Build Wallet (Linux)
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
run: npm install -g pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libclang-dev \
llvm-dev
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-cargo-wallet-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-wallet-
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('apps/desktop-wallet/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
working-directory: apps/desktop-wallet
run: pnpm install
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop-wallet
tagName: wallet-v__VERSION__
releaseName: 'Synor Wallet v__VERSION__'
releaseBody: ''
releaseDraft: ${{ github.event.inputs.draft || true }}
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

4
.gitignore vendored
View file

@ -55,3 +55,7 @@ temp/
# Firebase
.firebase/
**/.vite/
# Secrets
credentials.json
*.p12

505
.misar/MISAR.md Normal file
View file

@ -0,0 +1,505 @@
# synor.cc
## Project Context
<!-- Describe your project here. Misar Code reads this file to understand your codebase. -->
- **Branch**: main
- **Remote**: https://github.com/mrgulshanyadav/Blockchain.cc.git
---
## Stack
| Layer | Technology | Details |
|-------|------------|---------|
| **Container** | Docker | Dockerfile |
---
## Directory Structure
```
synor.cc/
CLAUDE.md
Dockerfile.explorer
Dockerfile.faucet
Dockerfile.security
Dockerfile.test
README.md
SECURITY.md
docker-compose.compute.yml
docker-compose.dex-services.yml
docker-compose.dex.yml
... and 4 more files
.cargo/
config.toml
.github/
dependabot.yml
workflows/
ci.yml
release-wallet.yml
release.yml
security.yml
.playwright-mcp/
block-enhanced.png
dag-2d-view.png
dag-3d-final.png
dag-3d-modern-full.png
dag-3d-modern.png
dag-3d-view.png
dark-theme-restored.png
explorer-blocks-working.png
explorer-dag-working.png
explorer-error-state.png
... and 21 more files
.vscode/
settings.json
apps/
api-gateway/
Dockerfile
README.md
package.json
tsconfig.json
src/
api-keys.ts
index.ts
rate-limiter.ts
cli/
Cargo.toml
src/
client.rs
config.rs
main.rs
output.rs
wallet.rs
desktop-wallet/
Dockerfile
Dockerfile.dev
README.md
docker-compose.dev.yml
index.html
playwright.config.ts
pnpm-lock.yaml
postcss.config.js
tailwind.config.js
tsconfig.json
... and 2 more files
e2e/
smoke.spec.ts
playwright-report/
index.html
src/
App.tsx
index.css
main.tsx
vite-env.d.ts
src-tauri/
Cargo.lock
build.rs
test-results/
.last-run.json
explorer/
Cargo.toml
explorer-web/
Dockerfile
index.html
nginx.conf
package-lock.json
package.json
playwright.config.ts
postcss.config.js
tailwind.config.js
tsconfig.json
tsconfig.node.json
... and 1 more files
e2e/
accessibility.spec.ts
blocks.spec.ts
home.spec.ts
mobile.spec.ts
search.spec.ts
public/
favicon.svg
src/
App.tsx
index.css
main.tsx
vite-env.d.ts
src/
main.rs
faucet/
Cargo.toml
src/
main.rs
secrets.rs
hardhat-plugin/
README.md
package.json
tsconfig.json
src/
deployer.ts
index.ts
network.ts
provider.ts
type-extensions.ts
types.ts
synord/
Cargo.toml
src/
cli.rs
config.rs
lib.rs
main.rs
node.rs
tests/
byzantine_fault_tests.rs
fork_resolution.rs
multi_node_network.rs
node_lifecycle.rs
reorg_tests.rs
stress_tests.rs
sync_protocol.rs
web/
.gitignore
Dockerfile
README.md
index.html
package.json
postcss.config.js
tailwind.config.js
tsconfig.json
tsconfig.node.json
vite.config.ts
public/
synor.svg
src/
App.tsx
index.css
main.tsx
vite-env.d.ts
website/
Dockerfile
index.html
nginx.conf
package-lock.json
package.json
postcss.config.js
tailwind.config.js
tsconfig.json
tsconfig.node.json
vite.config.ts
public/
favicon.svg
src/
App.tsx
index.css
main.tsx
vite-env.d.ts
contracts/
multi-sig/
Cargo.toml
src/
lib.rs
perps/
Cargo.lock
Cargo.toml
src/
lib.rs
target/
.rustc_info.json
CACHEDIR.TAG
crates/
synor-compiler/
Cargo.toml
src/
lib.rs
metadata.rs
optimizer.rs
validator.rs
synor-contract-test/
Cargo.toml
src/
assertions.rs
environment.rs
lib.rs
mock_storage.rs
test_account.rs
synor-dag/
Cargo.toml
benches/
ghostdag_bench.rs
src/
dag.rs
dagknight.rs
ghostdag.rs
latency.rs
lib.rs
ordering.rs
pruning.rs
reachability.rs
synor-economics/
src/
error.rs
lib.rs
synor-governance/
Cargo.toml
src/
dao.rs
lib.rs
multisig.rs
treasury.rs
vesting.rs
synor-network/
Cargo.toml
src/
behaviour.rs
config.rs
eclipse.rs
lib.rs
message.rs
partition.rs
peer.rs
protocol.rs
rate_limit.rs
ratelimit.rs
... and 4 more files
synor-privacy/
Cargo.toml
src/
bulletproofs.rs
confidential.rs
error.rs
lib.rs
pedersen.rs
ring.rs
stealth.rs
synor-storage/
Cargo.toml
benches/
storage_bench.rs.disabled
src/
cache.rs
car.rs
cf.rs
chunker.rs
cid.rs
db.rs
deal.rs
erasure.rs
error.rs
lib.rs
... and 3 more files
synor-verifier/
Cargo.toml
src/
ast.rs
checker.rs
error.rs
lib.rs
parser.rs
prover.rs
smt.rs
symbolic.rs
synor-vm/
Cargo.toml
src/
compression.rs
compute.rs
context.rs
engine.rs
gas.rs
gas_estimator.rs
host.rs
lib.rs
scheduler.rs
speculation.rs
... and 2 more files
docker/
compute-node/
Dockerfile
prometheus.yml
dex/
aggregator/
index.js
package.json
api/
index.js
package.json
oracle/
index.js
package.json
perps/
index.js
package.json
simulator/
index.js
package.json
economics-service/
Dockerfile
config.toml
init.sql
hosting-gateway/
Caddyfile
Dockerfile
storage-gateway/
nginx.conf
storage-node/
Dockerfile
config.toml
zk-rollup/
Dockerfile
config.toml
nginx.conf
prometheus.yml
docs/
ARCHITECTURE_HOSTING.md
ARCHITECTURE_STORAGE.md
CODE_SIGNING.md
DEPLOYMENT.md
DEVELOPER_GUIDE.md
DEX_AND_MONETIZATION.md
PLAN.md
SECURITY_AUDIT_SCOPE.md
TESTNET.md
PLAN/
PHASE11-Synor-Compute-L2-Part2-HyperEfficiency.md
PHASE11-Synor-Compute-L2-Part3-HeterogeneousCompute.md
PHASE11-Synor-Compute-L2.md
PHASE14_PLAN.md
README.md
VALIDATION_REPORT.md
PHASE0-Foundation/
01-Milestone-01-CoreTypes.md
01-Milestone-02-Cryptography.md
PHASE1-NodeIntegration/
01-Milestone-01-ServiceWiring.md
01-Milestone-02-GenesisChain.md
PHASE13-AdvancedEnhancements/
README.md
PHASE2-CLIWallet/
01-Milestone-01-WalletCrypto.md
01-Milestone-02-CLICommands.md
PHASE3-NetworkBootstrap/
01-Milestone-01-TestnetDeployment.md
01-Milestone-02-NetworkHardening.md
PHASE4-SmartContracts/
01-Milestone-01-ContractSDK.md
01-Milestone-02-ContractTooling.md
PHASE5-Governance/
01-Milestone-01-DAOLaunch.md
01-Milestone-02-GovernanceFeatures.md
PHASE6-QualityAssurance/
01-Milestone-01-Benchmarks.md
01-Milestone-02-Testing.md
01-Milestone-03-Optimization.md
PHASE7-ProductionReadiness/
01-Milestone-01-Security.md
01-Milestone-02-MainnetLaunch.md
01-Milestone-03-Ecosystem.md
PHASE9-SynorHosting/
01-Milestone-01-HostingCore.md
01-Milestone-02-HostingGateway.md
01-Milestone-03-HostingCLI.md
milestones/
phase-2-apis-cli.md
tutorials/
01-getting-started.md
02-building-a-wallet.md
03-smart-contracts.md
04-api-guide.md
README.md
formal/
README.md
kani/
README.md
proofs/
DifficultyConvergence.md
tla/
GHOSTDAGOrdering.tla
UTXOConservation.tla
monitoring/
alertmanager.yml
alerts.yml
prometheus.yml
grafana/
dashboards/
synor-testnet.json
scripts/
build-wasm.sh
docker-entrypoint.sh
generate-node-keys.sh
new-contract.sh
profile.sh
security-audit.sh
testnet-deploy.sh
sdk/
c/
.gitignore
CMakeLists.txt
README.md
examples/
compiler_example.c
crypto_example.c
dex_example.c
ibc_example.c
zk_example.c
include/
synor_compute.h
synor_crypto.h
src/
common.c
synor_compute.c
ruby/
README.md
synor_compute.gemspec
synor_rpc.gemspec
synor_storage.gemspec
synor_wallet.gemspec
examples/
compiler_example.rb
crypto_example.rb
dex_example.rb
ibc_example.rb
zk_example.rb
lib/
synor_bridge.rb
synor_compute.rb
synor_contract.rb
synor_database.rb
synor_economics.rb
synor_governance.rb
synor_hosting.rb
synor_mining.rb
synor_privacy.rb
synor_rpc.rb
... and 2 more files
test/
test_tensor.rb
test_types.rb
src/
lib.rs
tests/
cross_crate_integration.rs
phase13_integration.rs
```
---
## Key Config Files
- `.github/workflows/ci.yml`
- `.github/workflows/release-wallet.yml`
- `.github/workflows/release.yml`
- `.github/workflows/security.yml`
- `Cargo.toml`
- `Dockerfile`
---
## Rules
<!-- Add project-specific rules for the AI assistant. Examples: -->
<!-- - Always use TypeScript strict mode -->
<!-- - Prefer functional components over class components -->

32
.misar/mcp.json Normal file
View file

@ -0,0 +1,32 @@
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/researchfellow/Desktop/G1 Technologies/synor.cc"
]
},
"git": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-git",
"--repository",
"/Users/researchfellow/Desktop/G1 Technologies/synor.cc"
]
},
"context7": {
"url": "https://mcp.context7.com/mcp",
"headers": {}
},
"playwright": {
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest"
]
}
}
}

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
description = "Synor Blockchain - Quantum-secure decentralized cloud computing platform"
license = "MIT OR Apache-2.0"
repository = "https://github.com/synorcc/synor"
repository = "https://git.misar.io/misaradmin/synor"
[workspace]
resolver = "2"
@ -83,7 +83,7 @@ version = "0.1.0"
edition = "2021"
authors = ["Synor Team <team@synor.cc>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/synorcc/synor"
repository = "https://git.misar.io/misaradmin/synor"
homepage = "https://synor.cc"
description = "Quantum-secure decentralized cloud computing platform"
rust-version = "1.75"

20
MISAR.md Normal file
View file

@ -0,0 +1,20 @@
# synor.cc
## Project Context
<!-- Describe your project here. Misar Code reads this file to understand your codebase. -->
## Rules
<!-- Add project-specific rules for the AI assistant. Examples: -->
<!-- - Always use TypeScript strict mode -->
<!-- - Prefer functional components over class components -->
<!-- - Use pnpm as the package manager -->
## Stack
<!-- List your tech stack so the AI gives relevant suggestions. Examples: -->
<!-- - Framework: Next.js 15 (App Router) -->
<!-- - Language: TypeScript -->
<!-- - Styling: Tailwind CSS -->
<!-- - Database: PostgreSQL -->

View file

@ -11,20 +11,22 @@ Quantum-secure decentralized cloud computing platform built on DAG-based consens
## Installation
### Desktop Wallet
### Desktop Wallet (v0.1.1)
Download the latest release for your platform:
| Platform | Download | Notes |
|----------|----------|-------|
| **macOS (Apple Silicon)** | `Synor-Wallet_x.x.x_aarch64.dmg` | Drag to Applications |
| **macOS (Intel)** | `Synor-Wallet_x.x.x_x64.dmg` | Drag to Applications |
| **Windows** | `Synor-Wallet_x.x.x_x64_en-US.msi` | Run installer (recommended) |
| **Windows (portable)** | `Synor-Wallet_x.x.x_x64-setup.exe` | Alternative installer |
| **Linux** | `Synor-Wallet_x.x.x_amd64.AppImage` | `chmod +x && ./` |
| **macOS (Apple Silicon)** | `Synor-Wallet_0.1.1_aarch64.dmg` | Drag to Applications |
| **macOS (Intel)** | `Synor-Wallet_0.1.1_x64.dmg` | Drag to Applications |
| **Windows** | `Synor-Wallet_0.1.1_x64_en-US.msi` | Run installer (recommended) |
| **Windows (portable)** | `Synor-Wallet_0.1.1_x64-setup.exe` | Alternative installer |
| **Linux** | `Synor-Wallet_0.1.1_amd64.AppImage` | `chmod +x && ./` |
**First launch on macOS**: Right-click → Open (to bypass Gatekeeper if not code-signed)
See [Desktop Wallet README](apps/desktop-wallet/README.md) for detailed features and development guide.
### Node Daemon (synord)
Download pre-built binaries:

View file

@ -256,7 +256,11 @@ pub async fn handle(
Ok(())
}
CompilerCommands::Encode { function, args, abi } => {
CompilerCommands::Encode {
function,
args,
abi,
} => {
output::print_info(&format!("Encoding call to: {}", function));
output::print_kv("Arguments", &args);
if let Some(a) = abi {
@ -268,7 +272,11 @@ pub async fn handle(
Ok(())
}
CompilerCommands::Decode { data, function, abi } => {
CompilerCommands::Decode {
data,
function,
abi,
} => {
output::print_info(&format!("Decoding result for: {}", function));
output::print_kv("Data", &data);
if let Some(a) = abi {
@ -314,7 +322,11 @@ pub async fn handle(
Ok(())
}
CompilerCommands::SecurityScan { wasm, min_severity, format: _ } => {
CompilerCommands::SecurityScan {
wasm,
min_severity,
format: _,
} => {
output::print_info(&format!("Security scan: {}", wasm.display()));
output::print_kv("Min severity", &min_severity);
@ -344,7 +356,11 @@ pub async fn handle(
Ok(())
}
CompilerCommands::Validate { wasm, exports, max_memory } => {
CompilerCommands::Validate {
wasm,
exports,
max_memory,
} => {
output::print_info(&format!("Validating: {}", wasm.display()));
if let Some(e) = exports {

View file

@ -196,8 +196,7 @@ pub async fn deploy(
}
// Determine output directory
let output_path = output_dir
.unwrap_or_else(|| cwd.join(config.output_dir()));
let output_path = output_dir.unwrap_or_else(|| cwd.join(config.output_dir()));
if !output_path.exists() {
return Err(anyhow!(
@ -270,7 +269,10 @@ fn validate_name(name: &str) -> Result<()> {
if name.len() > 63 {
return Err(anyhow!("Name must be 63 characters or less"));
}
if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(anyhow!(
"Name must contain only lowercase letters, numbers, and hyphens"
));
@ -281,8 +283,8 @@ fn validate_name(name: &str) -> Result<()> {
// Reserved names
const RESERVED: &[&str] = &[
"www", "api", "app", "admin", "mail", "ftp", "ssh", "cdn",
"storage", "gateway", "hosting", "node", "synor",
"www", "api", "app", "admin", "mail", "ftp", "ssh", "cdn", "storage", "gateway", "hosting",
"node", "synor",
];
if RESERVED.contains(&name) {
return Err(anyhow!("Name '{}' is reserved", name));
@ -397,11 +399,7 @@ fn guess_content_type(path: &Path) -> String {
}
/// Upload files to Synor Storage.
async fn upload_files(
base_dir: &Path,
files: &[DeployFile],
gateway_url: &str,
) -> Result<String> {
async fn upload_files(base_dir: &Path, files: &[DeployFile], gateway_url: &str) -> Result<String> {
let client = reqwest::Client::new();
// Create a multipart form with all files
@ -445,11 +443,7 @@ async fn upload_files(
}
/// Register the deployment with the hosting gateway.
async fn register_deployment(
name: &str,
cid: &str,
gateway_url: &str,
) -> Result<String> {
async fn register_deployment(name: &str, cid: &str, gateway_url: &str) -> Result<String> {
let client = reqwest::Client::new();
#[derive(Serialize)]
@ -662,7 +656,11 @@ pub async fn delete(name: &str, gateway_url: &str, format: OutputFormat) -> Resu
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Failed to delete deployment: {} - {}", status, body));
return Err(anyhow!(
"Failed to delete deployment: {} - {}",
status,
body
));
}
match format {
@ -707,22 +705,13 @@ mod tests {
#[test]
fn test_guess_content_type() {
assert_eq!(
guess_content_type(Path::new("index.html")),
"text/html"
);
assert_eq!(
guess_content_type(Path::new("style.css")),
"text/css"
);
assert_eq!(guess_content_type(Path::new("index.html")), "text/html");
assert_eq!(guess_content_type(Path::new("style.css")), "text/css");
assert_eq!(
guess_content_type(Path::new("app.js")),
"application/javascript"
);
assert_eq!(
guess_content_type(Path::new("image.png")),
"image/png"
);
assert_eq!(guess_content_type(Path::new("image.png")), "image/png");
assert_eq!(
guess_content_type(Path::new("data.wasm")),
"application/wasm"

View file

@ -246,7 +246,13 @@ pub async fn handle(
Ok(())
}
DexCommands::PlaceOrder { market, side, price, quantity, wallet } => {
DexCommands::PlaceOrder {
market,
side,
price,
quantity,
wallet,
} => {
output::print_info("Placing limit order...");
output::print_kv("Market", &market);
output::print_kv("Side", &side);
@ -257,7 +263,12 @@ pub async fn handle(
Ok(())
}
DexCommands::MarketOrder { market, side, quantity, wallet } => {
DexCommands::MarketOrder {
market,
side,
quantity,
wallet,
} => {
output::print_info("Placing market order...");
output::print_kv("Market", &market);
output::print_kv("Side", &side);
@ -275,7 +286,10 @@ pub async fn handle(
DexCommands::CancelAll { market, wallet } => {
let scope = market.unwrap_or_else(|| "all markets".to_string());
output::print_info(&format!("Cancelling all orders in {} for {}", scope, wallet));
output::print_info(&format!(
"Cancelling all orders in {} for {}",
scope, wallet
));
output::print_success("3 orders cancelled");
Ok(())
}
@ -317,7 +331,12 @@ pub async fn handle(
Ok(())
}
DexCommands::AddLiquidity { pool_id, amount_a, amount_b, wallet } => {
DexCommands::AddLiquidity {
pool_id,
amount_a,
amount_b,
wallet,
} => {
output::print_info("Adding liquidity...");
output::print_kv("Pool", &pool_id);
output::print_kv("Amount A", &amount_a);
@ -327,7 +346,11 @@ pub async fn handle(
Ok(())
}
DexCommands::RemoveLiquidity { pool_id, lp_amount, wallet } => {
DexCommands::RemoveLiquidity {
pool_id,
lp_amount,
wallet,
} => {
output::print_info("Removing liquidity...");
output::print_kv("Pool", &pool_id);
output::print_kv("LP Amount", &lp_amount);

View file

@ -169,11 +169,7 @@ pub enum ZkCommands {
}
/// Handle ZK commands.
pub async fn handle(
_client: &RpcClient,
command: ZkCommands,
_format: OutputFormat,
) -> Result<()> {
pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFormat) -> Result<()> {
match command {
ZkCommands::Compile { circuit, output } => {
output::print_info(&format!("Compiling circuit: {}", circuit.display()));
@ -211,8 +207,16 @@ pub async fn handle(
Ok(())
}
ZkCommands::ProveGroth16 { circuit, witness, proving_key: _, output } => {
output::print_info(&format!("Generating Groth16 proof for circuit: {}", circuit));
ZkCommands::ProveGroth16 {
circuit,
witness,
proving_key: _,
output,
} => {
output::print_info(&format!(
"Generating Groth16 proof for circuit: {}",
circuit
));
output::print_info(&format!("Witness: {}", witness.display()));
output::print_info("Computing witness...");
output::print_info("Generating proof...");
@ -226,7 +230,11 @@ pub async fn handle(
Ok(())
}
ZkCommands::ProvePlonk { circuit, witness, output } => {
ZkCommands::ProvePlonk {
circuit,
witness,
output,
} => {
output::print_info(&format!("Generating PLONK proof for circuit: {}", circuit));
output::print_info(&format!("Witness: {}", witness.display()));
output::print_info("Computing witness...");
@ -240,7 +248,11 @@ pub async fn handle(
Ok(())
}
ZkCommands::ProveStark { circuit, witness, output } => {
ZkCommands::ProveStark {
circuit,
witness,
output,
} => {
output::print_info(&format!("Generating STARK proof for circuit: {}", circuit));
output::print_info(&format!("Witness: {}", witness.display()));
output::print_info("Computing execution trace...");
@ -256,7 +268,11 @@ pub async fn handle(
Ok(())
}
ZkCommands::Verify { proof, verification_key: _, public_inputs: _ } => {
ZkCommands::Verify {
proof,
verification_key: _,
public_inputs: _,
} => {
output::print_info(&format!("Verifying proof: {}", proof.display()));
output::print_info("Loading proof...");
output::print_info("Verifying...");
@ -265,8 +281,15 @@ pub async fn handle(
Ok(())
}
ZkCommands::Setup { circuit, system, output } => {
output::print_info(&format!("Generating {} keys for circuit: {}", system, circuit));
ZkCommands::Setup {
circuit,
system,
output,
} => {
output::print_info(&format!(
"Generating {} keys for circuit: {}",
system, circuit
));
output::print_info("This may take a while for large circuits...");
output::print_info("Generating proving key...");
output::print_info("Deriving verification key...");

View file

@ -469,7 +469,11 @@ enum DeployCommands {
output: Option<PathBuf>,
/// Hosting gateway URL
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
#[arg(
long,
env = "SYNOR_HOSTING_URL",
default_value = "http://127.0.0.1:8280"
)]
gateway: String,
/// Skip running the build command
@ -495,7 +499,11 @@ enum DeployCommands {
/// List deployments
List {
/// Hosting gateway URL
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
#[arg(
long,
env = "SYNOR_HOSTING_URL",
default_value = "http://127.0.0.1:8280"
)]
gateway: String,
},
@ -505,7 +513,11 @@ enum DeployCommands {
name: String,
/// Hosting gateway URL
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
#[arg(
long,
env = "SYNOR_HOSTING_URL",
default_value = "http://127.0.0.1:8280"
)]
gateway: String,
},
@ -515,7 +527,11 @@ enum DeployCommands {
name: String,
/// Hosting gateway URL
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
#[arg(
long,
env = "SYNOR_HOSTING_URL",
default_value = "http://127.0.0.1:8280"
)]
gateway: String,
},
}
@ -591,9 +607,11 @@ async fn main() {
gateway,
skip_build,
} => commands::deploy::deploy(name, out_dir, &gateway, skip_build, output).await,
DeployCommands::Init { name, spa, output: out_dir } => {
commands::deploy::init(name, spa, out_dir, output)
}
DeployCommands::Init {
name,
spa,
output: out_dir,
} => commands::deploy::init(name, spa, out_dir, output),
DeployCommands::List { gateway } => commands::deploy::list(&gateway, output).await,
DeployCommands::Delete { name, gateway } => {
commands::deploy::delete(&name, &gateway, output).await

View file

@ -0,0 +1,26 @@
# Development Dockerfile for Synor Desktop Wallet Frontend
# This runs the Vite dev server for hot-reload development
FROM node:20-alpine
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install curl for healthcheck
RUN apk add --no-cache curl
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile || pnpm install
# Copy source files
COPY . .
# Expose the dev server port
EXPOSE 19420
# Start the Vite dev server
CMD ["pnpm", "run", "dev", "--host", "0.0.0.0", "--port", "19420"]

View file

@ -0,0 +1,243 @@
# Synor Desktop Wallet
A secure desktop wallet for the Synor blockchain network with post-quantum cryptography support (Dilithium3).
## Features
- **24-word BIP39 Mnemonic**: Industry-standard seed phrase generation
- **Post-Quantum Signatures**: Dilithium3 (NIST FIPS 204) for future-proof security
- **QR Code Generation**: Easily share receive addresses with scannable QR codes
- **OS Keychain Integration**: Secure storage via macOS Keychain, Windows Credential Manager, or Linux Secret Service
- **System Tray**: Minimize to tray for background operation
- **Auto-Updates**: Built-in updater for seamless version upgrades (when code-signed)
- **Multiple Addresses**: Generate and manage multiple receiving addresses
## Installation
### Download Pre-built Binaries
Download the latest release from [GitHub Releases](https://github.com/g1-technologies/synor/releases):
| Platform | File | Notes |
|----------|------|-------|
| **macOS (Apple Silicon)** | `Synor-Wallet_x.x.x_aarch64.dmg` | M1/M2/M3 Macs |
| **macOS (Intel)** | `Synor-Wallet_x.x.x_x64.dmg` | Intel Macs |
| **Windows** | `Synor-Wallet_x.x.x_x64_en-US.msi` | Recommended installer |
| **Windows (portable)** | `Synor-Wallet_x.x.x_x64-setup.exe` | Alternative installer |
| **Linux** | `Synor-Wallet_x.x.x_amd64.AppImage` | Universal Linux |
### macOS First Launch
If the app is not code-signed, macOS Gatekeeper will block it. To bypass:
1. Right-click the app → Open
2. Click "Open" in the dialog
Or run from terminal:
```bash
xattr -cr /Applications/Synor\ Wallet.app
```
## Development
### Prerequisites
- **Node.js 20+**
- **pnpm** (`npm install -g pnpm`)
- **Rust 1.75+** (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`)
- **Tauri CLI** (`cargo install tauri-cli`)
**macOS additional:**
```bash
brew install rocksdb
```
**Linux additional:**
```bash
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libclang-dev \
llvm-dev
```
### Development Mode
#### Full Development (with Rust backend)
```bash
cd apps/desktop-wallet
pnpm install
pnpm tauri:dev
```
#### Browser Preview Mode (UI only)
For rapid UI development without compiling Rust:
```bash
cd apps/desktop-wallet
pnpm install
pnpm dev
```
Then open http://localhost:1420 in your browser.
> **Note**: Browser preview mode uses mock data. Wallet operations (create, import, sign) are simulated.
### Build for Production
```bash
cd apps/desktop-wallet
pnpm install
pnpm tauri:build
```
Output locations:
- **macOS**: `src-tauri/target/release/bundle/dmg/`
- **Windows**: `src-tauri/target/release/bundle/msi/`
- **Linux**: `src-tauri/target/release/bundle/appimage/`
### Docker Development
```bash
cd apps/desktop-wallet
docker-compose -f docker-compose.dev.yml up
```
Runs frontend at http://localhost:19420
### Testing
```bash
# Run Playwright E2E tests
pnpm test
# Run with UI
pnpm test:ui
# Run headed (visible browser)
pnpm test:headed
```
## Project Structure
```
apps/desktop-wallet/
├── src/ # React frontend
│ ├── components/ # Reusable UI components
│ ├── pages/ # Route pages
│ │ ├── Welcome.tsx # Create/import wallet
│ │ ├── Dashboard.tsx # Balance overview
│ │ ├── Send.tsx # Send transactions
│ │ ├── Receive.tsx # Receive with QR codes
│ │ ├── Transactions.tsx # Transaction history
│ │ └── Settings.tsx # App settings
│ ├── store/ # Zustand state management
│ │ ├── wallet.ts # Wallet state
│ │ ├── node.ts # Node connection state
│ │ └── mining.ts # Mining state
│ ├── lib/ # Utilities
│ │ └── tauri.ts # Tauri invoke wrapper
│ └── App.tsx # Route definitions
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── commands.rs # Tauri command handlers
│ │ ├── keychain.rs # OS keychain integration
│ │ ├── crypto.rs # Cryptographic operations
│ │ └── lib.rs # Main library
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ └── icons/ # App icons
├── e2e/ # Playwright E2E tests
├── playwright.config.ts # Test configuration
└── package.json # Node.js dependencies
```
## Configuration
### Tauri Configuration
Edit `src-tauri/tauri.conf.json`:
```json
{
"app": {
"windows": [{
"title": "Synor Wallet",
"width": 1024,
"height": 768,
"minWidth": 800,
"minHeight": 600
}]
},
"plugins": {
"updater": {
"endpoints": ["https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"]
}
}
}
```
### Security
The Content Security Policy (CSP) is configured to:
- Allow connections to `*.synor.io` and localhost
- Block inline scripts (except WASM)
- Prevent embedding in iframes
## Embedded Node (Optional)
The wallet can optionally include a full node for decentralized operation:
```bash
# Build with embedded node support
cargo build --release --features embedded-node
```
This enables:
- Running a full node inside the wallet
- Mining directly from the wallet
- No external RPC dependency
## Changelog
### v0.1.1 (2026-02-02)
**New Features:**
- QR code generation on Receive page for easy address sharing
- Improved navigation flow on unlock screen
**Bug Fixes:**
- Fixed app crash on macOS after installation (Tauri 2.0 plugin configuration)
- Fixed icon bit depth compatibility issue (16-bit to 8-bit RGBA)
- Removed deprecated plugin scope configurations
**Technical:**
- Updated to Tauri 2.0 stable plugin APIs
- Added missing Rust dependencies (`once_cell`, `md5`)
- Removed duplicate imports in commands.rs
### v0.1.0 (Initial Release)
- Basic wallet creation and import
- Send/receive SYN tokens
- Transaction history
- Multiple address support
- OS keychain integration
- System tray support
## Security Notes
- **Seed phrases** are encrypted with Argon2id and stored in OS keychain
- **Private keys** are derived using BIP32 HD wallet standard
- **Transactions** are signed locally; private keys never leave the device
- **Post-quantum signatures** use Dilithium3 (NIST FIPS 204)
## License
MIT OR Apache-2.0

View file

@ -0,0 +1,34 @@
version: '3.8'
# Development Docker Compose for Synor Desktop Wallet
# Note: This runs the Vite dev server for frontend development
# The full Tauri app requires native compilation and can't run in Docker
services:
wallet-frontend:
build:
context: .
dockerfile: Dockerfile.dev
container_name: synor-wallet-frontend-dev
ports:
- "19420:19420" # Reserved port for wallet dev server
volumes:
- ./src:/app/src:delegated
- ./public:/app/public:delegated
- ./index.html:/app/index.html:ro
- ./vite.config.ts:/app/vite.config.ts:ro
- ./tailwind.config.js:/app/tailwind.config.js:ro
- ./postcss.config.js:/app/postcss.config.js:ro
- ./tsconfig.json:/app/tsconfig.json:ro
# Exclude node_modules to use container's installed packages
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_DEV_SERVER_PORT=19420
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:19420"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View file

@ -0,0 +1,150 @@
import { test, expect } from '@playwright/test';
/**
* Smoke tests for Synor Desktop Wallet
*
* These tests verify the development server is running and serving content.
* Note: Full E2E testing of Tauri features requires running the complete
* Tauri application with the Rust backend.
*
* For comprehensive E2E testing, use:
* pnpm tauri:dev (to run full app)
*
* These smoke tests verify:
* - Dev server responds
* - HTML content is served
* - React app bundles load
*/
test.describe('Smoke Tests', () => {
test('dev server should respond', async ({ page }) => {
const response = await page.goto('/');
expect(response?.status()).toBe(200);
});
test('should serve HTML content', async ({ page }) => {
await page.goto('/');
const html = await page.content();
// Should have basic HTML structure
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<html');
expect(html).toContain('</html>');
});
test('should have root element for React', async ({ page }) => {
await page.goto('/');
const html = await page.content();
// Should have React root element
expect(html).toContain('id="root"');
});
test('should load JavaScript bundles', async ({ page }) => {
await page.goto('/');
const html = await page.content();
// Should include script tags
expect(html).toContain('<script');
expect(html).toContain('type="module"');
});
test('should load CSS', async ({ page }) => {
await page.goto('/');
const html = await page.content();
// Should include styles (either link stylesheet or style tag)
const hasStylesheet = html.includes('stylesheet') || html.includes('<style');
expect(hasStylesheet).toBe(true);
});
test('should have correct title', async ({ page }) => {
await page.goto('/');
const title = await page.title();
// Should have a title set
expect(title.length).toBeGreaterThan(0);
});
test('all routes should return 200', async ({ page }) => {
const routes = [
'/',
'/setup',
'/dashboard',
'/send',
'/receive',
'/history',
'/node',
'/mining',
'/staking',
'/swap',
'/market',
'/contracts',
'/tokens',
'/nfts',
'/settings',
'/storage',
'/hosting',
'/compute',
'/database',
'/privacy',
'/bridge',
'/governance',
'/zk',
];
for (const route of routes) {
const response = await page.goto(route);
expect(response?.status(), `Route ${route} should return 200`).toBe(200);
}
});
});
test.describe('Build Verification', () => {
test('should load without unexpected JavaScript errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
// Ignore expected errors when running outside Tauri context:
// - Tauri API errors (__TAURI__, invoke, etc.)
// - React error boundary messages (expected when components fail without Tauri)
// - Window property checks
const isExpectedError =
text.includes('__TAURI__') ||
text.includes('tauri') ||
text.includes('invoke') ||
text.includes('window.') ||
text.includes('error boundary') ||
text.includes('Error occurred in') ||
text.includes('TitleBar') ||
text.includes('getCurrentWindow');
if (!isExpectedError) {
errors.push(text);
}
}
});
await page.goto('/');
await page.waitForTimeout(2000); // Wait for async operations
// Log any errors found for debugging
if (errors.length > 0) {
console.log('Unexpected console errors found:', errors);
}
// Should have no unexpected errors
expect(errors).toHaveLength(0);
});
test('should not have network failures for static assets', async ({ page }) => {
const failedRequests: string[] = [];
page.on('requestfailed', (request) => {
failedRequests.push(`${request.url()} - ${request.failure()?.errorText}`);
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Should have no failed requests
expect(failedRequests).toHaveLength(0);
});
});

View file

@ -1,6 +1,6 @@
{
"name": "@synor/desktop-wallet",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
@ -10,27 +10,32 @@
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"clsx": "^2.1.0",
"lucide-react": "^0.303.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"zustand": "^4.4.7",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0"
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",

View file

@ -0,0 +1,65 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E test configuration for Synor Desktop Wallet
*
* Tests run against the Vite dev server with mocked Tauri APIs.
* This allows testing the complete UI flow without requiring the
* full Tauri application to be running.
*/
export default defineConfig({
testDir: './e2e',
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Workers for parallel execution
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [
['html', { outputFolder: 'playwright-report' }],
['list'],
],
// Shared settings for all projects
use: {
// Base URL for the dev server
baseURL: 'http://localhost:1420',
// Collect trace when retrying the failed test
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'on-first-retry',
},
// Configure projects for different browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
// Run dev server before starting tests
webServer: {
command: 'pnpm run dev',
url: 'http://localhost:1420',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

1969
apps/desktop-wallet/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "synor-wallet"
version = "0.1.0"
version = "0.1.1"
description = "Secure Synor blockchain wallet with post-quantum cryptography"
authors = ["Synor Team"]
edition = "2021"
@ -42,14 +42,37 @@ bech32 = "0.11"
# OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service)
keyring = "3"
# Local crates from the monorepo (optional - for direct integration with core)
synor-crypto = { path = "../../../crates/synor-crypto", optional = true }
synor-types = { path = "../../../crates/synor-types", optional = true }
synor-rpc = { path = "../../../crates/synor-rpc", optional = true }
# HTTP client for RPC calls
reqwest = { version = "0.12", features = ["json"] }
# WebSocket client for real-time events
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Utils
uuid = { version = "1", features = ["v4"] }
once_cell = "1"
md5 = "0.7"
# Local crates from the monorepo (required for wallet functionality)
synor-crypto = { path = "../../../crates/synor-crypto" }
synor-types = { path = "../../../crates/synor-types" }
synor-rpc = { path = "../../../crates/synor-rpc" }
# Optional: Embedded node support (enables running a full node inside the wallet)
synord = { path = "../../../apps/synord", optional = true }
synor-mining = { path = "../../../crates/synor-mining", optional = true }
synor-network = { path = "../../../crates/synor-network", optional = true }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# Enable embedded node support - compiles full node into wallet binary
embedded-node = ["dep:synord", "dep:synor-mining", "dep:synor-network"]
[profile.release]
lto = true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,33 @@ pub enum Error {
#[error("Keychain error: {0}")]
Keychain(String),
#[error("Node error: {0}")]
NodeError(String),
#[error("Node is already running")]
NodeAlreadyRunning,
#[error("Node is not running")]
NodeNotRunning,
#[error("Not connected to any node")]
NotConnected,
#[error("Feature not enabled: {0}")]
FeatureNotEnabled(String),
#[error("Mining error: {0}")]
MiningError(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Contract error: {0}")]
ContractError(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Internal error: {0}")]
Internal(String),
}

View file

@ -12,7 +12,11 @@ mod commands;
mod crypto;
mod error;
mod keychain;
mod node;
mod rpc_client;
mod wallet;
mod wallet_manager;
mod watch_only;
use tauri::{
menu::{Menu, MenuItem},
@ -112,10 +116,39 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_process::init())
.setup(|app| {
// Initialize wallet state
// Initialize wallet state (legacy, for backwards compatibility)
let wallet_state = wallet::WalletState::new();
app.manage(wallet_state);
// Initialize wallet manager (multi-wallet support)
let wallet_manager = wallet_manager::WalletManager::new();
app.manage(wallet_manager);
// Initialize watch-only address manager
let watch_only_manager = watch_only::WatchOnlyManager::new();
app.manage(watch_only_manager);
// Initialize node manager with app handle for events
let node_manager = std::sync::Arc::new(
node::NodeManager::with_app_handle(app.handle().clone())
);
// Initialize RPC client
let rpc_client = std::sync::Arc::new(
rpc_client::RpcClient::new(node_manager.clone())
);
// Initialize app state (node + RPC)
let app_state = commands::AppState {
node_manager,
rpc_client,
};
app.manage(app_state);
// Initialize mining state
let mining_state = commands::MiningState::new();
app.manage(mining_state);
// Build and set up system tray
let menu = build_tray_menu(app.handle())?;
let _tray = TrayIconBuilder::new()
@ -148,13 +181,32 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Wallet management
// Wallet management (legacy single-wallet)
commands::create_wallet,
commands::import_wallet,
commands::unlock_wallet,
commands::lock_wallet,
commands::get_wallet_info,
commands::export_mnemonic,
// Multi-wallet management
commands::wallets_list,
commands::wallets_create,
commands::wallets_import,
commands::wallets_switch,
commands::wallets_rename,
commands::wallets_delete,
commands::wallets_get_active,
commands::wallets_unlock_active,
commands::wallets_lock_active,
commands::wallets_migrate_legacy,
// Watch-only addresses
commands::watch_only_list,
commands::watch_only_add,
commands::watch_only_update,
commands::watch_only_remove,
commands::watch_only_get,
commands::watch_only_refresh_balance,
commands::watch_only_get_tags,
// Addresses & UTXOs
commands::get_addresses,
commands::generate_address,
@ -165,10 +217,246 @@ pub fn run() {
commands::sign_transaction,
commands::broadcast_transaction,
commands::get_transaction_history,
// Network
// Batch transactions
commands::create_batch_transaction,
// Fee market analytics
commands::fee_get_mempool_stats,
commands::fee_get_recommendations,
commands::fee_get_analytics,
commands::fee_get_history,
commands::fee_calculate,
// Time-locked vaults
commands::vault_list,
commands::vault_get_summary,
commands::vault_create,
commands::vault_get,
commands::vault_withdraw,
commands::vault_delete,
commands::vault_time_remaining,
// Social recovery
commands::recovery_get_config,
commands::recovery_setup,
commands::recovery_add_guardian,
commands::recovery_remove_guardian,
commands::recovery_list_guardians,
commands::recovery_update_threshold,
commands::recovery_initiate,
commands::recovery_approve,
commands::recovery_get_request,
commands::recovery_list_requests,
commands::recovery_cancel,
commands::recovery_disable,
// Decoy wallets
commands::decoy_is_enabled,
commands::decoy_setup,
commands::decoy_create,
commands::decoy_list,
commands::decoy_update_balance,
commands::decoy_delete,
commands::decoy_check_duress,
commands::decoy_disable,
// Network (legacy)
commands::connect_node,
commands::disconnect_node,
commands::get_network_status,
// Node management (new)
commands::node_connect_external,
commands::node_start_embedded,
commands::node_stop,
commands::node_get_status,
commands::node_get_connection_mode,
commands::node_get_peers,
commands::node_get_sync_progress,
// Mining
commands::mining_start,
commands::mining_stop,
commands::mining_pause,
commands::mining_resume,
commands::mining_get_status,
commands::mining_get_stats,
commands::mining_set_threads,
// Enhanced wallet (using RPC client)
commands::wallet_get_balance,
commands::wallet_get_utxos,
commands::wallet_get_network_info,
commands::wallet_get_fee_estimate,
// Smart contracts
commands::contract_deploy,
commands::contract_call,
commands::contract_read,
commands::contract_get_info,
// Tokens
commands::token_create,
commands::token_get_info,
commands::token_transfer,
commands::token_get_balance,
commands::token_list_balances,
commands::token_mint,
commands::token_burn,
// NFTs
commands::nft_create_collection,
commands::nft_get_collection_info,
commands::nft_mint,
commands::nft_batch_mint,
commands::nft_get_token_info,
commands::nft_transfer,
commands::nft_burn,
commands::nft_list_owned,
commands::nft_list_owned_in_collection,
commands::nft_set_approval_for_all,
commands::nft_set_base_uri,
// Staking
commands::staking_get_pools,
commands::staking_get_user_stakes,
commands::staking_stake,
commands::staking_unstake,
commands::staking_claim_rewards,
// DEX/Swap
commands::swap_get_quote,
commands::swap_execute,
commands::swap_get_pools,
commands::swap_add_liquidity,
commands::swap_remove_liquidity,
// Address Book
commands::addressbook_get_all,
commands::addressbook_add,
commands::addressbook_update,
commands::addressbook_delete,
// Market/Prices
commands::market_get_prices,
commands::market_get_history,
// Multi-sig
commands::multisig_create,
commands::multisig_get_info,
commands::multisig_propose_tx,
commands::multisig_sign_tx,
commands::multisig_execute_tx,
commands::multisig_get_pending_txs,
// Backup/Export
commands::backup_export_wallet,
commands::backup_import_wallet,
commands::backup_export_history,
// Hardware Wallet
commands::hardware_detect_devices,
commands::hardware_get_address,
commands::hardware_sign_transaction,
// QR Code
commands::qr_generate,
commands::qr_parse_payment,
// DApp Browser
commands::dapp_get_connected,
commands::dapp_connect,
commands::dapp_disconnect,
commands::dapp_handle_request,
// Storage
commands::storage_upload,
commands::storage_download,
commands::storage_get_file_info,
commands::storage_list_files,
commands::storage_pin,
commands::storage_unpin,
commands::storage_delete,
commands::storage_get_usage,
// Hosting
commands::hosting_register_name,
commands::hosting_deploy,
commands::hosting_list_sites,
commands::hosting_add_custom_domain,
commands::hosting_verify_domain,
commands::hosting_delete_site,
// Compute
commands::compute_list_providers,
commands::compute_submit_job,
commands::compute_get_job,
commands::compute_list_jobs,
commands::compute_cancel_job,
// Database
commands::database_create,
commands::database_list,
commands::database_get_info,
commands::database_delete,
commands::database_query,
// Privacy
commands::privacy_get_balance,
commands::privacy_send,
commands::privacy_generate_stealth_address,
commands::privacy_shield,
commands::privacy_unshield,
commands::privacy_create_token,
commands::privacy_deploy_contract,
// Bridge
commands::bridge_get_chains,
commands::bridge_deposit,
commands::bridge_withdraw,
commands::bridge_get_transfer,
commands::bridge_list_transfers,
commands::bridge_get_wrapped_balance,
// Governance
commands::governance_get_proposals,
commands::governance_get_proposal,
commands::governance_create_proposal,
commands::governance_vote,
commands::governance_execute_proposal,
commands::governance_get_voting_power,
commands::governance_delegate,
// ZK-Rollup
commands::zk_get_stats,
commands::zk_get_account,
commands::zk_deposit,
commands::zk_withdraw,
commands::zk_transfer,
// Transaction Mixer (Phase 8)
commands::mixer_get_denominations,
commands::mixer_get_pool_status,
commands::mixer_create_request,
commands::mixer_get_request,
commands::mixer_list_requests,
commands::mixer_cancel_request,
// Limit Orders (Phase 9)
commands::limit_order_create,
commands::limit_order_get,
commands::limit_order_list,
commands::limit_order_cancel,
commands::limit_order_get_orderbook,
// Yield Aggregator (Phase 10)
commands::yield_get_opportunities,
commands::yield_deposit,
commands::yield_withdraw,
commands::yield_list_positions,
commands::yield_compound,
// Portfolio Analytics (Phase 11)
commands::portfolio_get_summary,
commands::portfolio_get_holdings,
commands::portfolio_get_tax_report,
commands::portfolio_export_tax_report,
commands::portfolio_get_history,
// Price Alerts (Phase 12)
commands::alert_create,
commands::alert_list,
commands::alert_delete,
commands::alert_toggle,
// CLI Mode (Phase 13)
commands::cli_execute,
commands::cli_get_history,
// RPC Profiles (Phase 14)
commands::rpc_profile_create,
commands::rpc_profile_list,
commands::rpc_profile_set_active,
commands::rpc_profile_delete,
commands::rpc_profile_test,
// Transaction Builder (Phase 15)
commands::tx_builder_create,
commands::tx_builder_sign,
commands::tx_builder_broadcast,
commands::tx_builder_decode,
// Plugin System (Phase 16)
commands::plugin_list_available,
commands::plugin_list_installed,
commands::plugin_install,
commands::plugin_uninstall,
commands::plugin_toggle,
commands::plugin_get_settings,
commands::plugin_set_settings,
// Updates
check_update,
install_update,

View file

@ -0,0 +1,480 @@
//! Embedded Node Module
//!
//! Provides optional embedded Synor node functionality for the desktop wallet.
//! When enabled with the `embedded-node` feature, users can run a full node
//! directly inside the wallet application.
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info, warn};
/// Node connection mode - embedded node or external RPC
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ConnectionMode {
/// No connection configured
Disconnected,
/// Connect to an external node via RPC
External {
http_url: String,
ws_url: Option<String>,
},
/// Run embedded node (requires `embedded-node` feature)
#[cfg(feature = "embedded-node")]
Embedded {
network: String,
data_dir: Option<PathBuf>,
},
}
impl Default for ConnectionMode {
fn default() -> Self {
ConnectionMode::Disconnected
}
}
/// Node status information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeStatus {
/// Connection mode
pub mode: ConnectionMode,
/// Whether the node is connected/running
pub is_connected: bool,
/// Current block height
pub block_height: u64,
/// Current blue score (DAG metric)
pub blue_score: u64,
/// Number of connected peers
pub peer_count: usize,
/// Whether the node is syncing
pub is_syncing: bool,
/// Sync progress (0.0 - 1.0)
pub sync_progress: f64,
/// Network name
pub network: String,
/// Chain ID
pub chain_id: u64,
}
impl Default for NodeStatus {
fn default() -> Self {
NodeStatus {
mode: ConnectionMode::Disconnected,
is_connected: false,
block_height: 0,
blue_score: 0,
peer_count: 0,
is_syncing: false,
sync_progress: 0.0,
network: String::new(),
chain_id: 0,
}
}
}
/// Sync progress information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SyncProgress {
/// Current block height
pub current_height: u64,
/// Target block height (highest known)
pub target_height: u64,
/// Progress percentage (0.0 - 100.0)
pub progress: f64,
/// Estimated time remaining in seconds
pub eta_seconds: Option<u64>,
/// Sync status message
pub status: String,
}
/// Peer information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeerInfo {
/// Peer ID
pub peer_id: String,
/// Peer address
pub address: String,
/// Connection direction (inbound/outbound)
pub direction: String,
/// Latency in milliseconds
pub latency_ms: Option<u64>,
/// Peer's block height
pub block_height: u64,
/// Connection status
pub status: String,
}
/// Manages node connection state and lifecycle
pub struct NodeManager {
/// Current connection mode
mode: RwLock<ConnectionMode>,
/// Cached status
status: RwLock<NodeStatus>,
/// Embedded node instance (when feature enabled)
#[cfg(feature = "embedded-node")]
embedded_node: RwLock<Option<Arc<synord::SynorNode>>>,
/// Tauri app handle for emitting events
app_handle: Option<tauri::AppHandle>,
}
impl NodeManager {
/// Creates a new node manager
pub fn new() -> Self {
NodeManager {
mode: RwLock::new(ConnectionMode::Disconnected),
status: RwLock::new(NodeStatus::default()),
#[cfg(feature = "embedded-node")]
embedded_node: RwLock::new(None),
app_handle: None,
}
}
/// Creates a new node manager with Tauri app handle for events
pub fn with_app_handle(app_handle: tauri::AppHandle) -> Self {
NodeManager {
mode: RwLock::new(ConnectionMode::Disconnected),
status: RwLock::new(NodeStatus::default()),
#[cfg(feature = "embedded-node")]
embedded_node: RwLock::new(None),
app_handle: Some(app_handle),
}
}
/// Gets the current connection mode
pub async fn connection_mode(&self) -> ConnectionMode {
self.mode.read().await.clone()
}
/// Gets the current node status
pub async fn status(&self) -> NodeStatus {
self.status.read().await.clone()
}
/// Connects to an external node via RPC
pub async fn connect_external(
&self,
http_url: String,
ws_url: Option<String>,
) -> crate::Result<()> {
info!(http_url = %http_url, "Connecting to external node");
// Update mode
*self.mode.write().await = ConnectionMode::External {
http_url: http_url.clone(),
ws_url: ws_url.clone(),
};
// Update status
let mut status = self.status.write().await;
status.mode = ConnectionMode::External {
http_url,
ws_url,
};
status.is_connected = true;
self.emit_status_changed(&status);
Ok(())
}
/// Disconnects from the current node
pub async fn disconnect(&self) -> crate::Result<()> {
let current_mode = self.mode.read().await.clone();
match current_mode {
ConnectionMode::Disconnected => {
// Already disconnected
Ok(())
}
ConnectionMode::External { .. } => {
info!("Disconnecting from external node");
*self.mode.write().await = ConnectionMode::Disconnected;
let mut status = self.status.write().await;
*status = NodeStatus::default();
self.emit_status_changed(&status);
Ok(())
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
self.stop_embedded_node().await
}
}
}
/// Emits a status changed event to the frontend
fn emit_status_changed(&self, status: &NodeStatus) {
if let Some(ref app) = self.app_handle {
use tauri::Emitter;
if let Err(e) = app.emit("node:status-changed", status) {
warn!("Failed to emit node status: {}", e);
}
}
}
/// Emits a sync progress event to the frontend
fn emit_sync_progress(&self, progress: &SyncProgress) {
if let Some(ref app) = self.app_handle {
use tauri::Emitter;
if let Err(e) = app.emit("node:sync-progress", progress) {
warn!("Failed to emit sync progress: {}", e);
}
}
}
}
// ============================================================================
// Embedded Node Support (feature-gated)
// ============================================================================
#[cfg(feature = "embedded-node")]
impl NodeManager {
/// Starts the embedded node
pub async fn start_embedded_node(
&self,
network: &str,
data_dir: Option<PathBuf>,
mining_enabled: bool,
coinbase_address: Option<String>,
mining_threads: usize,
) -> crate::Result<()> {
// Check if already running
if self.embedded_node.read().await.is_some() {
return Err(crate::Error::NodeAlreadyRunning);
}
info!(network = %network, "Starting embedded node");
// Create node configuration
let mut config = synord::NodeConfig::for_network(network)
.map_err(|e| crate::Error::NodeError(e.to_string()))?;
// Override data directory if specified
if let Some(dir) = data_dir.clone() {
config.data_dir = dir;
}
// Configure mining
if mining_enabled {
config.mining.enabled = true;
config.mining.coinbase_address = coinbase_address;
if mining_threads > 0 {
config.mining.threads = mining_threads;
}
}
// Configure RPC to use wallet-specific ports
config.rpc.http_addr = "127.0.0.1:19423".to_string();
config.rpc.ws_addr = "127.0.0.1:19424".to_string();
// Configure P2P to use wallet-specific port
config.p2p.listen_addr = format!("/ip4/0.0.0.0/tcp/{}", match network {
"mainnet" => 19422,
"testnet" => 19522,
"devnet" => 19622,
_ => 19422,
});
// Create and start the node
let node = synord::SynorNode::new(config)
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to create node: {}", e)))?;
node.start()
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to start node: {}", e)))?;
let node = Arc::new(node);
// Store the node
*self.embedded_node.write().await = Some(node.clone());
// Update mode and status
*self.mode.write().await = ConnectionMode::Embedded {
network: network.to_string(),
data_dir,
};
let node_info = node.info().await;
let mut status = self.status.write().await;
status.mode = self.mode.read().await.clone();
status.is_connected = true;
status.network = node_info.network;
status.chain_id = node_info.chain_id;
status.block_height = node_info.block_height;
status.blue_score = node_info.blue_score;
status.peer_count = node_info.peer_count;
status.is_syncing = node_info.is_syncing;
self.emit_status_changed(&status);
info!("Embedded node started successfully");
Ok(())
}
/// Stops the embedded node
pub async fn stop_embedded_node(&self) -> crate::Result<()> {
let node = self.embedded_node.write().await.take();
if let Some(node) = node {
info!("Stopping embedded node");
node.stop()
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to stop node: {}", e)))?;
}
*self.mode.write().await = ConnectionMode::Disconnected;
let mut status = self.status.write().await;
*status = NodeStatus::default();
self.emit_status_changed(&status);
info!("Embedded node stopped");
Ok(())
}
/// Gets the embedded node if running
pub async fn embedded_node(&self) -> Option<Arc<synord::SynorNode>> {
self.embedded_node.read().await.clone()
}
/// Refreshes the node status from the embedded node
pub async fn refresh_status(&self) -> crate::Result<NodeStatus> {
if let Some(node) = self.embedded_node.read().await.as_ref() {
let node_info = node.info().await;
let state = node.state().await;
let mut status = self.status.write().await;
status.block_height = node_info.block_height;
status.blue_score = node_info.blue_score;
status.peer_count = node_info.peer_count;
status.is_syncing = node_info.is_syncing;
// Calculate sync progress if syncing
if node_info.is_syncing {
// In a real implementation, we'd get the target height from peers
status.sync_progress = 0.0; // Placeholder
} else {
status.sync_progress = 100.0;
}
self.emit_status_changed(&status);
Ok(status.clone())
} else {
Ok(self.status.read().await.clone())
}
}
/// Gets connected peers from the embedded node
pub async fn get_peers(&self) -> crate::Result<Vec<PeerInfo>> {
if let Some(node) = self.embedded_node.read().await.as_ref() {
let peer_count = node.network().peer_count().await;
// In a full implementation, we'd get detailed peer info from the network service
// For now, return a placeholder
Ok(vec![])
} else {
Ok(vec![])
}
}
}
// Stub implementations when embedded-node feature is disabled
#[cfg(not(feature = "embedded-node"))]
impl NodeManager {
/// Stub: Embedded node not available
pub async fn start_embedded_node(
&self,
_network: &str,
_data_dir: Option<PathBuf>,
_mining_enabled: bool,
_coinbase_address: Option<String>,
_mining_threads: usize,
) -> crate::Result<()> {
Err(crate::Error::FeatureNotEnabled("embedded-node".to_string()))
}
/// Stub: Embedded node not available
pub async fn stop_embedded_node(&self) -> crate::Result<()> {
Err(crate::Error::FeatureNotEnabled("embedded-node".to_string()))
}
/// Stub: No embedded node
pub async fn refresh_status(&self) -> crate::Result<NodeStatus> {
Ok(self.status.read().await.clone())
}
/// Stub: No peers without embedded node
pub async fn get_peers(&self) -> crate::Result<Vec<PeerInfo>> {
Ok(vec![])
}
}
impl Default for NodeManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_mode_default() {
let mode = ConnectionMode::default();
assert_eq!(mode, ConnectionMode::Disconnected);
}
#[test]
fn test_node_status_default() {
let status = NodeStatus::default();
assert!(!status.is_connected);
assert_eq!(status.block_height, 0);
assert_eq!(status.peer_count, 0);
}
#[tokio::test]
async fn test_node_manager_creation() {
let manager = NodeManager::new();
let mode = manager.connection_mode().await;
assert_eq!(mode, ConnectionMode::Disconnected);
}
#[tokio::test]
async fn test_connect_external() {
let manager = NodeManager::new();
let result = manager
.connect_external(
"http://localhost:16110".to_string(),
Some("ws://localhost:16111".to_string()),
)
.await;
assert!(result.is_ok());
let mode = manager.connection_mode().await;
match mode {
ConnectionMode::External { http_url, ws_url } => {
assert_eq!(http_url, "http://localhost:16110");
assert_eq!(ws_url, Some("ws://localhost:16111".to_string()));
}
_ => panic!("Expected External mode"),
}
}
#[tokio::test]
async fn test_disconnect() {
let manager = NodeManager::new();
manager
.connect_external("http://localhost:16110".to_string(), None)
.await
.unwrap();
let result = manager.disconnect().await;
assert!(result.is_ok());
let mode = manager.connection_mode().await;
assert_eq!(mode, ConnectionMode::Disconnected);
}
}

View file

@ -0,0 +1,563 @@
//! RPC Client Abstraction
//!
//! Provides a unified interface for communicating with Synor nodes,
//! whether embedded or external. This allows the wallet to seamlessly
//! switch between connection modes.
use std::sync::Arc;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{debug, error, info};
use crate::node::{ConnectionMode, NodeManager, NodeStatus};
/// JSON-RPC 2.0 request
#[derive(Debug, Serialize)]
struct JsonRpcRequest<T> {
jsonrpc: &'static str,
method: String,
params: T,
id: u64,
}
/// JSON-RPC 2.0 response
#[derive(Debug, Deserialize)]
struct JsonRpcResponse<T> {
#[allow(dead_code)]
jsonrpc: String,
result: Option<T>,
error: Option<JsonRpcError>,
#[allow(dead_code)]
id: u64,
}
/// JSON-RPC 2.0 error
#[derive(Debug, Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
#[allow(dead_code)]
data: Option<serde_json::Value>,
}
/// Balance information
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Balance {
/// Address
pub address: String,
/// Balance in sompi (smallest unit)
pub balance: u64,
}
/// UTXO (Unspent Transaction Output)
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Utxo {
/// Transaction ID
pub transaction_id: String,
/// Output index
pub index: u32,
/// Amount in sompi
pub amount: u64,
/// Script public key (hex)
pub script_public_key: String,
/// Block DAA score when created
pub block_daa_score: u64,
/// Whether this is a coinbase output
pub is_coinbase: bool,
}
/// Transaction status
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionStatus {
/// Transaction ID
pub transaction_id: String,
/// Is confirmed
pub is_confirmed: bool,
/// Confirmations count
pub confirmations: u64,
/// Block hash (if confirmed)
pub block_hash: Option<String>,
/// Block time (if confirmed)
pub block_time: Option<u64>,
}
/// Transaction for broadcasting
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawTransaction {
/// Transaction version
pub version: u16,
/// Inputs
pub inputs: Vec<TransactionInput>,
/// Outputs
pub outputs: Vec<TransactionOutput>,
/// Lock time
pub lock_time: u64,
/// Subnetwork ID
pub subnetwork_id: String,
/// Gas
pub gas: u64,
/// Payload (hex)
pub payload: String,
}
/// Transaction input
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionInput {
/// Previous transaction ID
pub previous_transaction_id: String,
/// Previous output index
pub previous_index: u32,
/// Signature script (hex)
pub signature_script: String,
/// Sequence number
pub sequence: u64,
}
/// Transaction output
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionOutput {
/// Amount in sompi
pub amount: u64,
/// Script public key (hex)
pub script_public_key: String,
}
/// Network information
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkInfo {
/// Network name
pub network: String,
/// Is synced
pub is_synced: bool,
/// Current block height
pub block_height: u64,
/// Blue score
pub blue_score: u64,
/// Difficulty
pub difficulty: f64,
/// Peer count
pub peer_count: usize,
/// Mempool size
pub mempool_size: u64,
}
/// Fee estimate
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeeEstimate {
/// Priority fee rate (sompi per gram)
pub priority: f64,
/// Normal fee rate
pub normal: f64,
/// Low fee rate
pub low: f64,
}
/// Unified RPC client for communicating with Synor nodes
pub struct RpcClient {
/// Node manager for connection handling
node_manager: Arc<NodeManager>,
/// HTTP client for external RPC
http_client: reqwest::Client,
/// Request ID counter
request_id: RwLock<u64>,
}
impl RpcClient {
/// Creates a new RPC client
pub fn new(node_manager: Arc<NodeManager>) -> Self {
RpcClient {
node_manager,
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client"),
request_id: RwLock::new(0),
}
}
/// Gets the next request ID
async fn next_id(&self) -> u64 {
let mut id = self.request_id.write().await;
*id += 1;
*id
}
/// Gets the current connection mode
pub async fn connection_mode(&self) -> ConnectionMode {
self.node_manager.connection_mode().await
}
/// Gets the current node status
pub async fn node_status(&self) -> NodeStatus {
self.node_manager.status().await
}
/// Makes an RPC call to an external node
async fn call_external<P, R>(&self, http_url: &str, method: &str, params: P) -> crate::Result<R>
where
P: Serialize,
R: DeserializeOwned,
{
let id = self.next_id().await;
let request = JsonRpcRequest {
jsonrpc: "2.0",
method: method.to_string(),
params,
id,
};
debug!(method = %method, id = %id, "Making external RPC call");
let response = self
.http_client
.post(http_url)
.json(&request)
.send()
.await
.map_err(|e| crate::Error::Rpc(format!("HTTP request failed: {}", e)))?;
let status = response.status();
if !status.is_success() {
return Err(crate::Error::Rpc(format!("HTTP error: {}", status)));
}
let rpc_response: JsonRpcResponse<R> = response
.json()
.await
.map_err(|e| crate::Error::Rpc(format!("Failed to parse response: {}", e)))?;
if let Some(error) = rpc_response.error {
return Err(crate::Error::Rpc(format!(
"RPC error {}: {}",
error.code, error.message
)));
}
rpc_response
.result
.ok_or_else(|| crate::Error::Rpc("Empty response".to_string()))
}
// ============================================================================
// Public API Methods
// ============================================================================
/// Gets the balance for an address
pub async fn get_balance(&self, address: &str) -> crate::Result<Balance> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBalanceByAddress", (address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
// Direct call to embedded node
if let Some(node) = self.node_manager.embedded_node().await {
// TODO: Implement direct UTXO query from embedded node
// For now, use the internal RPC
let rpc_addr = format!(
"http://{}",
node.config().rpc.http_addr
);
self.call_external(&rpc_addr, "synor_getBalanceByAddress", (address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets UTXOs for an address
pub async fn get_utxos(&self, address: &str) -> crate::Result<Vec<Utxo>> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getUtxosByAddress", (address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getUtxosByAddress", (address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Broadcasts a transaction to the network
pub async fn broadcast_transaction(&self, tx: &RawTransaction) -> crate::Result<String> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_submitTransaction", (tx,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_submitTransaction", (tx,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets transaction status
pub async fn get_transaction_status(&self, txid: &str) -> crate::Result<TransactionStatus> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getTransaction", (txid,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getTransaction", (txid,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets network information
pub async fn get_network_info(&self) -> crate::Result<NetworkInfo> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getInfo", ())
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
// Build network info from embedded node directly
let info = node.info().await;
Ok(NetworkInfo {
network: info.network,
is_synced: !info.is_syncing,
block_height: info.block_height,
blue_score: info.blue_score,
difficulty: 0.0, // TODO: Get from consensus
peer_count: info.peer_count,
mempool_size: 0, // TODO: Get from mempool
})
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets fee estimate
pub async fn get_fee_estimate(&self) -> crate::Result<FeeEstimate> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_estimateFee", ())
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_estimateFee", ())
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets block by hash
pub async fn get_block(&self, hash: &str) -> crate::Result<serde_json::Value> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBlock", (hash, true))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getBlock", (hash, true))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets current block template for mining
pub async fn get_block_template(&self, coinbase_address: &str) -> crate::Result<serde_json::Value> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBlockTemplate", (coinbase_address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getBlockTemplate", (coinbase_address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Submits a mined block
pub async fn submit_block(&self, block: &serde_json::Value) -> crate::Result<bool> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_submitBlock", (block,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_submitBlock", (block,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets connected peers
pub async fn get_peers(&self) -> crate::Result<Vec<crate::node::PeerInfo>> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
// External: call RPC
let peers: Vec<serde_json::Value> = self
.call_external(&http_url, "net_getPeerInfo", ())
.await?;
// Convert to our PeerInfo type
let peer_infos = peers
.into_iter()
.map(|p| crate::node::PeerInfo {
peer_id: p["id"].as_str().unwrap_or("").to_string(),
address: p["address"].as_str().unwrap_or("").to_string(),
direction: if p["isOutbound"].as_bool().unwrap_or(false) {
"outbound"
} else {
"inbound"
}
.to_string(),
latency_ms: p["lastPingDuration"].as_u64(),
block_height: p["syncedHeaders"].as_u64().unwrap_or(0),
status: "connected".to_string(),
})
.collect();
Ok(peer_infos)
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
self.node_manager.get_peers().await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_balance_serialization() {
let balance = Balance {
address: "synor:qztest123".to_string(),
balance: 1_000_000_000,
};
let json = serde_json::to_string(&balance).unwrap();
assert!(json.contains("synor:qztest123"));
assert!(json.contains("1000000000"));
}
#[test]
fn test_utxo_serialization() {
let utxo = Utxo {
transaction_id: "abc123".to_string(),
index: 0,
amount: 5_000_000_000,
script_public_key: "76a914...88ac".to_string(),
block_daa_score: 100,
is_coinbase: false,
};
let json = serde_json::to_string(&utxo).unwrap();
assert!(json.contains("transactionId"));
assert!(json.contains("blockDaaScore"));
}
#[test]
fn test_network_info_serialization() {
let info = NetworkInfo {
network: "testnet".to_string(),
is_synced: true,
block_height: 10000,
blue_score: 5000,
difficulty: 12345.67,
peer_count: 25,
mempool_size: 50,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("isSynced"));
assert!(json.contains("blockHeight"));
}
}

View file

@ -0,0 +1,491 @@
//! Multi-wallet management for the desktop wallet
//!
//! Supports multiple wallets with:
//! - Unique IDs for each wallet
//! - Labels/names for easy identification
//! - Switching between wallets
//! - Wallet-specific data directories
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::wallet::{WalletMetadata, WalletState};
use crate::{Error, Result};
/// Wallet index file name
const WALLETS_INDEX_FILE: &str = "wallets.json";
/// Summary info for a wallet (non-sensitive, used in listings)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletSummary {
/// Unique wallet identifier
pub id: String,
/// User-defined label/name
pub label: String,
/// Primary address (first derived)
pub primary_address: String,
/// Network (mainnet/testnet)
pub network: String,
/// Creation timestamp
pub created_at: i64,
/// Last access timestamp
pub last_accessed: i64,
/// Whether this is the active wallet
#[serde(skip)]
pub is_active: bool,
}
/// Persisted wallet index
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WalletsIndex {
/// Map of wallet ID to summary
pub wallets: HashMap<String, WalletSummary>,
/// Currently active wallet ID
pub active_wallet_id: Option<String>,
}
/// Manages multiple wallets
pub struct WalletManager {
/// Base data directory (contains wallet subdirectories)
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
/// Index of all wallets
pub index: Arc<RwLock<WalletsIndex>>,
/// Currently active wallet state
pub active_wallet: Arc<RwLock<Option<WalletState>>>,
/// Currently active wallet ID
pub active_wallet_id: Arc<RwLock<Option<String>>>,
}
impl WalletManager {
/// Create a new wallet manager
pub fn new() -> Self {
Self {
data_dir: Arc::new(RwLock::new(None)),
index: Arc::new(RwLock::new(WalletsIndex::default())),
active_wallet: Arc::new(RwLock::new(None)),
active_wallet_id: Arc::new(RwLock::new(None)),
}
}
/// Set the base data directory
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
tokio::fs::create_dir_all(&path).await
.map_err(|e| Error::Io(e))?;
let mut data_dir = self.data_dir.write().await;
*data_dir = Some(path);
// Load existing index
drop(data_dir);
self.load_index().await?;
Ok(())
}
/// Get the wallets index file path
async fn index_path(&self) -> Result<PathBuf> {
let data_dir = self.data_dir.read().await;
data_dir
.as_ref()
.map(|p| p.join(WALLETS_INDEX_FILE))
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
}
/// Get a wallet's data directory
async fn wallet_dir(&self, wallet_id: &str) -> Result<PathBuf> {
let data_dir = self.data_dir.read().await;
data_dir
.as_ref()
.map(|p| p.join("wallets").join(wallet_id))
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
}
/// Load the wallets index from disk
pub async fn load_index(&self) -> Result<()> {
let path = self.index_path().await?;
if !path.exists() {
return Ok(()); // No index yet, will be created on first wallet
}
let json = tokio::fs::read_to_string(&path).await
.map_err(|e| Error::Io(e))?;
let loaded_index: WalletsIndex = serde_json::from_str(&json)
.map_err(|e| Error::Serialization(e.to_string()))?;
let mut index = self.index.write().await;
*index = loaded_index;
Ok(())
}
/// Save the wallets index to disk
pub async fn save_index(&self) -> Result<()> {
let path = self.index_path().await?;
let index = self.index.read().await;
let json = serde_json::to_string_pretty(&*index)
.map_err(|e| Error::Serialization(e.to_string()))?;
tokio::fs::write(&path, json).await
.map_err(|e| Error::Io(e))?;
Ok(())
}
/// List all wallets
pub async fn list_wallets(&self) -> Vec<WalletSummary> {
let index = self.index.read().await;
let active_id = self.active_wallet_id.read().await;
let mut wallets: Vec<WalletSummary> = index.wallets.values().cloned().collect();
// Mark active wallet
if let Some(active) = active_id.as_ref() {
for wallet in &mut wallets {
wallet.is_active = &wallet.id == active;
}
}
// Sort by last accessed (most recent first)
wallets.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
wallets
}
/// Get active wallet ID
pub async fn get_active_wallet_id(&self) -> Option<String> {
self.active_wallet_id.read().await.clone()
}
/// Create a new wallet
pub async fn create_wallet(
&self,
label: String,
password: &str,
testnet: bool,
) -> Result<(String, String, String)> {
// Generate unique wallet ID
let wallet_id = Uuid::new_v4().to_string();
// Create wallet directory
let wallet_dir = self.wallet_dir(&wallet_id).await?;
tokio::fs::create_dir_all(&wallet_dir).await
.map_err(|e| Error::Io(e))?;
// Create wallet state for this wallet
let wallet_state = WalletState::new();
wallet_state.set_data_dir(wallet_dir).await?;
// Create the wallet (generates mnemonic)
let (mnemonic, address) = wallet_state.create(password, testnet).await?;
// Add to index
let summary = WalletSummary {
id: wallet_id.clone(),
label,
primary_address: address.clone(),
network: if testnet { "testnet".to_string() } else { "mainnet".to_string() },
created_at: current_timestamp(),
last_accessed: current_timestamp(),
is_active: true,
};
{
let mut index = self.index.write().await;
index.wallets.insert(wallet_id.clone(), summary);
index.active_wallet_id = Some(wallet_id.clone());
}
// Save index
self.save_index().await?;
// Set as active wallet
{
let mut active = self.active_wallet.write().await;
*active = Some(wallet_state);
}
{
let mut active_id = self.active_wallet_id.write().await;
*active_id = Some(wallet_id.clone());
}
Ok((wallet_id, mnemonic, address))
}
/// Import a wallet from mnemonic
pub async fn import_wallet(
&self,
label: String,
mnemonic: &str,
password: &str,
testnet: bool,
) -> Result<(String, String)> {
// Generate unique wallet ID
let wallet_id = Uuid::new_v4().to_string();
// Create wallet directory
let wallet_dir = self.wallet_dir(&wallet_id).await?;
tokio::fs::create_dir_all(&wallet_dir).await
.map_err(|e| Error::Io(e))?;
// Create wallet state for this wallet
let wallet_state = WalletState::new();
wallet_state.set_data_dir(wallet_dir).await?;
// Import the wallet
let address = wallet_state.import(mnemonic, password, testnet).await?;
// Add to index
let summary = WalletSummary {
id: wallet_id.clone(),
label,
primary_address: address.clone(),
network: if testnet { "testnet".to_string() } else { "mainnet".to_string() },
created_at: current_timestamp(),
last_accessed: current_timestamp(),
is_active: true,
};
{
let mut index = self.index.write().await;
index.wallets.insert(wallet_id.clone(), summary);
index.active_wallet_id = Some(wallet_id.clone());
}
// Save index
self.save_index().await?;
// Set as active wallet
{
let mut active = self.active_wallet.write().await;
*active = Some(wallet_state);
}
{
let mut active_id = self.active_wallet_id.write().await;
*active_id = Some(wallet_id.clone());
}
Ok((wallet_id, address))
}
/// Switch to a different wallet
pub async fn switch_wallet(&self, wallet_id: &str) -> Result<()> {
// Check wallet exists
{
let index = self.index.read().await;
if !index.wallets.contains_key(wallet_id) {
return Err(Error::WalletNotFound);
}
}
// Lock current wallet if any
{
let mut active = self.active_wallet.write().await;
if let Some(wallet) = active.as_ref() {
wallet.lock().await;
}
*active = None;
}
// Load the new wallet's data
let wallet_dir = self.wallet_dir(wallet_id).await?;
let wallet_state = WalletState::new();
wallet_state.set_data_dir(wallet_dir).await?;
wallet_state.load_metadata().await?;
// Update active wallet
{
let mut active = self.active_wallet.write().await;
*active = Some(wallet_state);
}
{
let mut active_id = self.active_wallet_id.write().await;
*active_id = Some(wallet_id.to_string());
}
// Update index with new active and last accessed
{
let mut index = self.index.write().await;
index.active_wallet_id = Some(wallet_id.to_string());
if let Some(summary) = index.wallets.get_mut(wallet_id) {
summary.last_accessed = current_timestamp();
}
}
self.save_index().await?;
Ok(())
}
/// Rename a wallet
pub async fn rename_wallet(&self, wallet_id: &str, new_label: String) -> Result<()> {
let mut index = self.index.write().await;
let summary = index.wallets.get_mut(wallet_id)
.ok_or(Error::WalletNotFound)?;
summary.label = new_label;
drop(index);
self.save_index().await?;
Ok(())
}
/// Delete a wallet
pub async fn delete_wallet(&self, wallet_id: &str) -> Result<()> {
// Don't allow deleting the active wallet while it's active
{
let active_id = self.active_wallet_id.read().await;
if active_id.as_ref() == Some(&wallet_id.to_string()) {
return Err(Error::Internal(
"Cannot delete the currently active wallet. Switch to another wallet first.".to_string()
));
}
}
// Remove from index
{
let mut index = self.index.write().await;
index.wallets.remove(wallet_id);
}
self.save_index().await?;
// Delete wallet directory
let wallet_dir = self.wallet_dir(wallet_id).await?;
if wallet_dir.exists() {
tokio::fs::remove_dir_all(&wallet_dir).await
.map_err(|e| Error::Io(e))?;
}
Ok(())
}
/// Get active wallet state (returns a clone reference for thread safety)
pub async fn get_active_wallet(&self) -> Result<Arc<RwLock<Option<WalletState>>>> {
Ok(self.active_wallet.clone())
}
/// Check if active wallet is unlocked
pub async fn is_active_unlocked(&self) -> bool {
let active = self.active_wallet.read().await;
if let Some(wallet) = active.as_ref() {
wallet.is_unlocked().await
} else {
false
}
}
/// Unlock the active wallet
pub async fn unlock_active(&self, password: &str) -> Result<()> {
let active = self.active_wallet.read().await;
let wallet = active.as_ref().ok_or(Error::WalletNotFound)?;
wallet.unlock(password).await
}
/// Lock the active wallet
pub async fn lock_active(&self) -> Result<()> {
let active = self.active_wallet.read().await;
if let Some(wallet) = active.as_ref() {
wallet.lock().await;
}
Ok(())
}
/// Initialize from existing single wallet (migration)
/// This migrates a legacy single-wallet setup to multi-wallet
pub async fn migrate_legacy_wallet(&self) -> Result<Option<String>> {
let data_dir = self.data_dir.read().await;
let base_dir = data_dir.as_ref()
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))?;
// Check for legacy wallet.json in base directory
let legacy_path = base_dir.join("wallet.json");
if !legacy_path.exists() {
return Ok(None);
}
// Read legacy wallet
let json = tokio::fs::read_to_string(&legacy_path).await
.map_err(|e| Error::Io(e))?;
let legacy_meta: WalletMetadata = serde_json::from_str(&json)
.map_err(|e| Error::Serialization(e.to_string()))?;
// Generate ID for migrated wallet
let wallet_id = Uuid::new_v4().to_string();
// Create new wallet directory
let wallet_dir = base_dir.join("wallets").join(&wallet_id);
tokio::fs::create_dir_all(&wallet_dir).await
.map_err(|e| Error::Io(e))?;
// Move wallet.json to new location
let new_wallet_path = wallet_dir.join("wallet.json");
tokio::fs::copy(&legacy_path, &new_wallet_path).await
.map_err(|e| Error::Io(e))?;
// Create summary
let primary_address = legacy_meta.addresses.first()
.map(|a| a.address.clone())
.unwrap_or_else(|| "Unknown".to_string());
let summary = WalletSummary {
id: wallet_id.clone(),
label: "Main Wallet".to_string(),
primary_address,
network: legacy_meta.network,
created_at: legacy_meta.created_at,
last_accessed: current_timestamp(),
is_active: true,
};
// Update index
{
let mut index = self.index.write().await;
index.wallets.insert(wallet_id.clone(), summary);
index.active_wallet_id = Some(wallet_id.clone());
}
self.save_index().await?;
// Rename legacy file to indicate migration
let backup_path = base_dir.join("wallet.json.migrated");
tokio::fs::rename(&legacy_path, &backup_path).await
.map_err(|e| Error::Io(e))?;
// Load the migrated wallet as active
self.switch_wallet(&wallet_id).await?;
Ok(Some(wallet_id))
}
/// Get wallet count
pub async fn wallet_count(&self) -> usize {
let index = self.index.read().await;
index.wallets.len()
}
/// Check if any wallets exist
pub async fn has_wallets(&self) -> bool {
let index = self.index.read().await;
!index.wallets.is_empty()
}
}
impl Default for WalletManager {
fn default() -> Self {
Self::new()
}
}
/// Get current timestamp
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

View file

@ -0,0 +1,283 @@
//! Watch-only address management
//!
//! Allows monitoring addresses without holding private keys.
//! Useful for:
//! - Monitoring cold storage addresses
//! - Tracking other wallets
//! - Observing addresses before import
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
/// Watch-only addresses file name
const WATCH_ONLY_FILE: &str = "watch_only.json";
/// A watch-only address entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchOnlyAddress {
/// Bech32 encoded address
pub address: String,
/// User-defined label
pub label: String,
/// Network (mainnet/testnet)
pub network: String,
/// When this address was added
pub added_at: i64,
/// Optional notes
pub notes: Option<String>,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
/// Last known balance (cached)
#[serde(default)]
pub cached_balance: Option<u64>,
/// When balance was last updated
#[serde(default)]
pub balance_updated_at: Option<i64>,
}
/// Persisted watch-only addresses
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WatchOnlyData {
/// Map of address -> entry
pub addresses: HashMap<String, WatchOnlyAddress>,
}
/// Watch-only address manager
pub struct WatchOnlyManager {
/// Data directory
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
/// Watch-only addresses
pub data: Arc<RwLock<WatchOnlyData>>,
}
impl WatchOnlyManager {
/// Create a new manager
pub fn new() -> Self {
Self {
data_dir: Arc::new(RwLock::new(None)),
data: Arc::new(RwLock::new(WatchOnlyData::default())),
}
}
/// Set the data directory
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
tokio::fs::create_dir_all(&path).await
.map_err(|e| Error::Io(e))?;
let mut data_dir = self.data_dir.write().await;
*data_dir = Some(path);
// Load existing data
drop(data_dir);
self.load().await?;
Ok(())
}
/// Get the file path
async fn file_path(&self) -> Result<PathBuf> {
let data_dir = self.data_dir.read().await;
data_dir
.as_ref()
.map(|p| p.join(WATCH_ONLY_FILE))
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
}
/// Load watch-only addresses from disk
pub async fn load(&self) -> Result<()> {
let path = self.file_path().await?;
if !path.exists() {
return Ok(());
}
let json = tokio::fs::read_to_string(&path).await
.map_err(|e| Error::Io(e))?;
let loaded: WatchOnlyData = serde_json::from_str(&json)
.map_err(|e| Error::Serialization(e.to_string()))?;
let mut data = self.data.write().await;
*data = loaded;
Ok(())
}
/// Save watch-only addresses to disk
pub async fn save(&self) -> Result<()> {
let path = self.file_path().await?;
let data = self.data.read().await;
let json = serde_json::to_string_pretty(&*data)
.map_err(|e| Error::Serialization(e.to_string()))?;
tokio::fs::write(&path, json).await
.map_err(|e| Error::Io(e))?;
Ok(())
}
/// Add a watch-only address
pub async fn add_address(
&self,
address: String,
label: String,
network: String,
notes: Option<String>,
tags: Vec<String>,
) -> Result<WatchOnlyAddress> {
// Validate address format (basic check)
if !address.starts_with("synor1") && !address.starts_with("tsynor1") {
return Err(Error::Validation("Invalid address format".to_string()));
}
// Check for duplicates
{
let data = self.data.read().await;
if data.addresses.contains_key(&address) {
return Err(Error::Validation("Address already exists".to_string()));
}
}
let entry = WatchOnlyAddress {
address: address.clone(),
label,
network,
added_at: current_timestamp(),
notes,
tags,
cached_balance: None,
balance_updated_at: None,
};
{
let mut data = self.data.write().await;
data.addresses.insert(address.clone(), entry.clone());
}
self.save().await?;
Ok(entry)
}
/// Update a watch-only address
pub async fn update_address(
&self,
address: &str,
label: Option<String>,
notes: Option<String>,
tags: Option<Vec<String>>,
) -> Result<WatchOnlyAddress> {
let mut data = self.data.write().await;
let entry = data.addresses.get_mut(address)
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
if let Some(l) = label {
entry.label = l;
}
if let Some(n) = notes {
entry.notes = Some(n);
}
if let Some(t) = tags {
entry.tags = t;
}
let updated = entry.clone();
drop(data);
self.save().await?;
Ok(updated)
}
/// Remove a watch-only address
pub async fn remove_address(&self, address: &str) -> Result<()> {
let mut data = self.data.write().await;
if data.addresses.remove(address).is_none() {
return Err(Error::NotFound("Watch-only address not found".to_string()));
}
drop(data);
self.save().await?;
Ok(())
}
/// List all watch-only addresses
pub async fn list_addresses(&self) -> Vec<WatchOnlyAddress> {
let data = self.data.read().await;
let mut addresses: Vec<WatchOnlyAddress> = data.addresses.values().cloned().collect();
addresses.sort_by(|a, b| b.added_at.cmp(&a.added_at));
addresses
}
/// Get a specific watch-only address
pub async fn get_address(&self, address: &str) -> Option<WatchOnlyAddress> {
let data = self.data.read().await;
data.addresses.get(address).cloned()
}
/// Update cached balance for an address
pub async fn update_balance(&self, address: &str, balance: u64) -> Result<()> {
let mut data = self.data.write().await;
let entry = data.addresses.get_mut(address)
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
entry.cached_balance = Some(balance);
entry.balance_updated_at = Some(current_timestamp());
drop(data);
self.save().await?;
Ok(())
}
/// Get addresses by tag
pub async fn get_addresses_by_tag(&self, tag: &str) -> Vec<WatchOnlyAddress> {
let data = self.data.read().await;
data.addresses.values()
.filter(|a| a.tags.contains(&tag.to_string()))
.cloned()
.collect()
}
/// Get all unique tags
pub async fn get_all_tags(&self) -> Vec<String> {
let data = self.data.read().await;
let mut tags: Vec<String> = data.addresses.values()
.flat_map(|a| a.tags.clone())
.collect();
tags.sort();
tags.dedup();
tags
}
/// Get total count
pub async fn count(&self) -> usize {
let data = self.data.read().await;
data.addresses.len()
}
}
impl Default for WatchOnlyManager {
fn default() -> Self {
Self::new()
}
}
/// Get current timestamp
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Synor Wallet",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "io.synor.wallet",
"build": {
"beforeDevCommand": "pnpm dev",
@ -34,17 +34,9 @@
}
},
"plugins": {
"fs": {
"scope": {
"allow": ["$APPDATA/*", "$HOME/.synor/*"]
}
},
"store": {},
"shell": {
"open": true
},
"dialog": {},
"clipboard-manager": {},
"updater": {
"endpoints": [
"https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"
@ -53,8 +45,7 @@
"windows": {
"installMode": "passive"
}
},
"notification": {}
}
},
"bundle": {
"active": true,

View file

@ -8,24 +8,88 @@ import UpdateBanner from './components/UpdateBanner';
// Hooks
import { useTrayEvents } from './hooks/useTrayEvents';
import { useNodeEvents } from './hooks/useNodeEvents';
import { useMiningEvents } from './hooks/useMiningEvents';
// Pages
// Onboarding Pages
import Welcome from './pages/Welcome';
import CreateWallet from './pages/CreateWallet';
import ImportWallet from './pages/ImportWallet';
import Unlock from './pages/Unlock';
// Core Wallet Pages
import Dashboard from './pages/Dashboard';
import Send from './pages/Send';
import Receive from './pages/Receive';
import History from './pages/History';
import Settings from './pages/Settings';
// Node & Mining Pages
import NodeDashboard from './pages/Node/NodeDashboard';
import MiningDashboard from './pages/Mining/MiningDashboard';
// Smart Contract Pages
import ContractsDashboard from './pages/Contracts/ContractsDashboard';
import TokensDashboard from './pages/Tokens/TokensDashboard';
import NftsDashboard from './pages/NFTs/NftsDashboard';
// DeFi Pages
import StakingDashboard from './pages/Staking/StakingDashboard';
import SwapDashboard from './pages/Swap/SwapDashboard';
import MarketDashboard from './pages/Market/MarketDashboard';
// Infrastructure Pages
import StorageDashboard from './pages/Storage/StorageDashboard';
import HostingDashboard from './pages/Hosting/HostingDashboard';
import ComputeDashboard from './pages/Compute/ComputeDashboard';
import DatabaseDashboard from './pages/Database/DatabaseDashboard';
// Privacy & Bridge Pages
import PrivacyDashboard from './pages/Privacy/PrivacyDashboard';
import BridgeDashboard from './pages/Bridge/BridgeDashboard';
// Governance & L2 Pages
import GovernanceDashboard from './pages/Governance/GovernanceDashboard';
import ZKDashboard from './pages/ZK/ZKDashboard';
// Tools Pages
import DAppBrowser from './pages/DApps/DAppBrowser';
import AddressBookPage from './pages/AddressBook/AddressBookPage';
import MultisigDashboard from './pages/Multisig/MultisigDashboard';
import HardwareWalletPage from './pages/Hardware/HardwareWalletPage';
import QRScannerPage from './pages/QRScanner/QRScannerPage';
import BackupPage from './pages/Backup/BackupPage';
import WatchOnlyDashboard from './pages/WatchOnly/WatchOnlyDashboard';
import BatchSendDashboard from './pages/BatchSend/BatchSendDashboard';
import FeeAnalyticsDashboard from './pages/FeeAnalytics/FeeAnalyticsDashboard';
import VaultsDashboard from './pages/Vaults/VaultsDashboard';
import RecoveryDashboard from './pages/Recovery/RecoveryDashboard';
import DecoyDashboard from './pages/Decoy/DecoyDashboard';
import MixerDashboard from './pages/Mixer/MixerDashboard';
import LimitOrdersDashboard from './pages/LimitOrders/LimitOrdersDashboard';
import YieldDashboard from './pages/Yield/YieldDashboard';
import PortfolioDashboard from './pages/Portfolio/PortfolioDashboard';
import AlertsDashboard from './pages/Alerts/AlertsDashboard';
import CliDashboard from './pages/CLI/CliDashboard';
import RpcProfilesDashboard from './pages/RpcProfiles/RpcProfilesDashboard';
import TxBuilderDashboard from './pages/TxBuilder/TxBuilderDashboard';
import PluginsDashboard from './pages/Plugins/PluginsDashboard';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isUnlocked } = useWalletStore();
return isUnlocked ? <>{children}</> : <Navigate to="/unlock" replace />;
}
function App() {
const { isInitialized, isUnlocked } = useWalletStore();
// Listen for system tray events
useTrayEvents();
// Setup node and mining event listeners
useNodeEvents();
useMiningEvents();
return (
<div className="h-screen flex flex-col bg-gray-950">
{/* Update notification banner */}
@ -56,34 +120,355 @@ function App() {
{/* Protected routes (require unlocked wallet) */}
<Route element={<Layout />}>
{/* Core Wallet */}
<Route
path="/dashboard"
element={
isUnlocked ? <Dashboard /> : <Navigate to="/unlock" replace />
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/send"
element={
isUnlocked ? <Send /> : <Navigate to="/unlock" replace />
<ProtectedRoute>
<Send />
</ProtectedRoute>
}
/>
<Route
path="/receive"
element={
isUnlocked ? <Receive /> : <Navigate to="/unlock" replace />
<ProtectedRoute>
<Receive />
</ProtectedRoute>
}
/>
<Route
path="/history"
element={
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
<ProtectedRoute>
<History />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
isUnlocked ? <Settings /> : <Navigate to="/unlock" replace />
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
{/* Node & Mining */}
<Route
path="/node"
element={
<ProtectedRoute>
<NodeDashboard />
</ProtectedRoute>
}
/>
<Route
path="/mining"
element={
<ProtectedRoute>
<MiningDashboard />
</ProtectedRoute>
}
/>
{/* Smart Contracts */}
<Route
path="/contracts"
element={
<ProtectedRoute>
<ContractsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/tokens"
element={
<ProtectedRoute>
<TokensDashboard />
</ProtectedRoute>
}
/>
<Route
path="/nfts"
element={
<ProtectedRoute>
<NftsDashboard />
</ProtectedRoute>
}
/>
{/* DeFi */}
<Route
path="/staking"
element={
<ProtectedRoute>
<StakingDashboard />
</ProtectedRoute>
}
/>
<Route
path="/swap"
element={
<ProtectedRoute>
<SwapDashboard />
</ProtectedRoute>
}
/>
<Route
path="/market"
element={
<ProtectedRoute>
<MarketDashboard />
</ProtectedRoute>
}
/>
{/* Infrastructure */}
<Route
path="/storage"
element={
<ProtectedRoute>
<StorageDashboard />
</ProtectedRoute>
}
/>
<Route
path="/hosting"
element={
<ProtectedRoute>
<HostingDashboard />
</ProtectedRoute>
}
/>
<Route
path="/compute"
element={
<ProtectedRoute>
<ComputeDashboard />
</ProtectedRoute>
}
/>
<Route
path="/database"
element={
<ProtectedRoute>
<DatabaseDashboard />
</ProtectedRoute>
}
/>
{/* Privacy & Bridge */}
<Route
path="/privacy"
element={
<ProtectedRoute>
<PrivacyDashboard />
</ProtectedRoute>
}
/>
<Route
path="/bridge"
element={
<ProtectedRoute>
<BridgeDashboard />
</ProtectedRoute>
}
/>
{/* Governance & L2 */}
<Route
path="/governance"
element={
<ProtectedRoute>
<GovernanceDashboard />
</ProtectedRoute>
}
/>
<Route
path="/zk"
element={
<ProtectedRoute>
<ZKDashboard />
</ProtectedRoute>
}
/>
{/* Tools */}
<Route
path="/dapps"
element={
<ProtectedRoute>
<DAppBrowser />
</ProtectedRoute>
}
/>
<Route
path="/addressbook"
element={
<ProtectedRoute>
<AddressBookPage />
</ProtectedRoute>
}
/>
<Route
path="/multisig"
element={
<ProtectedRoute>
<MultisigDashboard />
</ProtectedRoute>
}
/>
<Route
path="/hardware"
element={
<ProtectedRoute>
<HardwareWalletPage />
</ProtectedRoute>
}
/>
<Route
path="/qr"
element={
<ProtectedRoute>
<QRScannerPage />
</ProtectedRoute>
}
/>
<Route
path="/backup"
element={
<ProtectedRoute>
<BackupPage />
</ProtectedRoute>
}
/>
<Route
path="/watch-only"
element={
<ProtectedRoute>
<WatchOnlyDashboard />
</ProtectedRoute>
}
/>
<Route
path="/batch-send"
element={
<ProtectedRoute>
<BatchSendDashboard />
</ProtectedRoute>
}
/>
<Route
path="/fee-analytics"
element={
<ProtectedRoute>
<FeeAnalyticsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/vaults"
element={
<ProtectedRoute>
<VaultsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/recovery"
element={
<ProtectedRoute>
<RecoveryDashboard />
</ProtectedRoute>
}
/>
<Route
path="/decoy"
element={
<ProtectedRoute>
<DecoyDashboard />
</ProtectedRoute>
}
/>
<Route
path="/mixer"
element={
<ProtectedRoute>
<MixerDashboard />
</ProtectedRoute>
}
/>
<Route
path="/limit-orders"
element={
<ProtectedRoute>
<LimitOrdersDashboard />
</ProtectedRoute>
}
/>
<Route
path="/yield"
element={
<ProtectedRoute>
<YieldDashboard />
</ProtectedRoute>
}
/>
<Route
path="/portfolio"
element={
<ProtectedRoute>
<PortfolioDashboard />
</ProtectedRoute>
}
/>
<Route
path="/alerts"
element={
<ProtectedRoute>
<AlertsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/cli"
element={
<ProtectedRoute>
<CliDashboard />
</ProtectedRoute>
}
/>
<Route
path="/rpc-profiles"
element={
<ProtectedRoute>
<RpcProfilesDashboard />
</ProtectedRoute>
}
/>
<Route
path="/tx-builder"
element={
<ProtectedRoute>
<TxBuilderDashboard />
</ProtectedRoute>
}
/>
<Route
path="/plugins"
element={
<ProtectedRoute>
<PluginsDashboard />
</ProtectedRoute>
}
/>
</Route>

View file

@ -0,0 +1,270 @@
import { ReactNode, useEffect, useState } from 'react';
/**
* Fade in animation wrapper
*/
export function FadeIn({
children,
delay = 0,
duration = 300,
className = '',
}: {
children: ReactNode;
delay?: number;
duration?: number;
className?: string;
}) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div
className={className}
style={{
opacity: visible ? 1 : 0,
transition: `opacity ${duration}ms ease-in-out`,
}}
>
{children}
</div>
);
}
/**
* Slide in from direction
*/
export function SlideIn({
children,
direction = 'left',
delay = 0,
duration = 300,
distance = 20,
className = '',
}: {
children: ReactNode;
direction?: 'left' | 'right' | 'up' | 'down';
delay?: number;
duration?: number;
distance?: number;
className?: string;
}) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
const transforms = {
left: `translateX(${visible ? 0 : -distance}px)`,
right: `translateX(${visible ? 0 : distance}px)`,
up: `translateY(${visible ? 0 : -distance}px)`,
down: `translateY(${visible ? 0 : distance}px)`,
};
return (
<div
className={className}
style={{
opacity: visible ? 1 : 0,
transform: transforms[direction],
transition: `all ${duration}ms ease-out`,
}}
>
{children}
</div>
);
}
/**
* Scale in animation
*/
export function ScaleIn({
children,
delay = 0,
duration = 200,
className = '',
}: {
children: ReactNode;
delay?: number;
duration?: number;
className?: string;
}) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div
className={className}
style={{
opacity: visible ? 1 : 0,
transform: `scale(${visible ? 1 : 0.95})`,
transition: `all ${duration}ms ease-out`,
}}
>
{children}
</div>
);
}
/**
* Stagger children animations
*/
export function StaggerChildren({
children,
staggerDelay = 50,
initialDelay = 0,
className = '',
}: {
children: ReactNode[];
staggerDelay?: number;
initialDelay?: number;
className?: string;
}) {
return (
<div className={className}>
{children.map((child, index) => (
<FadeIn key={index} delay={initialDelay + index * staggerDelay}>
{child}
</FadeIn>
))}
</div>
);
}
/**
* Pulse animation (for attention)
*/
export function Pulse({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`animate-pulse ${className}`}>
{children}
</div>
);
}
/**
* Bounce animation
*/
export function Bounce({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`animate-bounce ${className}`}>
{children}
</div>
);
}
/**
* Number counter animation
*/
export function CountUp({
end,
start = 0,
duration = 1000,
decimals = 0,
prefix = '',
suffix = '',
className = '',
}: {
end: number;
start?: number;
duration?: number;
decimals?: number;
prefix?: string;
suffix?: string;
className?: string;
}) {
const [count, setCount] = useState(start);
useEffect(() => {
const startTime = Date.now();
const diff = end - start;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = start + diff * eased;
setCount(current);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [end, start, duration]);
return (
<span className={className}>
{prefix}
{count.toFixed(decimals)}
{suffix}
</span>
);
}
/**
* Typing animation for text
*/
export function TypeWriter({
text,
speed = 50,
delay = 0,
className = '',
onComplete,
}: {
text: string;
speed?: number;
delay?: number;
className?: string;
onComplete?: () => void;
}) {
const [displayed, setDisplayed] = useState('');
useEffect(() => {
let index = 0;
const timer = setTimeout(() => {
const interval = setInterval(() => {
setDisplayed(text.slice(0, index + 1));
index++;
if (index >= text.length) {
clearInterval(interval);
onComplete?.();
}
}, speed);
return () => clearInterval(interval);
}, delay);
return () => clearTimeout(timer);
}, [text, speed, delay, onComplete]);
return (
<span className={className}>
{displayed}
<span className="animate-pulse">|</span>
</span>
);
}

View file

@ -0,0 +1,295 @@
import { useState } from 'react';
import { X, Eye, EyeOff, Copy, Check, AlertTriangle } from 'lucide-react';
import { useWalletManagerStore } from '../store/walletManager';
interface CreateWalletModalProps {
onClose: () => void;
}
export function CreateWalletModal({ onClose }: CreateWalletModalProps) {
const [step, setStep] = useState<'form' | 'mnemonic' | 'verify'>('form');
const [label, setLabel] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isTestnet, setIsTestnet] = useState(true);
const [mnemonic, setMnemonic] = useState('');
const [address, setAddress] = useState('');
const [mnemonicConfirmed, setMnemonicConfirmed] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const { createWallet, isLoading } = useWalletManagerStore();
const handleCreate = async () => {
// Validation
if (!label.trim()) {
setError('Please enter a wallet label');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setError('');
try {
const result = await createWallet(label.trim(), password, isTestnet);
setMnemonic(result.mnemonic);
setAddress(result.address);
setStep('mnemonic');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create wallet');
}
};
const handleCopyMnemonic = async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for clipboard API failure
}
};
const handleFinish = () => {
if (!mnemonicConfirmed) {
setError('Please confirm you have saved your recovery phrase');
return;
}
onClose();
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">
{step === 'form' && 'Create New Wallet'}
{step === 'mnemonic' && 'Backup Recovery Phrase'}
{step === 'verify' && 'Verify Backup'}
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
{step === 'form' && (
<div className="space-y-4">
{/* Wallet Label */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Wallet Label
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Main Wallet, Trading, Savings"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Confirm Password
</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Network Selection */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Network
</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setIsTestnet(true)}
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
isTestnet
? 'bg-synor-600/20 border-synor-500 text-synor-300'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
Testnet
</button>
<button
type="button"
onClick={() => setIsTestnet(false)}
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
!isTestnet
? 'bg-synor-600/20 border-synor-500 text-synor-300'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
Mainnet
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
<AlertTriangle size={14} />
{error}
</div>
)}
</div>
)}
{step === 'mnemonic' && (
<div className="space-y-4">
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="text-yellow-500 mt-0.5" size={18} />
<div>
<p className="text-yellow-300 font-medium text-sm">
Write down this recovery phrase
</p>
<p className="text-yellow-300/70 text-xs mt-1">
This is the ONLY way to recover your wallet. Store it securely
and never share it with anyone.
</p>
</div>
</div>
</div>
{/* Mnemonic Display */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="grid grid-cols-3 gap-2">
{mnemonic.split(' ').map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-gray-900/50 rounded px-2 py-1.5"
>
<span className="text-gray-600 text-xs w-4">{index + 1}.</span>
<span className="text-white text-sm font-mono">{word}</span>
</div>
))}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopyMnemonic}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
{copied ? (
<>
<Check size={16} className="text-green-400" />
<span className="text-green-400">Copied!</span>
</>
) : (
<>
<Copy size={16} />
Copy to clipboard
</>
)}
</button>
{/* Address */}
<div className="text-center text-sm">
<p className="text-gray-500">Your wallet address:</p>
<p className="text-synor-400 font-mono text-xs mt-1 break-all">
{address}
</p>
</div>
{/* Confirmation Checkbox */}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={mnemonicConfirmed}
onChange={(e) => setMnemonicConfirmed(e.target.checked)}
className="mt-1 w-4 h-4 rounded border-gray-600 bg-gray-800 text-synor-500 focus:ring-synor-500"
/>
<span className="text-sm text-gray-400">
I have securely saved my recovery phrase and understand that
losing it means losing access to my funds forever.
</span>
</label>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle size={14} />
{error}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-800 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
{step === 'form' && (
<button
onClick={handleCreate}
disabled={isLoading}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Wallet'}
</button>
)}
{step === 'mnemonic' && (
<button
onClick={handleFinish}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
Done
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
/**
* Error Boundary component for graceful error handling
*
* Catches JavaScript errors anywhere in the child component tree
* and displays a fallback UI instead of crashing the whole app.
*/
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: undefined });
};
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 bg-red-900/10 rounded-xl border border-red-800/50">
<AlertTriangle className="text-red-400 mb-4" size={48} />
<h2 className="text-xl font-semibold text-white mb-2">Something went wrong</h2>
<p className="text-gray-400 text-center mb-4 max-w-md">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.handleReset}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium transition-colors"
>
<RefreshCw size={16} />
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
/**
* HOC to wrap any component with error boundary
*/
export function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
fallback?: ReactNode
) {
return function WithErrorBoundary(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
};
}

View file

@ -0,0 +1,263 @@
import { useState } from 'react';
import { X, Eye, EyeOff, AlertTriangle, CheckCircle } from 'lucide-react';
import { useWalletManagerStore } from '../store/walletManager';
interface ImportWalletModalProps {
onClose: () => void;
}
export function ImportWalletModal({ onClose }: ImportWalletModalProps) {
const [step, setStep] = useState<'form' | 'success'>('form');
const [label, setLabel] = useState('');
const [mnemonic, setMnemonic] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isTestnet, setIsTestnet] = useState(true);
const [importedAddress, setImportedAddress] = useState('');
const [error, setError] = useState('');
const { importWallet, isLoading } = useWalletManagerStore();
// Validate mnemonic word count
const mnemonicWords = mnemonic.trim().split(/\s+/).filter(Boolean);
const isValidWordCount = mnemonicWords.length === 12 || mnemonicWords.length === 24;
const handleImport = async () => {
// Validation
if (!label.trim()) {
setError('Please enter a wallet label');
return;
}
if (!isValidWordCount) {
setError('Recovery phrase must be 12 or 24 words');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setError('');
try {
const address = await importWallet(
label.trim(),
mnemonic.trim().toLowerCase(),
password,
isTestnet
);
setImportedAddress(address);
setStep('success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import wallet');
}
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">
{step === 'form' ? 'Import Wallet' : 'Wallet Imported'}
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
{step === 'form' && (
<div className="space-y-4">
{/* Wallet Label */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Wallet Label
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Imported Wallet, Cold Storage"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Recovery Phrase */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Recovery Phrase
</label>
<textarea
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
placeholder="Enter your 12 or 24 word recovery phrase..."
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none font-mono text-sm"
/>
<p className="text-xs text-gray-500 mt-1.5">
Words: {mnemonicWords.length}
{mnemonicWords.length > 0 && !isValidWordCount && (
<span className="text-yellow-500 ml-2">
(needs 12 or 24 words)
</span>
)}
{isValidWordCount && (
<span className="text-green-500 ml-2">
<CheckCircle size={12} className="inline" />
</span>
)}
</p>
</div>
{/* Password */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
New Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password for this wallet"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Confirm Password
</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Network Selection */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Network
</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setIsTestnet(true)}
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
isTestnet
? 'bg-synor-600/20 border-synor-500 text-synor-300'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
Testnet
</button>
<button
type="button"
onClick={() => setIsTestnet(false)}
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
!isTestnet
? 'bg-synor-600/20 border-synor-500 text-synor-300'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
Mainnet
</button>
</div>
</div>
{/* Security Warning */}
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="text-yellow-500 mt-0.5" size={14} />
<p className="text-yellow-300/80 text-xs">
Make sure you&apos;re entering your recovery phrase on a secure device.
Never share your phrase with anyone.
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
<AlertTriangle size={14} />
{error}
</div>
)}
</div>
)}
{step === 'success' && (
<div className="text-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle size={32} className="text-green-400" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">
Wallet Imported!
</h3>
<p className="text-gray-400 text-sm mb-4">
Your wallet has been successfully imported and is ready to use.
</p>
<div className="bg-gray-800 rounded-lg p-3">
<p className="text-xs text-gray-500 mb-1">Wallet Address</p>
<p className="text-synor-400 font-mono text-sm break-all">
{importedAddress}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-800 flex gap-3">
{step === 'form' && (
<>
<button
onClick={onClose}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={isLoading || !isValidWordCount}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Importing...' : 'Import Wallet'}
</button>
</>
)}
{step === 'success' && (
<button
onClick={onClose}
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
Done
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -8,88 +8,244 @@ import {
Lock,
Wifi,
WifiOff,
Server,
Hammer,
FileCode2,
Coins,
Image,
PiggyBank,
ArrowLeftRight,
Users,
Globe,
Usb,
Shield,
BarChart3,
QrCode,
HardDrive,
Cloud,
Globe2,
Cpu,
Database,
EyeOff,
GitBranch,
Vote,
Layers,
Eye,
ListPlus,
Activity,
Timer,
ShieldCheck,
// Phase 7-16 icons
UserX,
Shuffle,
ArrowUpDown,
TrendingUp,
PieChart,
Bell,
Terminal,
Wrench,
Puzzle,
} from 'lucide-react';
import { useState } from 'react';
import { useWalletStore } from '../store/wallet';
import { useNodeStore } from '../store/node';
import { useMiningStore, formatHashrate } from '../store/mining';
import { NotificationsBell } from './NotificationsPanel';
import { WalletSelector } from './WalletSelector';
import { CreateWalletModal } from './CreateWalletModal';
import { ImportWalletModal } from './ImportWalletModal';
const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/send', label: 'Send', icon: Send },
{ to: '/batch-send', label: 'Batch Send', icon: ListPlus },
{ to: '/receive', label: 'Receive', icon: Download },
{ to: '/history', label: 'History', icon: History },
];
const defiNavItems = [
{ to: '/staking', label: 'Staking', icon: PiggyBank },
{ to: '/swap', label: 'Swap', icon: ArrowLeftRight },
{ to: '/market', label: 'Market', icon: BarChart3 },
];
const advancedNavItems = [
{ to: '/node', label: 'Node', icon: Server },
{ to: '/mining', label: 'Mining', icon: Hammer },
{ to: '/contracts', label: 'Contracts', icon: FileCode2 },
{ to: '/tokens', label: 'Tokens', icon: Coins },
{ to: '/nfts', label: 'NFTs', icon: Image },
];
const infrastructureNavItems = [
{ to: '/storage', label: 'Storage', icon: Cloud },
{ to: '/hosting', label: 'Hosting', icon: Globe2 },
{ to: '/compute', label: 'Compute', icon: Cpu },
{ to: '/database', label: 'Database', icon: Database },
];
const privacyBridgeNavItems = [
{ to: '/privacy', label: 'Privacy', icon: EyeOff },
{ to: '/bridge', label: 'Bridge', icon: GitBranch },
];
const governanceNavItems = [
{ to: '/governance', label: 'Governance', icon: Vote },
{ to: '/zk', label: 'ZK-Rollup', icon: Layers },
];
const toolsNavItems = [
{ to: '/watch-only', label: 'Watch-Only', icon: Eye },
{ to: '/fee-analytics', label: 'Fee Analytics', icon: Activity },
{ to: '/vaults', label: 'Time Vaults', icon: Timer },
{ to: '/recovery', label: 'Recovery', icon: ShieldCheck },
{ to: '/decoy', label: 'Decoy Wallets', icon: UserX },
{ to: '/mixer', label: 'Mixer', icon: Shuffle },
{ to: '/limit-orders', label: 'Limit Orders', icon: ArrowUpDown },
{ to: '/yield', label: 'Yield', icon: TrendingUp },
{ to: '/portfolio', label: 'Portfolio', icon: PieChart },
{ to: '/alerts', label: 'Alerts', icon: Bell },
{ to: '/cli', label: 'CLI', icon: Terminal },
{ to: '/rpc-profiles', label: 'RPC Profiles', icon: Server },
{ to: '/tx-builder', label: 'Tx Builder', icon: Wrench },
{ to: '/plugins', label: 'Plugins', icon: Puzzle },
{ to: '/dapps', label: 'DApps', icon: Globe },
{ to: '/addressbook', label: 'Address Book', icon: Users },
{ to: '/multisig', label: 'Multi-sig', icon: Shield },
{ to: '/hardware', label: 'Hardware', icon: Usb },
{ to: '/qr', label: 'QR Code', icon: QrCode },
{ to: '/backup', label: 'Backup', icon: HardDrive },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export default function Layout() {
const { lockWallet, networkStatus, balance } = useWalletStore();
const { lockWallet, balance } = useWalletStore();
const nodeStatus = useNodeStore((state) => state.status);
const miningStatus = useMiningStore((state) => state.status);
// Modal state for multi-wallet management
const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const handleLock = async () => {
await lockWallet();
};
const renderNavSection = (
items: typeof navItems,
title?: string
) => (
<>
{title && (
<div className="pt-4 pb-2">
<p className="px-4 text-xs text-gray-600 uppercase tracking-wider">{title}</p>
</div>
)}
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center justify-between px-4 py-2.5 rounded-lg transition-colors ${
isActive
? 'bg-synor-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`
}
>
<div className="flex items-center gap-3">
<Icon size={18} />
{label}
</div>
{/* Status indicators */}
{to === '/node' && nodeStatus.isConnected && (
<span className="w-2 h-2 rounded-full bg-green-400" />
)}
{to === '/mining' && miningStatus.isMining && (
<span className="text-xs text-synor-400">
{formatHashrate(miningStatus.hashrate)}
</span>
)}
</NavLink>
))}
</>
);
return (
<div className="flex h-full">
{/* Sidebar */}
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
{/* Wallet Selector */}
<div className="p-3 border-b border-gray-800">
<WalletSelector
onCreateWallet={() => setShowCreateModal(true)}
onImportWallet={() => setShowImportModal(true)}
/>
</div>
{/* Balance display */}
<div className="p-6 border-b border-gray-800">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
Balance
</p>
<p className="text-2xl font-bold text-white">
<div className="p-4 border-b border-gray-800">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
<p className="text-xl font-bold text-white">
{balance?.balanceHuman || '0 SYN'}
</p>
{balance?.pending ? (
<p className="text-xs text-gray-500 mt-1">
+ {(balance.pending / 100_000_000).toFixed(8)} SYN pending
+ {(balance.pending / 100_000_000).toFixed(8)} pending
</p>
) : null}
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-synor-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`
}
>
<Icon size={20} />
{label}
</NavLink>
))}
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
{renderNavSection(navItems)}
{renderNavSection(defiNavItems, 'DeFi')}
{renderNavSection(advancedNavItems, 'Advanced')}
{renderNavSection(infrastructureNavItems, 'Infrastructure')}
{renderNavSection(privacyBridgeNavItems, 'Privacy & Bridge')}
{renderNavSection(governanceNavItems, 'Governance')}
{renderNavSection(toolsNavItems, 'Tools')}
</nav>
{/* Footer */}
<div className="p-4 border-t border-gray-800 space-y-2">
{/* Network status */}
<div className="p-3 border-t border-gray-800 space-y-2">
{/* Notifications */}
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-gray-400">Notifications</span>
<NotificationsBell />
</div>
{/* Node status */}
<div className="flex items-center gap-2 px-4 py-2 text-sm">
{networkStatus.connected ? (
{nodeStatus.isConnected ? (
<>
<Wifi size={16} className="text-green-400" />
<span className="text-gray-400">
{networkStatus.network || 'Connected'}
<Wifi size={14} className="text-green-400" />
<span className="text-gray-400 text-xs">
{nodeStatus.network || 'Connected'}
{nodeStatus.isSyncing && ' (Syncing)'}
</span>
</>
) : (
<>
<WifiOff size={16} className="text-red-400" />
<span className="text-gray-400">Disconnected</span>
<WifiOff size={14} className="text-red-400" />
<span className="text-gray-400 text-xs">Disconnected</span>
</>
)}
</div>
{/* Block height */}
{nodeStatus.isConnected && nodeStatus.blockHeight > 0 && (
<div className="px-4 text-xs text-gray-500">
Block #{nodeStatus.blockHeight.toLocaleString()}
</div>
)}
{/* Lock button */}
<button
onClick={handleLock}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors text-sm"
>
<Lock size={16} />
<Lock size={14} />
Lock Wallet
</button>
</div>
@ -99,6 +255,14 @@ export default function Layout() {
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
{/* Multi-wallet Modals */}
{showCreateModal && (
<CreateWalletModal onClose={() => setShowCreateModal(false)} />
)}
{showImportModal && (
<ImportWalletModal onClose={() => setShowImportModal(false)} />
)}
</div>
);
}

View file

@ -0,0 +1,189 @@
import { RefreshCw, Loader2 } from 'lucide-react';
/**
* Spinning loader component
*/
export function LoadingSpinner({
size = 24,
className = '',
}: {
size?: number;
className?: string;
}) {
return (
<Loader2
size={size}
className={`animate-spin text-synor-400 ${className}`}
/>
);
}
/**
* Full-page loading overlay
*/
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<div className="fixed inset-0 bg-gray-950/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size={48} />
<p className="text-gray-300 text-lg">{message}</p>
</div>
</div>
);
}
/**
* Inline loading state
*/
export function LoadingInline({
message = 'Loading...',
size = 'md',
}: {
message?: string;
size?: 'sm' | 'md' | 'lg';
}) {
const sizes = {
sm: { icon: 16, text: 'text-sm' },
md: { icon: 20, text: 'text-base' },
lg: { icon: 24, text: 'text-lg' },
};
const { icon, text } = sizes[size];
return (
<div className="flex items-center gap-2 text-gray-400">
<LoadingSpinner size={icon} />
<span className={text}>{message}</span>
</div>
);
}
/**
* Button with loading state
*/
export function LoadingButton({
loading,
disabled,
onClick,
children,
variant = 'primary',
className = '',
}: {
loading: boolean;
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
className?: string;
}) {
const variants = {
primary: 'bg-synor-600 hover:bg-synor-700 text-white',
secondary: 'bg-gray-700 hover:bg-gray-600 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
};
return (
<button
onClick={onClick}
disabled={loading || disabled}
className={`
flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium
transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
${variants[variant]} ${className}
`}
>
{loading ? (
<>
<RefreshCw size={18} className="animate-spin" />
Processing...
</>
) : (
children
)}
</button>
);
}
/**
* Skeleton loading placeholder
*/
export function Skeleton({
width = '100%',
height = '1rem',
rounded = 'rounded',
className = '',
}: {
width?: string | number;
height?: string | number;
rounded?: 'rounded' | 'rounded-md' | 'rounded-lg' | 'rounded-xl' | 'rounded-full';
className?: string;
}) {
return (
<div
className={`animate-pulse bg-gray-700/50 ${rounded} ${className}`}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
}}
/>
);
}
/**
* Card skeleton for loading states
*/
export function CardSkeleton({ lines = 3 }: { lines?: number }) {
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<Skeleton width="60%" height="1.25rem" className="mb-3" />
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
width={i === lines - 1 ? '40%' : '100%'}
height="0.875rem"
className={i < lines - 1 ? 'mb-2' : ''}
/>
))}
</div>
);
}
/**
* Table skeleton for loading states
*/
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="space-y-2">
{/* Header */}
<div className="flex gap-4 p-3 bg-gray-900 rounded-lg">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} width={`${100 / columns}%`} height="1rem" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={rowIdx} className="flex gap-4 p-3 bg-gray-900/50 rounded-lg">
{Array.from({ length: columns }).map((_, colIdx) => (
<Skeleton key={colIdx} width={`${100 / columns}%`} height="1rem" />
))}
</div>
))}
</div>
);
}
/**
* Stats card skeleton
*/
export function StatsSkeleton({ count = 4 }: { count?: number }) {
return (
<div className={`grid grid-cols-1 md:grid-cols-${Math.min(count, 4)} gap-4`}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<Skeleton width="40%" height="0.75rem" className="mb-2" />
<Skeleton width="70%" height="1.5rem" />
</div>
))}
</div>
);
}

View file

@ -0,0 +1,277 @@
import { useState } from 'react';
import {
Bell,
BellOff,
Settings,
X,
Send,
Hammer,
Coins,
AlertCircle,
Info,
} from 'lucide-react';
import {
useNotificationsStore,
NotificationType,
requestNotificationPermission,
} from '../store/notifications';
interface NotificationsPanelProps {
isOpen: boolean;
onClose: () => void;
}
const TYPE_ICONS: Record<NotificationType, React.ReactNode> = {
transaction: <Send size={16} className="text-blue-400" />,
mining: <Hammer size={16} className="text-yellow-400" />,
staking: <Coins size={16} className="text-purple-400" />,
system: <Info size={16} className="text-gray-400" />,
price: <AlertCircle size={16} className="text-green-400" />,
};
export default function NotificationsPanel({ isOpen, onClose }: NotificationsPanelProps) {
const {
notifications,
preferences,
unreadCount,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
updatePreferences,
} = useNotificationsStore();
const [showSettings, setShowSettings] = useState(false);
const handleEnableNotifications = async () => {
const granted = await requestNotificationPermission();
if (granted) {
updatePreferences({ enabled: true });
}
};
const formatTime = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return new Date(timestamp).toLocaleDateString();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50" onClick={onClose}>
<div
className="absolute right-4 top-16 w-96 max-h-[70vh] bg-gray-900 rounded-xl border border-gray-800 shadow-xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<Bell size={20} className="text-synor-400" />
<h2 className="font-semibold text-white">Notifications</h2>
{unreadCount > 0 && (
<span className="px-2 py-0.5 bg-synor-600 text-white text-xs rounded-full">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Settings size={18} className="text-gray-400" />
</button>
<button
onClick={onClose}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<X size={18} className="text-gray-400" />
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="p-4 border-b border-gray-800 bg-gray-800/50">
<h3 className="text-sm font-medium text-white mb-3">Notification Settings</h3>
<div className="space-y-2">
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Enable Notifications</span>
<input
type="checkbox"
checked={preferences.enabled}
onChange={(e) => {
if (e.target.checked) {
handleEnableNotifications();
} else {
updatePreferences({ enabled: false });
}
}}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Transaction Alerts</span>
<input
type="checkbox"
checked={preferences.transactionAlerts}
onChange={(e) =>
updatePreferences({ transactionAlerts: e.target.checked })
}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Mining Alerts</span>
<input
type="checkbox"
checked={preferences.miningAlerts}
onChange={(e) =>
updatePreferences({ miningAlerts: e.target.checked })
}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Staking Alerts</span>
<input
type="checkbox"
checked={preferences.stakingAlerts}
onChange={(e) =>
updatePreferences({ stakingAlerts: e.target.checked })
}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Price Alerts</span>
<input
type="checkbox"
checked={preferences.priceAlerts}
onChange={(e) =>
updatePreferences({ priceAlerts: e.target.checked })
}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
<label className="flex items-center justify-between">
<span className="text-sm text-gray-400">Sound</span>
<input
type="checkbox"
checked={preferences.soundEnabled}
onChange={(e) =>
updatePreferences({ soundEnabled: e.target.checked })
}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
</label>
</div>
</div>
)}
{/* Actions */}
{notifications.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<button
onClick={markAllAsRead}
className="text-sm text-synor-400 hover:text-synor-300"
>
Mark all as read
</button>
<button
onClick={clearAll}
className="text-sm text-red-400 hover:text-red-300"
>
Clear all
</button>
</div>
)}
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{notifications.length > 0 ? (
<div className="divide-y divide-gray-800">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-800/50 transition-colors ${
!notification.read ? 'bg-synor-600/5' : ''
}`}
onClick={() => markAsRead(notification.id)}
>
<div className="flex items-start gap-3">
<div className="p-2 bg-gray-800 rounded-lg">
{TYPE_ICONS[notification.type]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4
className={`font-medium ${
notification.read ? 'text-gray-400' : 'text-white'
}`}
>
{notification.title}
</h4>
<button
onClick={(e) => {
e.stopPropagation();
removeNotification(notification.id);
}}
className="p-1 hover:bg-gray-700 rounded transition-colors"
>
<X size={14} className="text-gray-500" />
</button>
</div>
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">
{notification.message}
</p>
<span className="text-xs text-gray-600 mt-1 block">
{formatTime(notification.timestamp)}
</span>
</div>
{!notification.read && (
<div className="w-2 h-2 bg-synor-400 rounded-full mt-2" />
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<BellOff size={32} className="mb-3 opacity-50" />
<p>No notifications</p>
</div>
)}
</div>
</div>
</div>
);
}
// Bell button component to be used in the header/titlebar
export function NotificationsBell() {
const [isOpen, setIsOpen] = useState(false);
const { unreadCount } = useNotificationsStore();
return (
<>
<button
onClick={() => setIsOpen(true)}
className="relative p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Bell size={20} className="text-gray-400" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-synor-600 text-white text-xs rounded-full flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
<NotificationsPanel isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View file

@ -1,12 +1,34 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useState, useEffect } from 'react';
import { getCurrentWindow, mockWindow, isTauri } from '../lib/tauri';
import { Minus, Square, X } from 'lucide-react';
type AppWindow = typeof mockWindow;
/**
* Custom title bar for frameless window mode.
* Provides drag region and window controls.
*/
export default function TitleBar() {
const appWindow = getCurrentWindow();
const [appWindow, setAppWindow] = useState<AppWindow>(mockWindow);
useEffect(() => {
getCurrentWindow().then(setAppWindow);
}, []);
// Hide title bar in browser mode (no native window controls needed)
if (!isTauri()) {
return (
<div className="h-8 flex items-center justify-between bg-gray-900 border-b border-gray-800 select-none">
<div className="flex-1 h-full flex items-center px-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-br from-synor-400 to-synor-600" />
<span className="text-xs font-medium text-gray-400">Synor Wallet</span>
<span className="text-xs text-yellow-500 ml-2">(Browser Preview Mode)</span>
</div>
</div>
</div>
);
}
return (
<div

View file

@ -0,0 +1,291 @@
import { useState, useEffect, useRef } from 'react';
import {
Wallet,
ChevronDown,
Plus,
Import,
Edit2,
Trash2,
Check,
X,
MoreVertical,
} from 'lucide-react';
import { useWalletManagerStore, WalletSummary } from '../store/walletManager';
interface WalletSelectorProps {
onCreateWallet?: () => void;
onImportWallet?: () => void;
}
export function WalletSelector({ onCreateWallet, onImportWallet }: WalletSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState('');
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const {
wallets,
activeWalletId,
isLoading,
loadWallets,
switchWallet,
renameWallet,
deleteWallet,
} = useWalletManagerStore();
// Load wallets on mount
useEffect(() => {
loadWallets();
}, [loadWallets]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setMenuOpenId(null);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const activeWallet = wallets.find((w) => w.id === activeWalletId);
const handleSwitch = async (walletId: string) => {
if (walletId === activeWalletId) return;
try {
await switchWallet(walletId);
setIsOpen(false);
} catch {
// Error handled in store
}
};
const handleStartEdit = (wallet: WalletSummary, e: React.MouseEvent) => {
e.stopPropagation();
setEditingId(wallet.id);
setEditLabel(wallet.label);
setMenuOpenId(null);
};
const handleSaveEdit = async (walletId: string) => {
if (editLabel.trim()) {
try {
await renameWallet(walletId, editLabel.trim());
} catch {
// Error handled in store
}
}
setEditingId(null);
};
const handleCancelEdit = () => {
setEditingId(null);
setEditLabel('');
};
const handleDelete = async (walletId: string, e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpenId(null);
if (walletId === activeWalletId) {
alert('Cannot delete the active wallet. Switch to another wallet first.');
return;
}
if (confirm('Are you sure you want to delete this wallet? This action cannot be undone.')) {
try {
await deleteWallet(walletId);
} catch {
// Error handled in store
}
}
};
const toggleMenu = (walletId: string, e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpenId(menuOpenId === walletId ? null : walletId);
};
// Truncate address for display
const truncateAddress = (address: string) => {
if (address.length <= 16) return address;
return `${address.slice(0, 8)}...${address.slice(-6)}`;
};
return (
<div className="relative" ref={dropdownRef}>
{/* Selector Button */}
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors border border-gray-700/50"
>
<div className="w-8 h-8 rounded-full bg-synor-600/20 flex items-center justify-center">
<Wallet size={16} className="text-synor-400" />
</div>
<div className="flex-1 text-left min-w-0">
<p className="text-sm font-medium text-white truncate">
{activeWallet?.label || 'No Wallet'}
</p>
<p className="text-xs text-gray-500 truncate">
{activeWallet ? truncateAddress(activeWallet.primaryAddress) : 'Select a wallet'}
</p>
</div>
<ChevronDown
size={16}
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-2 bg-gray-900 border border-gray-800 rounded-lg shadow-xl z-50 overflow-hidden">
{/* Wallet List */}
<div className="max-h-64 overflow-y-auto">
{wallets.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
No wallets yet
</div>
) : (
wallets.map((wallet) => (
<div
key={wallet.id}
className={`relative ${
wallet.id === activeWalletId ? 'bg-synor-600/10' : 'hover:bg-gray-800/50'
}`}
>
{editingId === wallet.id ? (
// Edit mode
<div className="flex items-center gap-2 p-3">
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-synor-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(wallet.id);
if (e.key === 'Escape') handleCancelEdit();
}}
/>
<button
onClick={() => handleSaveEdit(wallet.id)}
className="p-1 text-green-400 hover:bg-green-400/10 rounded"
>
<Check size={14} />
</button>
<button
onClick={handleCancelEdit}
className="p-1 text-red-400 hover:bg-red-400/10 rounded"
>
<X size={14} />
</button>
</div>
) : (
// Normal mode
<button
onClick={() => handleSwitch(wallet.id)}
className="w-full flex items-center gap-3 p-3 text-left"
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
wallet.id === activeWalletId
? 'bg-synor-600/30'
: 'bg-gray-700/50'
}`}
>
<Wallet
size={14}
className={
wallet.id === activeWalletId
? 'text-synor-400'
: 'text-gray-400'
}
/>
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${
wallet.id === activeWalletId
? 'text-synor-300'
: 'text-white'
}`}
>
{wallet.label}
</p>
<p className="text-xs text-gray-500 truncate">
{truncateAddress(wallet.primaryAddress)}
</p>
</div>
{wallet.id === activeWalletId && (
<span className="text-xs text-synor-400 bg-synor-600/20 px-2 py-0.5 rounded">
Active
</span>
)}
<div className="relative">
<button
onClick={(e) => toggleMenu(wallet.id, e)}
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-700 rounded transition-colors"
>
<MoreVertical size={14} />
</button>
{/* Context Menu */}
{menuOpenId === wallet.id && (
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg z-10 min-w-[120px]">
<button
onClick={(e) => handleStartEdit(wallet, e)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white"
>
<Edit2 size={12} />
Rename
</button>
<button
onClick={(e) => handleDelete(wallet.id, e)}
disabled={wallet.id === activeWalletId}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={12} />
Delete
</button>
</div>
)}
</div>
</button>
)}
</div>
))
)}
</div>
{/* Actions */}
<div className="border-t border-gray-800">
<button
onClick={() => {
setIsOpen(false);
onCreateWallet?.();
}}
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
>
<Plus size={14} />
Create New Wallet
</button>
<button
onClick={() => {
setIsOpen(false);
onImportWallet?.();
}}
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
>
<Import size={14} />
Import Wallet
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
// UI Components
export { default as Layout } from './Layout';
export { default as TitleBar } from './TitleBar';
export { UpdateBanner } from './UpdateBanner';
export { NotificationsBell } from './NotificationsPanel';
// Error Handling
export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary';
// Loading States
export {
LoadingSpinner,
LoadingOverlay,
LoadingInline,
LoadingButton,
Skeleton,
CardSkeleton,
TableSkeleton,
StatsSkeleton,
} from './LoadingStates';
// Animations
export {
FadeIn,
SlideIn,
ScaleIn,
StaggerChildren,
Pulse,
Bounce,
CountUp,
TypeWriter,
} from './Animations';
// Multi-Wallet Components
export { WalletSelector } from './WalletSelector';
export { CreateWalletModal } from './CreateWalletModal';
export { ImportWalletModal } from './ImportWalletModal';

View file

@ -5,8 +5,7 @@
*/
import { useEffect, useState, useCallback } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { listen, invoke } from '../lib/tauri';
export interface UpdateInfo {
version: string;

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useMiningStore } from '../store/mining';
/**
* Hook to setup and cleanup mining event listeners
* Should be called once at the app level
*/
export function useMiningEvents() {
const setupEventListeners = useMiningStore(
(state) => state.setupEventListeners
);
const cleanupEventListeners = useMiningStore(
(state) => state.cleanupEventListeners
);
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
}
/**
* Hook to auto-start mining if enabled in settings
*/
export function useAutoStartMining() {
const autoStartMining = useMiningStore((state) => state.autoStartMining);
const defaultThreads = useMiningStore((state) => state.defaultThreads);
const defaultCoinbaseAddress = useMiningStore(
(state) => state.defaultCoinbaseAddress
);
const status = useMiningStore((state) => state.status);
const startMining = useMiningStore((state) => state.startMining);
useEffect(() => {
// Only auto-start if setting is enabled and not already mining
if (!autoStartMining || status.isMining || !defaultCoinbaseAddress) return;
const autoStart = async () => {
try {
await startMining(defaultCoinbaseAddress, defaultThreads);
} catch (error) {
// Silently fail auto-start
console.debug('Auto-start mining failed:', error);
}
};
// Delay auto-start to allow node to connect first
const timeout = setTimeout(autoStart, 3000);
return () => clearTimeout(timeout);
}, [
autoStartMining,
defaultThreads,
defaultCoinbaseAddress,
status.isMining,
startMining,
]);
}

View file

@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { useNodeStore } from '../store/node';
/**
* Hook to setup and cleanup node event listeners
* Should be called once at the app level
*/
export function useNodeEvents() {
const setupEventListeners = useNodeStore((state) => state.setupEventListeners);
const cleanupEventListeners = useNodeStore(
(state) => state.cleanupEventListeners
);
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
}
/**
* Hook to auto-connect to the last used node on startup
*/
export function useAutoConnect() {
const preferredMode = useNodeStore((state) => state.preferredMode);
const lastExternalUrl = useNodeStore((state) => state.lastExternalUrl);
const lastNetwork = useNodeStore((state) => state.lastNetwork);
const status = useNodeStore((state) => state.status);
const connectExternal = useNodeStore((state) => state.connectExternal);
const startEmbeddedNode = useNodeStore((state) => state.startEmbeddedNode);
useEffect(() => {
// Only auto-connect if not already connected
if (status.isConnected) return;
const autoConnect = async () => {
try {
if (preferredMode === 'external' && lastExternalUrl) {
await connectExternal(lastExternalUrl);
} else if (preferredMode === 'embedded' && lastNetwork) {
await startEmbeddedNode({ network: lastNetwork });
}
} catch (error) {
// Silently fail auto-connect - user can manually connect
console.debug('Auto-connect failed:', error);
}
};
// Delay auto-connect to allow app to fully initialize
const timeout = setTimeout(autoConnect, 1000);
return () => clearTimeout(timeout);
}, [
preferredMode,
lastExternalUrl,
lastNetwork,
status.isConnected,
connectExternal,
startEmbeddedNode,
]);
}

View file

@ -5,7 +5,7 @@
*/
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { listen } from '../lib/tauri';
import { useNavigate } from 'react-router-dom';
import { useWalletStore } from '../store/wallet';

View file

@ -0,0 +1,503 @@
/**
* Tauri Adapter - Provides mock implementations when running in browser
*
* This allows the wallet UI to be previewed in a browser without the Rust backend.
* When running in Tauri, it uses real invoke() calls.
* When running in browser, it returns mock data for UI development/preview.
*/
// Detect if we're running inside Tauri
export const isTauri = (): boolean => {
return typeof window !== 'undefined' && '__TAURI__' in window;
};
// Mock data generators
const mockGenerators = {
// Wallet commands - 24-word BIP39 mnemonic
create_wallet: async (_args?: { password: string }) => ({
mnemonic: 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual',
address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
}),
import_wallet: async (_args?: { request: { mnemonic: string; password: string } }) =>
'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
unlock_wallet: async (_args?: { password: string }) => true,
lock_wallet: async () => undefined,
get_balance: async () => ({
balance: 12345678900000,
balanceHuman: '123,456.789 SYN',
pending: 500000000,
}),
get_addresses: async () => [
{ address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y', index: 0, isChange: false, label: 'Main' },
{ address: 'tsynor1qa1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8', index: 1, isChange: false, label: 'Savings' },
],
connect_node: async (_args?: { rpcUrl: string; wsUrl?: string }) => ({
connected: true,
network: 'testnet',
blockHeight: 1234567,
peerCount: 8,
synced: true,
}),
// Node commands
node_start: async () => undefined,
node_stop: async () => undefined,
node_status: async () => ({
isRunning: true,
isConnected: true,
isSyncing: false,
network: 'testnet',
blockHeight: 1234567,
blueScore: 1234500,
peerCount: 8,
version: '0.1.0',
}),
get_peers: async () => [
{ id: 'peer1', address: '192.168.1.100:19420', connected: true, latency: 45 },
{ id: 'peer2', address: '10.0.0.50:19420', connected: true, latency: 120 },
],
// Mining commands
mining_start: async () => undefined,
mining_stop: async () => undefined,
mining_stats: async () => ({
isMining: true,
hashrate: 1250000,
blocksFound: 3,
threads: 4,
difficulty: 1234567890,
coinbaseAddress: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y',
}),
// Transaction commands
get_transaction_history: async () => [
{ txid: 'abc123...', type: 'receive', amount: 1000000000, timestamp: Date.now() - 86400000, confirmations: 100 },
{ txid: 'def456...', type: 'send', amount: 500000000, timestamp: Date.now() - 172800000, confirmations: 200 },
],
create_transaction: async () => ({ txHex: '0x...', fee: 1000 }),
sign_transaction: async () => '0x...',
broadcast_transaction: async () => 'txid_abc123...',
// Staking commands
get_staking_info: async () => ({
isStaking: true,
stakedAmount: 5000000000000,
pendingRewards: 25000000000,
apr: 8.5,
validators: [{ address: 'val1', stake: 1000000000000, commission: 5 }],
}),
// Decoy wallets
list_decoy_wallets: async () => [
{ id: 'decoy1', name: 'Shopping', balance: 10000000000, isPrimary: false, createdAt: Date.now() - 604800000 },
{ id: 'decoy2', name: 'Travel', balance: 5000000000, isPrimary: false, createdAt: Date.now() - 1209600000 },
],
create_decoy_wallet: async () => ({ id: 'new_decoy', name: 'New Decoy', balance: 0, isPrimary: false, createdAt: Date.now() }),
delete_decoy_wallet: async () => undefined,
// Fee analytics
get_fee_analytics: async () => ({
currentFeeRate: 1,
lowFeeRate: 1,
mediumFeeRate: 5,
highFeeRate: 10,
mempoolSize: 1234,
averageBlockTime: 1000,
feeHistory: Array.from({ length: 24 }, (_, i) => ({ timestamp: Date.now() - i * 3600000, feeRate: Math.random() * 5 + 1 })),
}),
// Watch-only
list_watch_addresses: async () => [
{ address: 'tsynor1watch1...', label: 'Cold Storage', balance: 100000000000000 },
{ address: 'tsynor1watch2...', label: 'Exchange', balance: 50000000000000 },
],
add_watch_address: async () => undefined,
remove_watch_address: async () => undefined,
// Vaults (Time-locked)
list_vaults: async () => [
{ id: 'vault1', name: 'Retirement', amount: 50000000000000, unlockTime: Date.now() + 31536000000, status: 'locked' },
{ id: 'vault2', name: 'Savings', amount: 10000000000000, unlockTime: Date.now() + 2592000000, status: 'locked' },
],
create_vault: async () => ({ id: 'new_vault', name: 'New Vault', amount: 0, unlockTime: Date.now() + 86400000, status: 'locked' }),
unlock_vault: async () => undefined,
// Recovery
get_recovery_status: async () => ({
hasBackup: true,
lastBackupDate: Date.now() - 604800000,
recoveryMethod: 'mnemonic',
socialRecoveryEnabled: false,
guardians: [],
}),
create_backup: async () => 'backup_data_encrypted...',
verify_backup: async () => true,
// Mixer
list_mixer_pools: async () => [
{ denomination: 100000000, poolSize: 50, minParticipants: 5, fee: 0.001 },
{ denomination: 1000000000, poolSize: 30, minParticipants: 5, fee: 0.001 },
{ denomination: 10000000000, poolSize: 15, minParticipants: 5, fee: 0.001 },
],
get_mixer_requests: async () => [
{ id: 'mix1', denomination: 1000000000, status: 'pending', createdAt: Date.now() - 3600000 },
],
create_mix_request: async () => ({ id: 'new_mix', denomination: 1000000000, status: 'pending', createdAt: Date.now() }),
cancel_mix_request: async () => undefined,
// Limit orders
list_limit_orders: async () => [
{ id: 'order1', pair: 'SYN/BTC', type: 'buy', price: 0.00001, amount: 1000000000, filled: 500000000, status: 'partial' },
{ id: 'order2', pair: 'SYN/ETH', type: 'sell', price: 0.0001, amount: 2000000000, filled: 0, status: 'open' },
],
get_order_book: async () => ({
bids: [{ price: 0.00001, amount: 5000000000 }, { price: 0.000009, amount: 10000000000 }],
asks: [{ price: 0.000011, amount: 3000000000 }, { price: 0.000012, amount: 8000000000 }],
}),
create_limit_order: async () => ({ id: 'new_order', pair: 'SYN/BTC', type: 'buy', price: 0.00001, amount: 1000000000, filled: 0, status: 'open' }),
cancel_limit_order: async () => undefined,
// Yield
list_yield_opportunities: async () => [
{ id: 'yield1', protocol: 'SynorLend', asset: 'SYN', apy: 12.5, tvl: 1000000000000000, risk: 'low' },
{ id: 'yield2', protocol: 'SynorFarm', asset: 'SYN-LP', apy: 45.0, tvl: 500000000000000, risk: 'medium' },
],
list_yield_positions: async () => [
{ id: 'pos1', opportunityId: 'yield1', amount: 10000000000000, rewardsEarned: 125000000000, startTime: Date.now() - 2592000000 },
],
deposit_yield: async () => ({ id: 'new_pos', opportunityId: 'yield1', amount: 5000000000000, rewardsEarned: 0, startTime: Date.now() }),
withdraw_yield: async () => undefined,
// Portfolio
get_portfolio_summary: async () => ({
totalValueUsd: 125000.50,
dayChangeUsd: 2500.25,
dayChangePercent: 2.04,
totalPnlUsd: 25000.00,
totalPnlPercent: 25.0,
totalCostBasisUsd: 100000.50,
}),
list_portfolio_holdings: async () => [
{ asset: 'Synor', symbol: 'SYN', balance: 1234567800000000, balanceFormatted: '12,345.678 SYN', valueUsd: 100000, pnlPercent: 25, allocationPercent: 80 },
{ asset: 'Bitcoin', symbol: 'BTC', balance: 10000000, balanceFormatted: '0.1 BTC', valueUsd: 25000.50, pnlPercent: 50, allocationPercent: 20 },
],
get_tax_report: async () => [
{ id: 'tx1', timestamp: Date.now() - 86400000, txType: 'buy', asset: 'SYN', amount: 100000000000, totalUsd: 1000, gainLossUsd: undefined, isLongTerm: false },
{ id: 'tx2', timestamp: Date.now() - 31536000000, txType: 'sell', asset: 'SYN', amount: 50000000000, totalUsd: 750, gainLossUsd: 250, isLongTerm: true },
],
export_tax_report: async () => 'Date,Type,Asset,Amount,Total USD,Gain/Loss\n2025-01-01,buy,SYN,100,1000,\n2024-01-01,sell,SYN,50,750,250',
// Alerts
list_alerts: async () => [
{ id: 'alert1', type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false },
{ id: 'alert2', type: 'price_below', asset: 'SYN', threshold: 50, enabled: true, triggered: true },
{ id: 'alert3', type: 'balance_below', asset: 'SYN', threshold: 1000, enabled: false, triggered: false },
],
create_alert: async () => ({ id: 'new_alert', type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false }),
update_alert: async () => undefined,
delete_alert: async () => undefined,
// CLI
execute_cli_command: async (args?: { command: string }) => ({
success: true,
output: `Executed: ${args?.command || 'help'}\n\nMock CLI output for browser preview.\nAvailable commands: help, balance, send, receive, history`,
executionTime: 150,
}),
// RPC Profiles
list_rpc_profiles: async () => [
{ id: 'mainnet', name: 'Mainnet', rpcUrl: 'https://rpc.synor.network', wsUrl: 'wss://ws.synor.network', isActive: false },
{ id: 'testnet', name: 'Testnet', rpcUrl: 'https://testnet.synor.network', wsUrl: 'wss://testnet-ws.synor.network', isActive: true },
{ id: 'local', name: 'Local Node', rpcUrl: 'http://localhost:19423', wsUrl: 'ws://localhost:19424', isActive: false },
],
create_rpc_profile: async () => ({ id: 'new_profile', name: 'New Profile', rpcUrl: '', wsUrl: '', isActive: false }),
update_rpc_profile: async () => undefined,
delete_rpc_profile: async () => undefined,
set_active_rpc_profile: async () => undefined,
// Address book
list_contacts: async () => [
{ id: 'contact1', name: 'Alice', address: 'tsynor1alice...', notes: 'Friend' },
{ id: 'contact2', name: 'Bob', address: 'tsynor1bob...', notes: 'Business partner' },
],
add_contact: async () => ({ id: 'new_contact', name: 'New Contact', address: '', notes: '' }),
update_contact: async () => undefined,
delete_contact: async () => undefined,
// Multi-sig
list_multisig_wallets: async () => [
{ id: 'ms1', name: 'Team Wallet', threshold: 2, signers: ['addr1', 'addr2', 'addr3'], balance: 100000000000000 },
],
create_multisig_wallet: async () => ({ id: 'new_ms', name: 'New Multisig', threshold: 2, signers: [], balance: 0 }),
// Backup
get_backup_status: async () => ({
hasLocalBackup: true,
hasCloudBackup: false,
lastBackupDate: Date.now() - 604800000,
}),
create_local_backup: async () => 'backup_created',
restore_from_backup: async () => true,
// QR
generate_qr_code: async () => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
// Hardware wallet
list_hardware_wallets: async () => [],
connect_hardware_wallet: async () => ({ id: 'hw1', type: 'ledger', connected: true }),
// Portfolio commands (snake_case for backend compatibility)
portfolio_get_summary: async () => ({
total_value_usd: 125000.50,
day_change_usd: 2500.25,
day_change_percent: 2.04,
total_pnl_usd: 25000.00,
total_pnl_percent: 25.0,
total_cost_basis_usd: 100000.50,
}),
portfolio_get_holdings: async () => [
{ asset: 'Synor', symbol: 'SYN', balance: 1234567800000000, balance_formatted: '12,345.678 SYN', price_usd: 8.1, value_usd: 100000, cost_basis_usd: 80000, pnl_usd: 20000, pnl_percent: 25, allocation_percent: 80 },
{ asset: 'Bitcoin', symbol: 'BTC', balance: 10000000, balance_formatted: '0.1 BTC', price_usd: 250000, value_usd: 25000.50, cost_basis_usd: 16700, pnl_usd: 8300.50, pnl_percent: 50, allocation_percent: 20 },
],
portfolio_get_tax_report: async () => [
{ id: 'tx1', timestamp: Date.now() - 86400000, tx_type: 'buy', asset: 'SYN', amount: 100000000000, price_usd: 10, total_usd: 1000, cost_basis_usd: 1000, gain_loss_usd: null, is_long_term: false },
{ id: 'tx2', timestamp: Date.now() - 31536000000, tx_type: 'sell', asset: 'SYN', amount: 50000000000, price_usd: 15, total_usd: 750, cost_basis_usd: 500, gain_loss_usd: 250, is_long_term: true },
],
portfolio_export_tax_report: async () => 'Date,Type,Asset,Amount,Total USD,Gain/Loss\n2025-01-01,buy,SYN,100,1000,\n2024-01-01,sell,SYN,50,750,250',
portfolio_get_history: async () => Array.from({ length: 30 }, (_, i) => ({
timestamp: Date.now() - i * 86400000,
value_usd: 100000 + Math.random() * 50000,
})),
// Wallet manager commands
wallets_list: async () => [
{ id: 'wallet1', name: 'Main Wallet', address: 'tsynor1qz8v5zp2q0ew9d2k8n7j3x4c5v6b7n8m9k0l1p2q3r4s5t6u7v8w9x0y', isActive: true, createdAt: Date.now() - 86400000 * 30 },
],
wallets_create: async () => ({ id: 'new_wallet', name: 'New Wallet', address: 'tsynor1new...', isActive: false, createdAt: Date.now() }),
wallets_import: async () => ({ id: 'imported', name: 'Imported Wallet', address: 'tsynor1imp...', isActive: false, createdAt: Date.now() }),
wallets_switch: async () => undefined,
wallets_delete: async () => undefined,
wallets_rename: async () => undefined,
// Yield commands (snake_case)
yield_list_opportunities: async () => [
{ id: 'yield1', protocol: 'SynorLend', asset: 'SYN', apy: 12.5, tvl: 1000000000000000, risk: 'low', min_deposit: 100000000 },
{ id: 'yield2', protocol: 'SynorFarm', asset: 'SYN-LP', apy: 45.0, tvl: 500000000000000, risk: 'medium', min_deposit: 500000000 },
],
yield_list_positions: async () => [
{ id: 'pos1', opportunity_id: 'yield1', amount: 10000000000000, rewards_earned: 125000000000, start_time: Date.now() - 2592000000 },
],
yield_deposit: async () => ({ id: 'new_pos', opportunity_id: 'yield1', amount: 5000000000000, rewards_earned: 0, start_time: Date.now() }),
yield_withdraw: async () => undefined,
// Alerts commands
alerts_list: async () => [
{ id: 'alert1', alert_type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false, created_at: Date.now() - 86400000 },
{ id: 'alert2', alert_type: 'price_below', asset: 'SYN', threshold: 50, enabled: true, triggered: true, created_at: Date.now() - 172800000 },
],
alerts_create: async () => ({ id: 'new_alert', alert_type: 'price_above', asset: 'SYN', threshold: 100, enabled: true, triggered: false, created_at: Date.now() }),
alerts_update: async () => undefined,
alerts_delete: async () => undefined,
// CLI commands
cli_execute: async (args?: { command: string }) => ({
success: true,
output: `$ ${args?.command || 'help'}\n\n[Mock CLI Output]\nCommand executed successfully in browser preview mode.\n\nAvailable commands:\n help - Show this help\n balance - Show wallet balance\n send - Send SYN to an address\n receive - Show receive address\n history - Transaction history`,
execution_time: 150,
}),
// Mixer commands (snake_case)
mixer_get_denominations: async () => [100000000, 1000000000, 10000000000, 100000000000],
mixer_list_pools: async () => [
{ denomination: 100000000, pool_size: 50, min_participants: 5, fee: 0.001, status: 'active' },
{ denomination: 1000000000, pool_size: 30, min_participants: 5, fee: 0.001, status: 'active' },
{ denomination: 10000000000, pool_size: 15, min_participants: 5, fee: 0.001, status: 'active' },
],
mixer_get_pool_status: async () => ({ denomination: 1000000000, pool_size: 30, participants: 12, status: 'active' }),
mixer_get_requests: async () => [
{ id: 'mix1', denomination: 1000000000, status: 'pending', created_at: Date.now() - 3600000 },
],
mixer_create_request: async () => ({ id: 'new_mix', denomination: 1000000000, status: 'pending', created_at: Date.now() }),
mixer_cancel_request: async () => undefined,
// Limit orders (snake_case)
limit_order_list: async () => [
{ id: 'order1', pair: 'SYN/BTC', order_type: 'buy', price: 0.00001, amount: 1000000000, filled: 500000000, status: 'partial', created_at: Date.now() - 86400000 },
{ id: 'order2', pair: 'SYN/ETH', order_type: 'sell', price: 0.0001, amount: 2000000000, filled: 0, status: 'open', created_at: Date.now() - 3600000 },
],
limit_order_get_orderbook: async () => ({
bids: [{ price: 0.00001, amount: 5000000000 }, { price: 0.000009, amount: 10000000000 }],
asks: [{ price: 0.000011, amount: 3000000000 }, { price: 0.000012, amount: 8000000000 }],
}),
limit_order_create: async () => ({ id: 'new_order', pair: 'SYN/BTC', order_type: 'buy', price: 0.00001, amount: 1000000000, filled: 0, status: 'open', created_at: Date.now() }),
limit_order_cancel: async () => undefined,
// RPC Profiles (snake_case)
rpc_profiles_list: async () => [
{ id: 'mainnet', name: 'Mainnet', rpc_url: 'https://rpc.synor.network', ws_url: 'wss://ws.synor.network', is_active: false },
{ id: 'testnet', name: 'Testnet', rpc_url: 'https://testnet.synor.network', ws_url: 'wss://testnet-ws.synor.network', is_active: true },
{ id: 'local', name: 'Local Node', rpc_url: 'http://localhost:19423', ws_url: 'ws://localhost:19424', is_active: false },
],
rpc_profiles_create: async () => ({ id: 'new_profile', name: 'New Profile', rpc_url: '', ws_url: '', is_active: false }),
rpc_profiles_update: async () => undefined,
rpc_profiles_delete: async () => undefined,
rpc_profiles_set_active: async () => undefined,
// Vaults (snake_case)
vault_list: async () => [
{ id: 'vault1', name: 'Retirement', amount: 50000000000000, unlock_time: Date.now() + 31536000000, status: 'locked', created_at: Date.now() - 86400000 * 365 },
{ id: 'vault2', name: 'Savings', amount: 10000000000000, unlock_time: Date.now() + 2592000000, status: 'locked', created_at: Date.now() - 86400000 * 30 },
],
vault_get_summary: async () => ({
total_locked: 60000000000000,
total_vaults: 2,
next_unlock: Date.now() + 2592000000,
}),
vault_create: async () => ({ id: 'new_vault', name: 'New Vault', amount: 0, unlock_time: Date.now() + 86400000, status: 'locked', created_at: Date.now() }),
vault_withdraw: async () => 'txid_vault_withdraw...',
vault_time_remaining: async () => 2592000000,
// Recovery
recovery_get_status: async () => ({
has_backup: true,
last_backup_date: Date.now() - 604800000,
recovery_method: 'mnemonic',
social_recovery_enabled: false,
guardians: [],
}),
recovery_create_backup: async () => 'backup_data_encrypted...',
recovery_verify_backup: async () => true,
// Decoy wallets (snake_case)
decoy_is_enabled: async () => true,
decoy_list: async () => [
{ id: 'decoy1', name: 'Shopping', balance: 10000000000, is_primary: false, created_at: Date.now() - 604800000 },
{ id: 'decoy2', name: 'Travel', balance: 5000000000, is_primary: false, created_at: Date.now() - 1209600000 },
],
decoy_create: async () => ({ id: 'new_decoy', name: 'New Decoy', balance: 0, is_primary: false, created_at: Date.now() }),
decoy_delete: async () => undefined,
decoy_transfer: async () => undefined,
// Fee analytics
fee_get_analytics: async () => ({
current_fee_rate: 1,
low_fee_rate: 1,
medium_fee_rate: 5,
high_fee_rate: 10,
mempool_size: 1234,
average_block_time: 1000,
fee_history: Array.from({ length: 24 }, (_, i) => ({
timestamp: Date.now() - i * 3600000,
fee_rate: Math.random() * 5 + 1,
})),
}),
fee_calculate: async () => 1500,
// Watch-only
watch_only_list: async () => [
{ address: 'tsynor1watch1abc123...', label: 'Cold Storage', balance: 100000000000000 },
{ address: 'tsynor1watch2def456...', label: 'Exchange', balance: 50000000000000 },
],
watch_only_add: async () => undefined,
watch_only_remove: async () => undefined,
// Default fallback for unknown commands
default: async (cmd: string) => {
console.warn(`[Mock] Unknown command: ${cmd}`);
return null;
},
};
type MockGenerators = typeof mockGenerators;
type CommandName = keyof MockGenerators;
/**
* Invoke a Tauri command with automatic mock fallback for browser
*/
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (isTauri()) {
// Running in Tauri - use real invoke
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
return tauriInvoke<T>(cmd, args);
}
// Running in browser - use mock
console.log(`[Mock] ${cmd}`, args || '');
const generator = mockGenerators[cmd as CommandName] || mockGenerators.default;
const result = await generator(args as never);
// Simulate network latency
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200));
return result as T;
}
/**
* Listen to Tauri events with mock fallback
*/
export async function listen<T>(
event: string,
handler: (payload: { payload: T }) => void
): Promise<() => void> {
if (isTauri()) {
const { listen: tauriListen } = await import('@tauri-apps/api/event');
return tauriListen<T>(event, handler);
}
// In browser, set up mock event emitter
console.log(`[Mock] Listening to event: ${event}`);
// Return a no-op unsubscribe function
return () => {
console.log(`[Mock] Unsubscribed from event: ${event}`);
};
}
// Export a flag for components to check
export const BROWSER_MOCK_MODE = !isTauri();
// Type for unlisten function
export type UnlistenFn = () => void;
/**
* Mock window API for browser
*/
export const mockWindow = {
async minimize() {
console.log('[Mock] Window minimize');
},
async maximize() {
console.log('[Mock] Window maximize');
},
async close() {
console.log('[Mock] Window close');
},
async toggleMaximize() {
console.log('[Mock] Window toggleMaximize');
},
async setTitle(title: string) {
console.log('[Mock] Window setTitle:', title);
document.title = title;
},
async isMaximized() {
return false;
},
};
/**
* Get the current window with mock fallback
*/
export async function getCurrentWindow() {
if (isTauri()) {
const { getCurrentWindow: tauriGetCurrentWindow } = await import('@tauri-apps/api/window');
return tauriGetCurrentWindow();
}
return mockWindow;
}

View file

@ -0,0 +1,313 @@
import { useState, useEffect } from 'react';
import {
Plus,
Edit2,
Trash2,
Search,
Copy,
Check,
AlertCircle,
Tag,
} from 'lucide-react';
import { useAddressBookStore, AddressBookEntry } from '../../store/addressbook';
export default function AddressBookPage() {
const {
entries,
isLoading,
error,
clearError,
fetchAll,
addEntry,
updateEntry,
deleteEntry,
} = useAddressBookStore();
const [searchQuery, setSearchQuery] = useState('');
const [showAddModal, setShowAddModal] = useState(false);
const [editingEntry, setEditingEntry] = useState<AddressBookEntry | null>(null);
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
// Form state
const [formName, setFormName] = useState('');
const [formAddress, setFormAddress] = useState('');
const [formNotes, setFormNotes] = useState('');
const [formTags, setFormTags] = useState('');
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredEntries = entries.filter(
(entry) =>
entry.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
entry.address.toLowerCase().includes(searchQuery.toLowerCase()) ||
entry.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase()))
);
const resetForm = () => {
setFormName('');
setFormAddress('');
setFormNotes('');
setFormTags('');
};
const handleAdd = async () => {
if (!formName || !formAddress) return;
try {
const tags = formTags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
await addEntry(formName, formAddress, formNotes || undefined, tags);
setShowAddModal(false);
resetForm();
} catch {
// Error handled by store
}
};
const handleEdit = async () => {
if (!editingEntry || !formName || !formAddress) return;
try {
const tags = formTags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
await updateEntry(editingEntry.id, formName, formAddress, formNotes || undefined, tags);
setEditingEntry(null);
resetForm();
} catch {
// Error handled by store
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this contact?')) return;
try {
await deleteEntry(id);
} catch {
// Error handled by store
}
};
const copyAddress = (address: string) => {
navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
};
const openEditModal = (entry: AddressBookEntry) => {
setEditingEntry(entry);
setFormName(entry.name);
setFormAddress(entry.address);
setFormNotes(entry.notes || '');
setFormTags(entry.tags.join(', '));
};
const renderEntryCard = (entry: AddressBookEntry) => (
<div
key={entry.id}
className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white truncate">{entry.name}</h3>
</div>
<div className="flex items-center gap-2 mt-1">
<code className="text-sm text-gray-400 truncate">{entry.address}</code>
<button
onClick={() => copyAddress(entry.address)}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
{copiedAddress === entry.address ? (
<Check size={14} className="text-green-400" />
) : (
<Copy size={14} className="text-gray-500" />
)}
</button>
</div>
{entry.notes && (
<p className="text-sm text-gray-500 mt-2 line-clamp-2">{entry.notes}</p>
)}
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entry.tags.map((tag, i) => (
<span
key={i}
className="px-2 py-0.5 bg-synor-600/20 text-synor-400 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-4">
<button
onClick={() => openEditModal(entry)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Edit2 size={18} className="text-gray-400" />
</button>
<button
onClick={() => handleDelete(entry.id)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Trash2 size={18} className="text-red-400" />
</button>
</div>
</div>
</div>
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Address Book</h1>
<p className="text-gray-400 mt-1">Manage your saved addresses</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={18} />
Add Contact
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Search */}
<div className="relative">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name, address, or tag..."
className="w-full pl-10 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* All Contacts */}
<div>
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
All Contacts ({filteredEntries.length})
</h2>
{filteredEntries.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredEntries.map(renderEntryCard)}
</div>
) : (
<div className="text-center py-12 text-gray-500">
{searchQuery ? 'No contacts found' : 'No contacts yet'}
</div>
)}
</div>
{/* Add/Edit Modal */}
{(showAddModal || editingEntry) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
<h2 className="text-xl font-bold text-white mb-4">
{editingEntry ? 'Edit Contact' : 'Add Contact'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Name *</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Contact name"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Address *</label>
<input
type="text"
value={formAddress}
onChange={(e) => setFormAddress(e.target.value)}
placeholder="synor1..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Tags (comma separated)
</label>
<div className="relative">
<Tag
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
/>
<input
type="text"
value={formTags}
onChange={(e) => setFormTags(e.target.value)}
placeholder="e.g., Exchange, Friend, Business"
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Notes</label>
<textarea
value={formNotes}
onChange={(e) => setFormNotes(e.target.value)}
placeholder="Optional notes about this contact"
rows={3}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => {
setShowAddModal(false);
setEditingEntry(null);
resetForm();
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Cancel
</button>
<button
onClick={editingEntry ? handleEdit : handleAdd}
disabled={!formName || !formAddress || isLoading}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : editingEntry ? 'Save Changes' : 'Add Contact'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,276 @@
import { useEffect, useState } from 'react';
import { Bell, Info, AlertCircle, Plus, RefreshCw, Loader2, X, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
import { useAlertsStore } from '../../store/alerts';
export default function AlertsDashboard() {
const {
alerts,
isLoading,
error,
listAlerts,
createAlert,
deleteAlert,
toggleAlert,
clearError,
} = useAlertsStore();
const [showCreateModal, setShowCreateModal] = useState(false);
const [asset, setAsset] = useState('SYN');
const [condition, setCondition] = useState<'above' | 'below'>('above');
const [targetPrice, setTargetPrice] = useState('');
const [notificationMethod, setNotificationMethod] = useState<'push' | 'email' | 'both'>('push');
useEffect(() => {
listAlerts();
}, [listAlerts]);
const handleCreateAlert = async () => {
if (!targetPrice) return;
try {
await createAlert(asset, condition, parseFloat(targetPrice), notificationMethod);
setShowCreateModal(false);
setTargetPrice('');
} catch {
// Error handled by store
}
};
const activeAlerts = alerts.filter(a => a.isEnabled && !a.isTriggered);
const triggeredAlerts = alerts.filter(a => a.isTriggered);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Bell className="text-synor-400" />
Price Alerts
</h1>
<p className="text-gray-400 mt-1">Get notified when tokens hit your targets</p>
</div>
<div className="flex gap-2">
<button
onClick={listAlerts}
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
disabled={isLoading}
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 hover:bg-synor-700"
>
<Plus size={18} />
New Alert
</button>
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="text-red-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-red-200">Error</p>
<p className="text-sm text-red-200/70">{error}</p>
</div>
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
</div>
)}
{/* Active Alerts */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Active Alerts ({activeAlerts.length})</h3>
{activeAlerts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Bell size={32} className="mx-auto mb-2 opacity-50" />
<p>No active price alerts</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-2 text-synor-400 hover:text-synor-300 text-sm"
>
Create your first alert
</button>
</div>
) : (
<div className="space-y-2">
{activeAlerts.map((alert) => (
<div key={alert.id} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
alert.condition === 'above' ? 'bg-green-500/20' : 'bg-red-500/20'
}`}>
<span className={alert.condition === 'above' ? 'text-green-400' : 'text-red-400'}>
{alert.condition === 'above' ? '↑' : '↓'}
</span>
</div>
<div>
<p className="font-medium">{alert.asset}</p>
<p className="text-sm text-gray-500">
{alert.condition === 'above' ? 'Above' : 'Below'} ${alert.targetPrice.toFixed(4)}
</p>
<p className="text-xs text-gray-600">
Current: ${alert.currentPrice.toFixed(4)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleAlert(alert.id, !alert.isEnabled)}
className="p-2 hover:bg-gray-700 rounded"
>
{alert.isEnabled ? (
<ToggleRight size={20} className="text-synor-400" />
) : (
<ToggleLeft size={20} className="text-gray-500" />
)}
</button>
<button
onClick={() => deleteAlert(alert.id)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Triggered Alerts */}
{triggeredAlerts.length > 0 && (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4 text-yellow-400">Triggered Alerts ({triggeredAlerts.length})</h3>
<div className="space-y-2">
{triggeredAlerts.map((alert) => (
<div key={alert.id} className="p-4 bg-yellow-500/10 rounded-lg flex justify-between items-center">
<div className="flex items-center gap-4">
<Bell className="text-yellow-400" />
<div>
<p className="font-medium">{alert.asset}</p>
<p className="text-sm text-gray-400">
Reached ${alert.targetPrice.toFixed(4)} on{' '}
{alert.triggeredAt ? new Date(alert.triggeredAt * 1000).toLocaleString() : 'Unknown'}
</p>
</div>
</div>
<button
onClick={() => deleteAlert(alert.id)}
className="p-2 text-gray-400 hover:bg-gray-700 rounded"
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
)}
{/* Alert Types Info */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Alert Types</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-green-400">Above Price</p>
<p className="text-xs text-gray-500">Alert when price rises above target</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-red-400">Below Price</p>
<p className="text-xs text-gray-500">Alert when price drops below target</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-yellow-400">Notification Methods</p>
<p className="text-xs text-gray-500">Push, Email, or Both</p>
</div>
</div>
</div>
{/* Create Alert Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Create Price Alert</h3>
<button onClick={() => setShowCreateModal(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Asset</label>
<select
value={asset}
onChange={(e) => setAsset(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
>
<option value="SYN">SYN</option>
<option value="BTC">BTC</option>
<option value="ETH">ETH</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Condition</label>
<div className="flex gap-2">
<button
onClick={() => setCondition('above')}
className={`flex-1 py-2 rounded-lg font-medium ${
condition === 'above' ? 'bg-green-600 text-white' : 'bg-gray-800 text-gray-400'
}`}
>
Above Price
</button>
<button
onClick={() => setCondition('below')}
className={`flex-1 py-2 rounded-lg font-medium ${
condition === 'below' ? 'bg-red-600 text-white' : 'bg-gray-800 text-gray-400'
}`}
>
Below Price
</button>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Target Price (USD)</label>
<input
type="number"
value={targetPrice}
onChange={(e) => setTargetPrice(e.target.value)}
placeholder="0.0000"
step="0.0001"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Notification Method</label>
<select
value={notificationMethod}
onChange={(e) => setNotificationMethod(e.target.value as 'push' | 'email' | 'both')}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
>
<option value="push">Desktop Push</option>
<option value="email">Email</option>
<option value="both">Both</option>
</select>
</div>
<button
onClick={handleCreateAlert}
disabled={isLoading || !targetPrice}
className="w-full py-3 bg-synor-600 rounded-lg font-medium hover:bg-synor-700 disabled:opacity-50"
>
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Create Alert'}
</button>
</div>
</div>
</div>
)}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Alerts use OS notifications to notify you even when the wallet is minimized
to the system tray.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,377 @@
import { useState } from 'react';
import { open, save } from '@tauri-apps/plugin-dialog';
import {
Download,
Upload,
FileJson,
Shield,
AlertCircle,
Check,
Clock,
HardDrive,
Lock,
} from 'lucide-react';
import { useBackupStore } from '../../store/backup';
export default function BackupPage() {
const {
isExporting,
isImporting,
lastExport,
lastHistoryExport,
error,
clearError,
exportWallet,
importWallet,
exportHistory,
} = useBackupStore();
const [exportPassword, setExportPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [importPassword, setImportPassword] = useState('');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [exportSuccess, setExportSuccess] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const handleExportWallet = async () => {
if (!exportPassword || exportPassword !== confirmPassword) return;
try {
const path = await save({
defaultPath: `synor-wallet-backup-${Date.now()}.enc`,
filters: [{ name: 'Encrypted Backup', extensions: ['enc'] }],
});
if (path) {
await exportWallet(exportPassword, path);
setExportPassword('');
setConfirmPassword('');
setExportSuccess(true);
setTimeout(() => setExportSuccess(false), 5000);
}
} catch {
// Error handled by store
}
};
const handleSelectFile = async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: 'Encrypted Backup', extensions: ['enc'] }],
});
if (selected && typeof selected === 'string') {
setSelectedFile(selected);
}
} catch {
// User cancelled
}
};
const handleImportWallet = async () => {
if (!selectedFile || !importPassword) return;
try {
await importWallet(selectedFile, importPassword);
setSelectedFile(null);
setImportPassword('');
setImportSuccess(true);
setTimeout(() => setImportSuccess(false), 5000);
} catch {
// Error handled by store
}
};
const handleExportHistory = async (format: 'json' | 'csv') => {
try {
const path = await save({
defaultPath: `synor-history-${Date.now()}.${format}`,
filters: [
format === 'json'
? { name: 'JSON', extensions: ['json'] }
: { name: 'CSV', extensions: ['csv'] },
],
});
if (path) {
await exportHistory(path, format);
}
} catch {
// Error handled by store
}
};
const passwordsMatch = exportPassword && exportPassword === confirmPassword;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Backup & Export</h1>
<p className="text-gray-400 mt-1">
Securely backup your wallet and export transaction history
</p>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Success Alerts */}
{exportSuccess && (
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800 rounded-lg">
<Check className="text-green-400" size={20} />
<p className="text-green-400">Wallet backup exported successfully!</p>
</div>
)}
{importSuccess && (
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800 rounded-lg">
<Check className="text-green-400" size={20} />
<p className="text-green-400">Wallet imported successfully!</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Export Wallet */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-synor-600/20 rounded-lg">
<Download className="text-synor-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Export Wallet</h2>
<p className="text-sm text-gray-400">
Create an encrypted backup of your wallet
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
Encryption Password
</label>
<input
type="password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter a strong password"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm password"
className={`w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500 focus:outline-none ${
confirmPassword && !passwordsMatch
? 'border-red-500'
: 'border-gray-700 focus:border-synor-500'
}`}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-400 mt-1">Passwords do not match</p>
)}
</div>
<button
onClick={handleExportWallet}
disabled={!passwordsMatch || isExporting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Exporting...
</>
) : (
<>
<Download size={18} />
Export Encrypted Backup
</>
)}
</button>
{lastExport && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
Last export: {new Date(lastExport.createdAt).toLocaleString()}
</div>
)}
</div>
</div>
{/* Import Wallet */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Upload className="text-purple-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Import Wallet</h2>
<p className="text-sm text-gray-400">
Restore from an encrypted backup file
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Backup File</label>
<button
onClick={handleSelectFile}
className="w-full flex items-center justify-between px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-left hover:border-gray-600 transition-colors"
>
<span className={selectedFile ? 'text-white' : 'text-gray-500'}>
{selectedFile
? selectedFile.split('/').pop()
: 'Select backup file...'}
</span>
<HardDrive size={18} className="text-gray-400" />
</button>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Decryption Password
</label>
<input
type="password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter backup password"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleImportWallet}
disabled={!selectedFile || !importPassword || isImporting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Importing...
</>
) : (
<>
<Upload size={18} />
Import Backup
</>
)}
</button>
</div>
</div>
{/* Export History */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-green-600/20 rounded-lg">
<FileJson className="text-green-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Export History</h2>
<p className="text-sm text-gray-400">
Export your transaction history for records
</p>
</div>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-400">
Export your complete transaction history for tax purposes, accounting, or
personal records.
</p>
<div className="flex gap-3">
<button
onClick={() => handleExportHistory('csv')}
disabled={isExporting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Export CSV
</button>
<button
onClick={() => handleExportHistory('json')}
disabled={isExporting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Export JSON
</button>
</div>
{lastHistoryExport && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
Last export: {lastHistoryExport.transactionCount} transactions on{' '}
{new Date(lastHistoryExport.createdAt).toLocaleDateString()}
</div>
)}
</div>
</div>
{/* Security Info */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow-600/20 rounded-lg">
<Shield className="text-yellow-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Security Tips</h2>
<p className="text-sm text-gray-400">Keep your backup safe</p>
</div>
</div>
<ul className="space-y-3 text-sm">
<li className="flex items-start gap-2">
<Lock size={16} className="text-synor-400 mt-0.5" />
<span className="text-gray-300">
Use a strong, unique password for your backup
</span>
</li>
<li className="flex items-start gap-2">
<Lock size={16} className="text-synor-400 mt-0.5" />
<span className="text-gray-300">
Store backups in multiple secure locations
</span>
</li>
<li className="flex items-start gap-2">
<Lock size={16} className="text-synor-400 mt-0.5" />
<span className="text-gray-300">
Never share your backup file or password
</span>
</li>
<li className="flex items-start gap-2">
<Lock size={16} className="text-synor-400 mt-0.5" />
<span className="text-gray-300">
Consider using cold storage for large amounts
</span>
</li>
<li className="flex items-start gap-2">
<Lock size={16} className="text-synor-400 mt-0.5" />
<span className="text-gray-300">
Create a new backup after important changes
</span>
</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,572 @@
import { useState, useRef } from 'react';
import {
Layers,
Plus,
Trash2,
Upload,
Download,
AlertTriangle,
Send,
X,
Check,
Copy,
FileSpreadsheet,
} from 'lucide-react';
import {
useBatchSendStore,
useValidRecipientCount,
useIsBatchReady,
BatchRecipient,
} from '../../store/batchSend';
export default function BatchSendDashboard() {
const {
recipients,
summary,
isLoading,
error,
lastTxId,
addRecipient,
removeRecipient,
updateRecipient,
clearRecipients,
importFromCsv,
createBatchTransaction,
signAndBroadcast,
} = useBatchSendStore();
const validCount = useValidRecipientCount();
const isReady = useIsBatchReady();
const [showImportModal, setShowImportModal] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [txHex, setTxHex] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleCreateTransaction = async () => {
try {
const hex = await createBatchTransaction();
setTxHex(hex);
setShowConfirmModal(true);
} catch {
// Error handled in store
}
};
const handleConfirmSend = async () => {
if (!txHex) return;
try {
await signAndBroadcast(txHex);
setShowConfirmModal(false);
setShowSuccessModal(true);
setTxHex(null);
} catch {
// Error handled in store
}
};
const handleCopyTxId = async () => {
if (!lastTxId) return;
try {
await navigator.clipboard.writeText(lastTxId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API failure
}
};
const handleExportTemplate = () => {
const template = 'address,amount,label\nsynor1...,10.5,Payment 1\nsynor1...,25.0,Payment 2';
const blob = new Blob([template], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'batch_template.csv';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Layers className="text-synor-400" />
Batch Send
</h1>
<p className="text-gray-400 mt-1">
Send to multiple addresses in a single transaction
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleExportTemplate}
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
>
<Download size={14} />
Template
</button>
<button
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
>
<Upload size={14} />
Import CSV
</button>
<button
onClick={clearRecipients}
disabled={recipients.length === 1 && !recipients[0].address}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg text-sm transition-colors disabled:opacity-50"
>
<Trash2 size={14} />
Clear All
</button>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle size={16} />
{error}
</div>
<button
onClick={() => useBatchSendStore.setState({ error: null })}
className="text-red-400 hover:text-red-300"
>
<X size={16} />
</button>
</div>
)}
{/* Recipients List */}
<div className="bg-gray-900 rounded-xl border border-gray-800">
{/* Header */}
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-800 text-sm text-gray-500">
<div className="col-span-1">#</div>
<div className="col-span-5">Recipient Address</div>
<div className="col-span-2">Amount (SYN)</div>
<div className="col-span-3">Label (optional)</div>
<div className="col-span-1"></div>
</div>
{/* Recipients */}
<div className="divide-y divide-gray-800/50">
{recipients.map((recipient, index) => (
<RecipientRow
key={recipient.id}
recipient={recipient}
index={index}
onUpdate={(updates) => updateRecipient(recipient.id, updates)}
onRemove={() => removeRecipient(recipient.id)}
canRemove={recipients.length > 1}
/>
))}
</div>
{/* Add button */}
<div className="p-3 border-t border-gray-800">
<button
onClick={addRecipient}
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors w-full justify-center"
>
<Plus size={16} />
Add Recipient
</button>
</div>
</div>
{/* Summary */}
{summary && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">Transaction Summary</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-gray-500 mb-1">Recipients</p>
<p className="text-xl font-bold text-white">{summary.recipientCount}</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total Amount</p>
<p className="text-xl font-bold text-synor-400">{summary.totalAmountHuman}</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Estimated Fee</p>
<p className="text-xl font-bold text-yellow-400">{summary.estimatedFeeHuman}</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total (incl. fee)</p>
<p className="text-xl font-bold text-white">{summary.totalWithFeeHuman}</p>
</div>
</div>
</div>
)}
{/* Send Button */}
<div className="flex justify-end">
<button
onClick={handleCreateTransaction}
disabled={!isReady || isLoading}
className="flex items-center gap-2 px-6 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={18} />
{isLoading ? 'Creating Transaction...' : `Send to ${validCount} Recipients`}
</button>
</div>
{/* Info Box */}
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
<h4 className="text-blue-300 font-medium mb-2">About Batch Transactions</h4>
<ul className="text-blue-300/70 text-sm space-y-1">
<li> Batch transactions combine multiple sends into a single transaction</li>
<li> This saves on fees compared to sending individually</li>
<li> All recipients receive their funds when the transaction confirms</li>
<li> You can import recipients from a CSV file</li>
</ul>
</div>
{/* Import Modal */}
{showImportModal && (
<ImportCsvModal
onClose={() => setShowImportModal(false)}
onImport={(csv) => {
importFromCsv(csv);
setShowImportModal(false);
}}
/>
)}
{/* Confirm Modal */}
{showConfirmModal && summary && (
<ConfirmModal
summary={summary}
recipients={recipients.filter((r) => r.isValid)}
isLoading={isLoading}
onConfirm={handleConfirmSend}
onCancel={() => {
setShowConfirmModal(false);
setTxHex(null);
}}
/>
)}
{/* Success Modal */}
{showSuccessModal && lastTxId && (
<SuccessModal
txId={lastTxId}
onClose={() => {
setShowSuccessModal(false);
clearRecipients();
}}
onCopy={handleCopyTxId}
copied={copied}
/>
)}
</div>
);
}
// Recipient row component
function RecipientRow({
recipient,
index,
onUpdate,
onRemove,
canRemove,
}: {
recipient: BatchRecipient;
index: number;
onUpdate: (updates: Partial<BatchRecipient>) => void;
onRemove: () => void;
canRemove: boolean;
}) {
return (
<div className={`grid grid-cols-12 gap-4 p-4 ${recipient.error ? 'bg-red-500/5' : ''}`}>
<div className="col-span-1 flex items-center">
<span className="text-gray-500">{index + 1}</span>
{recipient.isValid && <Check size={14} className="text-green-400 ml-2" />}
</div>
<div className="col-span-5">
<input
type="text"
value={recipient.address}
onChange={(e) => onUpdate({ address: e.target.value })}
placeholder="synor1... or tsynor1..."
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-white placeholder-gray-500 font-mono text-sm focus:outline-none ${
recipient.error && recipient.address
? 'border-red-500/50'
: 'border-gray-700 focus:border-synor-500'
}`}
/>
</div>
<div className="col-span-2">
<input
type="number"
value={recipient.amount || ''}
onChange={(e) => onUpdate({ amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
min="0"
step="0.00000001"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="col-span-3">
<input
type="text"
value={recipient.label || ''}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="Label"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="col-span-1 flex items-center justify-end">
<button
onClick={onRemove}
disabled={!canRemove}
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 size={16} />
</button>
</div>
{recipient.error && (
<div className="col-span-12 -mt-2">
<p className="text-red-400 text-xs">{recipient.error}</p>
</div>
)}
</div>
);
}
// Import CSV modal
function ImportCsvModal({
onClose,
onImport,
}: {
onClose: () => void;
onImport: (csv: string) => void;
}) {
const [csvContent, setCsvContent] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setCsvContent(content);
};
reader.readAsText(file);
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-lg shadow-2xl">
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FileSpreadsheet size={20} />
Import from CSV
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-gray-400 text-sm mb-3">
Upload a CSV file or paste content below. Format: address,amount,label
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full py-8 border-2 border-dashed border-gray-700 rounded-lg text-gray-400 hover:border-synor-500 hover:text-synor-400 transition-colors"
>
<Upload className="mx-auto mb-2" size={24} />
Click to upload CSV file
</button>
</div>
<div className="text-center text-gray-500 text-sm">or paste content</div>
<textarea
value={csvContent}
onChange={(e) => setCsvContent(e.target.value)}
placeholder="address,amount,label&#10;synor1abc...,10.5,Payment 1&#10;synor1def...,25.0,Payment 2"
rows={6}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm resize-none"
/>
</div>
<div className="p-4 border-t border-gray-800 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={() => onImport(csvContent)}
disabled={!csvContent.trim()}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Import
</button>
</div>
</div>
</div>
);
}
// Confirm transaction modal
function ConfirmModal({
summary,
recipients,
isLoading,
onConfirm,
onCancel,
}: {
summary: { totalAmountHuman: string; estimatedFeeHuman: string; totalWithFeeHuman: string };
recipients: BatchRecipient[];
isLoading: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
<div className="p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Confirm Batch Transaction</h2>
</div>
<div className="p-6 space-y-4">
{/* Summary */}
<div className="bg-gray-800/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between">
<span className="text-gray-400">Total Amount</span>
<span className="text-white font-medium">{summary.totalAmountHuman}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Network Fee</span>
<span className="text-yellow-400">{summary.estimatedFeeHuman}</span>
</div>
<div className="border-t border-gray-700 pt-2 flex justify-between">
<span className="text-gray-300 font-medium">Total</span>
<span className="text-synor-400 font-bold">{summary.totalWithFeeHuman}</span>
</div>
</div>
{/* Recipients list */}
<div>
<p className="text-sm text-gray-500 mb-2">Sending to {recipients.length} recipients:</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{recipients.slice(0, 5).map((r, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-gray-400 font-mono truncate max-w-[200px]">
{r.address.slice(0, 12)}...{r.address.slice(-6)}
</span>
<span className="text-white">{r.amount} SYN</span>
</div>
))}
{recipients.length > 5 && (
<p className="text-gray-500 text-sm">
... and {recipients.length - 5} more
</p>
)}
</div>
</div>
{/* Warning */}
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
<p className="text-yellow-300 text-sm">
Please verify all recipients and amounts. This transaction cannot be reversed.
</p>
</div>
</div>
<div className="p-4 border-t border-gray-800 flex gap-3">
<button
onClick={onCancel}
disabled={isLoading}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isLoading ? 'Sending...' : 'Confirm & Send'}
</button>
</div>
</div>
</div>
);
}
// Success modal
function SuccessModal({
txId,
onClose,
onCopy,
copied,
}: {
txId: string;
onClose: () => void;
onCopy: () => void;
copied: boolean;
}) {
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl text-center">
<div className="p-8">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<Check size={32} className="text-green-400" />
</div>
<h2 className="text-xl font-semibold text-white mb-2">Transaction Sent!</h2>
<p className="text-gray-400 mb-4">
Your batch transaction has been broadcast to the network.
</p>
<div className="bg-gray-800 rounded-lg p-3 mb-4">
<p className="text-xs text-gray-500 mb-1">Transaction ID</p>
<div className="flex items-center justify-center gap-2">
<code className="text-synor-400 text-sm font-mono break-all">
{txId.slice(0, 16)}...{txId.slice(-16)}
</code>
<button
onClick={onCopy}
className="text-gray-500 hover:text-white transition-colors"
>
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
</button>
</div>
</div>
</div>
<div className="p-4 border-t border-gray-800">
<button
onClick={onClose}
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,399 @@
import { useState, useEffect } from 'react';
import {
ArrowLeftRight,
RefreshCw,
AlertCircle,
Clock,
CheckCircle,
XCircle,
Loader,
ExternalLink,
} from 'lucide-react';
import { useBridgeStore, getChainIcon, getStatusColor } from '../../store/bridge';
export default function BridgeDashboard() {
const {
chains,
transfers,
wrappedBalances,
isLoading,
isTransferring,
error,
clearError,
fetchChains,
fetchTransfers,
getWrappedBalance,
deposit,
withdraw,
} = useBridgeStore();
const [activeTab, setActiveTab] = useState<'bridge' | 'history'>('bridge');
const [direction, setDirection] = useState<'deposit' | 'withdraw'>('deposit');
const [selectedChain, setSelectedChain] = useState('');
const [selectedToken, setSelectedToken] = useState('SYN');
const [amount, setAmount] = useState('');
const [destAddress, setDestAddress] = useState('');
useEffect(() => {
fetchChains();
fetchTransfers();
}, [fetchChains, fetchTransfers]);
// Fetch wrapped balances for supported tokens
useEffect(() => {
chains.forEach((chain) => {
chain.supportedTokens.forEach((token) => {
getWrappedBalance(token);
});
});
}, [chains, getWrappedBalance]);
const handleTransfer = async () => {
if (!selectedChain || !amount) return;
try {
if (direction === 'deposit') {
await deposit(selectedChain, selectedToken, amount);
} else {
if (!destAddress) return;
await withdraw(selectedChain, destAddress, selectedToken, amount);
}
setAmount('');
setDestAddress('');
fetchTransfers();
} catch {
// Error handled by store
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock size={16} className="text-yellow-400" />;
case 'confirming':
case 'relaying':
return <Loader size={16} className="text-blue-400 animate-spin" />;
case 'completed':
return <CheckCircle size={16} className="text-green-400" />;
case 'failed':
return <XCircle size={16} className="text-red-400" />;
default:
return <Clock size={16} className="text-gray-400" />;
}
};
const selectedChainInfo = chains.find((c) => c.chainId === selectedChain);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Cross-Chain Bridge</h1>
<p className="text-gray-400 mt-1">Transfer assets between Synor and other blockchains</p>
</div>
<button
onClick={() => {
fetchChains();
fetchTransfers();
}}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Wrapped Balances */}
{Object.keys(wrappedBalances).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(wrappedBalances).map(([token, balance]) => (
<div
key={token}
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
>
<div className="flex items-center gap-3">
<span className="text-2xl">🪙</span>
<div>
<p className="text-sm text-gray-400">Wrapped {token}</p>
<p className="text-lg font-bold text-white">{balance} w{token}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('bridge')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'bridge'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Bridge
</button>
<button
onClick={() => setActiveTab('history')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'history'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Transfer History ({transfers.length})
</button>
</div>
{/* Bridge Tab */}
{activeTab === 'bridge' && (
<div className="max-w-lg mx-auto">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
{/* Direction Toggle */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setDirection('deposit')}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
direction === 'deposit'
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
Deposit to Synor
</button>
<button
onClick={() => setDirection('withdraw')}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
direction === 'withdraw'
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
Withdraw from Synor
</button>
</div>
{/* Chain Selection */}
<div className="mb-4">
<label className="block text-sm text-gray-400 mb-2">
{direction === 'deposit' ? 'Source Chain' : 'Destination Chain'}
</label>
<div className="grid grid-cols-3 gap-2">
{chains.map((chain) => (
<button
key={chain.chainId}
onClick={() => setSelectedChain(chain.chainId)}
disabled={!chain.isActive}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border transition-colors ${
selectedChain === chain.chainId
? 'border-synor-500 bg-synor-600/20'
: chain.isActive
? 'border-gray-700 bg-gray-800 hover:border-gray-600'
: 'border-gray-800 bg-gray-800/50 opacity-50 cursor-not-allowed'
}`}
>
<span className="text-2xl">{getChainIcon(chain.chainId)}</span>
<span className="text-sm text-white">{chain.name}</span>
</button>
))}
</div>
</div>
{/* Token Selection */}
{selectedChainInfo && (
<div className="mb-4">
<label className="block text-sm text-gray-400 mb-1">Token</label>
<select
value={selectedToken}
onChange={(e) => setSelectedToken(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
{selectedChainInfo.supportedTokens.map((token) => (
<option key={token} value={token}>
{token}
</option>
))}
</select>
</div>
)}
{/* Amount Input */}
<div className="mb-4">
<label className="block text-sm text-gray-400 mb-1">Amount</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-xl placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Destination Address (for withdraw) */}
{direction === 'withdraw' && (
<div className="mb-4">
<label className="block text-sm text-gray-400 mb-1">Destination Address</label>
<input
type="text"
value={destAddress}
onChange={(e) => setDestAddress(e.target.value)}
placeholder="Enter destination address on target chain"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
)}
{/* Transfer Info */}
{selectedChainInfo && amount && (
<div className="mb-4 p-4 bg-gray-800/50 rounded-lg space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Required Confirmations</span>
<span className="text-white">{selectedChainInfo.confirmations}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">You Will Receive</span>
<span className="text-synor-400 font-medium">
~{amount} {direction === 'deposit' ? `w${selectedToken}` : selectedToken}
</span>
</div>
</div>
)}
{/* Transfer Visualization */}
<div className="flex items-center justify-center gap-4 mb-6 p-4 bg-gray-800/30 rounded-lg">
<div className="text-center">
<span className="text-2xl block mb-1">
{direction === 'deposit' ? getChainIcon(selectedChain) || '🔗' : '🟣'}
</span>
<span className="text-sm text-gray-400">
{direction === 'deposit' ? selectedChainInfo?.name || 'Select Chain' : 'Synor'}
</span>
</div>
<ArrowLeftRight size={24} className="text-synor-400" />
<div className="text-center">
<span className="text-2xl block mb-1">
{direction === 'deposit' ? '🟣' : getChainIcon(selectedChain) || '🔗'}
</span>
<span className="text-sm text-gray-400">
{direction === 'deposit' ? 'Synor' : selectedChainInfo?.name || 'Select Chain'}
</span>
</div>
</div>
{/* Transfer Button */}
<button
onClick={handleTransfer}
disabled={!selectedChain || !amount || isTransferring || (direction === 'withdraw' && !destAddress)}
className="w-full px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isTransferring ? (
<span className="flex items-center justify-center gap-2">
<RefreshCw size={18} className="animate-spin" />
Processing...
</span>
) : !selectedChain ? (
'Select a chain'
) : !amount ? (
'Enter an amount'
) : direction === 'withdraw' && !destAddress ? (
'Enter destination address'
) : direction === 'deposit' ? (
'Deposit'
) : (
'Withdraw'
)}
</button>
</div>
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div className="space-y-4">
{transfers.map((transfer) => {
const srcChain = chains.find((c) => c.chainId === transfer.sourceChain);
const dstChain = chains.find((c) => c.chainId === transfer.destChain);
const isDeposit = transfer.destChain === 'synor';
return (
<div
key={transfer.transferId}
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getStatusIcon(transfer.status)}
<div>
<h3 className="text-white font-medium">
{isDeposit ? 'Deposit' : 'Withdrawal'}
</h3>
<p className="text-sm text-gray-400">
{srcChain?.name || transfer.sourceChain} {dstChain?.name || transfer.destChain}
</p>
</div>
</div>
<span className={`text-sm font-medium ${getStatusColor(transfer.status)}`}>
{transfer.status.charAt(0).toUpperCase() + transfer.status.slice(1)}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">Amount</p>
<p className="text-white font-medium">{transfer.amount} {transfer.token}</p>
</div>
<div>
<p className="text-gray-500">Date</p>
<p className="text-white">{new Date(transfer.createdAt).toLocaleDateString()}</p>
</div>
{transfer.sourceTxHash && (
<div>
<p className="text-gray-500">Source TX</p>
<a
href="#"
className="text-synor-400 hover:text-synor-300 flex items-center gap-1"
>
{transfer.sourceTxHash.slice(0, 8)}...
<ExternalLink size={12} />
</a>
</div>
)}
{transfer.destTxHash && (
<div>
<p className="text-gray-500">Dest TX</p>
<a
href="#"
className="text-synor-400 hover:text-synor-300 flex items-center gap-1"
>
{transfer.destTxHash.slice(0, 8)}...
<ExternalLink size={12} />
</a>
</div>
)}
</div>
</div>
);
})}
{transfers.length === 0 && (
<div className="text-center py-12 text-gray-500">
No bridge transfers yet
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal, Info, Send, Loader2 } from 'lucide-react';
import { useCliStore } from '../../store/cli';
export default function CliDashboard() {
const {
history,
isExecuting,
execute,
loadHistory,
clearOutput,
navigateHistory,
} = useCliStore();
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadHistory();
}, [loadHistory]);
// Auto-scroll to bottom when history changes
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [history]);
const handleCommand = async () => {
if (!input.trim() || isExecuting) return;
const cmd = input.trim();
setInput('');
await execute(cmd);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleCommand();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = navigateHistory('up');
if (prev !== null) setInput(prev);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = navigateHistory('down');
if (next !== null) setInput(next);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Terminal className="text-synor-400" />
CLI Mode
</h1>
<p className="text-gray-400 mt-1">Terminal interface for power users</p>
</div>
<button
onClick={clearOutput}
className="px-3 py-1 bg-gray-800 rounded text-sm hover:bg-gray-700"
>
Clear
</button>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div
ref={outputRef}
className="p-4 font-mono text-sm bg-black/50 h-96 overflow-y-auto"
>
{/* Welcome message if no history */}
{history.length === 0 && (
<div className="text-gray-500">
<p className="text-synor-400">{'>'} Welcome to Synor CLI Mode</p>
<p className="text-synor-400">{'>'} Type "help" for available commands</p>
<p className="text-synor-400">{'>'}</p>
</div>
)}
{/* Command history */}
{history.map((result, i) => (
<div key={i} className="mb-2">
<div className="text-synor-400">
{'>'} {result.command}
</div>
<div className={`whitespace-pre-wrap ${result.isError ? 'text-red-400' : 'text-gray-300'}`}>
{result.output}
</div>
</div>
))}
{/* Loading indicator */}
{isExecuting && (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 size={14} className="animate-spin" />
<span>Executing...</span>
</div>
)}
</div>
<div className="border-t border-gray-800 p-3 flex gap-2">
<span className="text-synor-400 font-mono">{'>'}</span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter command..."
className="flex-1 bg-transparent text-white font-mono outline-none"
autoFocus
disabled={isExecuting}
/>
<button
onClick={handleCommand}
disabled={isExecuting || !input.trim()}
className="p-2 bg-synor-600 rounded-lg hover:bg-synor-700 disabled:opacity-50"
>
{isExecuting ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
{/* Quick Commands */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3">Quick Commands</h3>
<div className="flex flex-wrap gap-2">
{['help', 'balance', 'address', 'status', 'utxos', 'peers'].map((cmd) => (
<button
key={cmd}
onClick={() => { setInput(cmd); }}
className="px-3 py-1 bg-gray-800 rounded font-mono text-sm hover:bg-gray-700"
>
{cmd}
</button>
))}
</div>
</div>
{/* Command Reference */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3">Command Reference</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="font-mono text-synor-400">help</div>
<div className="text-gray-400">Show all available commands</div>
<div className="font-mono text-synor-400">balance</div>
<div className="text-gray-400">Show wallet balance</div>
<div className="font-mono text-synor-400">address</div>
<div className="text-gray-400">Show wallet addresses</div>
<div className="font-mono text-synor-400">send &lt;addr&gt; &lt;amount&gt;</div>
<div className="text-gray-400">Send SYN to address</div>
<div className="font-mono text-synor-400">utxos</div>
<div className="text-gray-400">List unspent outputs</div>
<div className="font-mono text-synor-400">history</div>
<div className="text-gray-400">Transaction history</div>
<div className="font-mono text-synor-400">status</div>
<div className="text-gray-400">Network status</div>
<div className="font-mono text-synor-400">peers</div>
<div className="text-gray-400">Connected peers</div>
<div className="font-mono text-synor-400">clear</div>
<div className="text-gray-400">Clear output</div>
</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
CLI mode provides a terminal interface for advanced users who prefer keyboard-driven
interaction. Use / arrows to navigate command history.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,465 @@
import { useState, useEffect } from 'react';
import {
Cpu,
Server,
Play,
Pause,
XCircle,
RefreshCw,
AlertCircle,
Clock,
CheckCircle,
Loader,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useComputeStore, formatPrice } from '../../store/compute';
export default function ComputeDashboard() {
const {
providers,
jobs,
isLoading,
isSubmitting,
error,
clearError,
fetchProviders,
fetchJobs,
submitJob,
cancelJob,
} = useComputeStore();
const [activeTab, setActiveTab] = useState<'providers' | 'jobs'>('providers');
const [showSubmitForm, setShowSubmitForm] = useState(false);
const [selectedProvider, setSelectedProvider] = useState('');
const [dockerImage, setDockerImage] = useState('');
const [command, setCommand] = useState('');
const [inputCid, setInputCid] = useState('');
const [cpuCores, setCpuCores] = useState(4);
const [memoryGb, setMemoryGb] = useState(8);
const [maxHours, setMaxHours] = useState(1);
const [gpuType, setGpuType] = useState('');
const [expandedJob, setExpandedJob] = useState<string | null>(null);
useEffect(() => {
fetchProviders();
fetchJobs();
}, [fetchProviders, fetchJobs]);
const handleSubmitJob = async () => {
if (!selectedProvider || !dockerImage || !command) return;
try {
await submitJob({
provider: selectedProvider,
inputCid,
dockerImage,
command: command.split(' '),
gpuType: gpuType || undefined,
cpuCores,
memoryGb,
maxHours,
});
setShowSubmitForm(false);
setSelectedProvider('');
setDockerImage('');
setCommand('');
setInputCid('');
fetchJobs();
} catch {
// Error handled by store
}
};
const handleCancelJob = async (jobId: string) => {
await cancelJob(jobId);
fetchJobs();
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock size={16} className="text-yellow-400" />;
case 'running':
return <Loader size={16} className="text-blue-400 animate-spin" />;
case 'completed':
return <CheckCircle size={16} className="text-green-400" />;
case 'failed':
return <XCircle size={16} className="text-red-400" />;
case 'cancelled':
return <Pause size={16} className="text-gray-400" />;
default:
return <Clock size={16} className="text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'text-yellow-400';
case 'running':
return 'text-blue-400';
case 'completed':
return 'text-green-400';
case 'failed':
return 'text-red-400';
case 'cancelled':
return 'text-gray-400';
default:
return 'text-gray-400';
}
};
// Get unique GPU types from all providers
const availableGpuTypes = [...new Set(providers.flatMap((p) => p.gpuTypes))];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Compute Marketplace</h1>
<p className="text-gray-400 mt-1">Decentralized GPU and CPU compute resources</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
fetchProviders();
fetchJobs();
}}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button
onClick={() => setShowSubmitForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Play size={16} />
Submit Job
</button>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Submit Job Form Modal */}
{showSubmitForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-white mb-4">Submit Compute Job</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Provider</label>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select a provider</option>
{providers
.filter((p) => p.isAvailable)
.map((provider) => (
<option key={provider.address} value={provider.address}>
{provider.name} - {formatPrice(provider.pricePerHour)}/hr ({provider.gpuTypes.join(', ') || `${provider.cpuCores} cores`})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Docker Image</label>
<input
type="text"
value={dockerImage}
onChange={(e) => setDockerImage(e.target.value)}
placeholder="pytorch/pytorch:latest"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Command</label>
<input
type="text"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="python train.py --epochs 10"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Input Data CID (optional)</label>
<input
type="text"
value={inputCid}
onChange={(e) => setInputCid(e.target.value)}
placeholder="Qm..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">CPU Cores</label>
<input
type="number"
value={cpuCores}
onChange={(e) => setCpuCores(Number(e.target.value))}
min={1}
max={64}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Memory (GB)</label>
<input
type="number"
value={memoryGb}
onChange={(e) => setMemoryGb(Number(e.target.value))}
min={1}
max={512}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">GPU Type (optional)</label>
<select
value={gpuType}
onChange={(e) => setGpuType(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
<option value="">None</option>
{availableGpuTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Max Hours</label>
<input
type="number"
value={maxHours}
onChange={(e) => setMaxHours(Number(e.target.value))}
min={1}
max={168}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowSubmitForm(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmitJob}
disabled={!selectedProvider || !dockerImage || !command || isSubmitting}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Submit Job'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('providers')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'providers'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<Server size={16} className="inline mr-2" />
Providers ({providers.length})
</button>
<button
onClick={() => setActiveTab('jobs')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'jobs'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<Cpu size={16} className="inline mr-2" />
My Jobs ({jobs.length})
</button>
</div>
{/* Providers Tab */}
{activeTab === 'providers' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{providers.map((provider) => (
<div
key={provider.address}
className={`bg-gray-900 rounded-xl p-6 border ${
provider.isAvailable ? 'border-gray-800' : 'border-gray-800/50 opacity-60'
}`}
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">{provider.name}</h3>
<p className="text-sm text-gray-400 font-mono">{provider.address.slice(0, 12)}...</p>
</div>
<span
className={`px-2 py-1 text-xs rounded-full ${
provider.isAvailable
? 'bg-green-900/50 text-green-400'
: 'bg-gray-800 text-gray-500'
}`}
>
{provider.isAvailable ? 'Available' : 'Busy'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{provider.gpuTypes.length > 0 && (
<div>
<p className="text-gray-500">GPUs</p>
<p className="text-white font-medium">{provider.gpuTypes.join(', ')}</p>
</div>
)}
<div>
<p className="text-gray-500">CPU Cores</p>
<p className="text-white font-medium">{provider.cpuCores}</p>
</div>
<div>
<p className="text-gray-500">Memory</p>
<p className="text-white font-medium">{provider.memoryGb} GB</p>
</div>
<div>
<p className="text-gray-500">Price</p>
<p className="text-synor-400 font-medium">{formatPrice(provider.pricePerHour)}/hr</p>
</div>
<div>
<p className="text-gray-500">Reputation</p>
<p className="text-white font-medium">{provider.reputation}%</p>
</div>
</div>
{provider.isAvailable && (
<button
onClick={() => {
setSelectedProvider(provider.address);
setShowSubmitForm(true);
}}
className="w-full mt-4 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
Use This Provider
</button>
)}
</div>
))}
{providers.length === 0 && (
<div className="col-span-2 text-center py-12 text-gray-500">
No compute providers available
</div>
)}
</div>
)}
{/* Jobs Tab */}
{activeTab === 'jobs' && (
<div className="space-y-4">
{jobs.map((job) => (
<div
key={job.jobId}
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
>
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
onClick={() => setExpandedJob(expandedJob === job.jobId ? null : job.jobId)}
>
<div className="flex items-center gap-4">
{getStatusIcon(job.status)}
<div>
<h3 className="text-white font-medium">Job {job.jobId.slice(0, 8)}</h3>
<p className="text-sm text-gray-400">
{job.gpuType || `${job.cpuCores} cores`} {job.provider.slice(0, 8)}...
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
</span>
{expandedJob === job.jobId ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</div>
</div>
{expandedJob === job.jobId && (
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
{job.startedAt && (
<div>
<p className="text-gray-500">Started</p>
<p className="text-white">{new Date(job.startedAt).toLocaleString()}</p>
</div>
)}
{job.endedAt && (
<div>
<p className="text-gray-500">Ended</p>
<p className="text-white">{new Date(job.endedAt).toLocaleString()}</p>
</div>
)}
<div>
<p className="text-gray-500">Cost</p>
<p className="text-synor-400">{formatPrice(job.totalCost)}</p>
</div>
<div>
<p className="text-gray-500">Memory</p>
<p className="text-white">{job.memoryGb} GB</p>
</div>
</div>
{job.resultCid && (
<div className="mb-4">
<p className="text-gray-500 text-sm mb-1">Result CID</p>
<code className="block p-3 bg-gray-900 rounded-lg text-sm text-gray-300 overflow-x-auto font-mono">
{job.resultCid}
</code>
</div>
)}
{(job.status === 'pending' || job.status === 'running') && (
<button
onClick={(e) => {
e.stopPropagation();
handleCancelJob(job.jobId);
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
>
Cancel Job
</button>
)}
</div>
)}
</div>
))}
{jobs.length === 0 && (
<div className="text-center py-12 text-gray-500">
No compute jobs. Submit a job to get started.
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,563 @@
import { useState, useEffect } from 'react';
import {
FileCode2,
Plus,
Play,
BookOpen,
Trash2,
Copy,
ExternalLink,
RefreshCw,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { useContractsStore, truncateAddress } from '../../store/contracts';
import { useNodeStore } from '../../store/node';
export default function ContractsDashboard() {
const {
deployedContracts,
isDeploying,
isCalling,
isReading,
error,
clearError,
deployContract,
callContract,
readContract,
removeDeployedContract,
} = useContractsStore();
const nodeStatus = useNodeStore((state) => state.status);
// View state
const [activeTab, setActiveTab] = useState<'deploy' | 'interact'>('deploy');
const [selectedContract, setSelectedContract] = useState<string | null>(null);
// Deploy form state
const [deployForm, setDeployForm] = useState({
bytecode: '',
constructorArgs: '',
gasLimit: '1000000',
value: '0',
name: '',
abi: '',
});
// Interaction form state
const [interactForm, setInteractForm] = useState({
method: '',
args: '',
gasLimit: '100000',
value: '0',
isRead: false,
});
// Results
const [interactResult, setInteractResult] = useState<string | null>(null);
const [expandedContracts, setExpandedContracts] = useState<Set<string>>(new Set());
// Clear error on tab change
useEffect(() => {
clearError();
setInteractResult(null);
}, [activeTab, selectedContract, clearError]);
const handleDeploy = async () => {
if (!deployForm.bytecode) return;
try {
const result = await deployContract(
{
bytecode: deployForm.bytecode,
constructorArgs: deployForm.constructorArgs || undefined,
gasLimit: parseInt(deployForm.gasLimit),
value: parseInt(deployForm.value),
},
deployForm.name || undefined,
deployForm.abi || undefined
);
// Clear form and switch to interact tab
setDeployForm({
bytecode: '',
constructorArgs: '',
gasLimit: '1000000',
value: '0',
name: '',
abi: '',
});
setSelectedContract(result.contractAddress);
setActiveTab('interact');
} catch {
// Error is handled by store
}
};
const handleInteract = async () => {
if (!selectedContract || !interactForm.method) return;
setInteractResult(null);
try {
if (interactForm.isRead) {
const result = await readContract(
selectedContract,
interactForm.method,
interactForm.args || undefined
);
setInteractResult(result);
} else {
const result = await callContract({
contractAddress: selectedContract,
method: interactForm.method,
args: interactForm.args || undefined,
gasLimit: parseInt(interactForm.gasLimit),
value: parseInt(interactForm.value),
});
setInteractResult(`TX: ${result.txHash}\nGas Used: ${result.gasUsed}${result.result ? `\nResult: ${result.result}` : ''}`);
}
} catch {
// Error is handled by store
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const toggleContractExpanded = (address: string) => {
const newExpanded = new Set(expandedContracts);
if (newExpanded.has(address)) {
newExpanded.delete(address);
} else {
newExpanded.add(address);
}
setExpandedContracts(newExpanded);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<FileCode2 size={28} />
Smart Contracts
</h1>
<p className="text-gray-400 mt-1">
Deploy and interact with smart contracts on Synor
</p>
</div>
</div>
{/* Not connected warning */}
{!nodeStatus.isConnected && (
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
Please connect to a node to deploy and interact with contracts
</div>
)}
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
<Trash2 size={18} />
</button>
</div>
)}
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: Deployed Contracts List */}
<div className="lg:col-span-1">
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<FileCode2 size={20} />
Deployed Contracts
</h3>
{deployedContracts.length === 0 ? (
<p className="text-gray-500 text-sm">No contracts deployed yet</p>
) : (
<div className="space-y-2">
{deployedContracts.map((contract) => (
<div
key={contract.address}
className={`p-3 rounded-lg border transition-colors cursor-pointer ${
selectedContract === contract.address
? 'bg-synor-900/30 border-synor-700'
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
}`}
>
<div
className="flex items-center justify-between"
onClick={() => toggleContractExpanded(contract.address)}
>
<div className="flex items-center gap-2">
{expandedContracts.has(contract.address) ? (
<ChevronDown size={16} className="text-gray-400" />
) : (
<ChevronRight size={16} className="text-gray-400" />
)}
<div>
<p className="text-sm font-medium text-white">
{contract.name}
</p>
<p className="text-xs text-gray-500 font-mono">
{truncateAddress(contract.address)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(contract.address);
}}
className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-white"
title="Copy address"
>
<Copy size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedContract(contract.address);
setActiveTab('interact');
}}
className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-synor-400"
title="Interact"
>
<Play size={14} />
</button>
</div>
</div>
{expandedContracts.has(contract.address) && (
<div className="mt-3 pt-3 border-t border-gray-700 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-gray-500">Deployed:</span>
<span className="text-gray-400">
{new Date(contract.deployedAt).toLocaleDateString()}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-500">TX:</span>
<span className="text-gray-400 font-mono">
{truncateAddress(contract.txHash, 6)}
</span>
</div>
<button
onClick={() => removeDeployedContract(contract.address)}
className="w-full mt-2 text-xs text-red-400 hover:text-red-300 flex items-center justify-center gap-1"
>
<Trash2 size={12} />
Remove
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Right: Deploy / Interact Tabs */}
<div className="lg:col-span-2">
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('deploy')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'deploy'
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
<Plus size={18} />
Deploy
</button>
<button
onClick={() => setActiveTab('interact')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === 'interact'
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
<Play size={18} />
Interact
</button>
</div>
{/* Deploy Tab */}
{activeTab === 'deploy' && (
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Contract Name (optional)
</label>
<input
type="text"
value={deployForm.name}
onChange={(e) =>
setDeployForm({ ...deployForm, name: e.target.value })
}
placeholder="My Contract"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Bytecode *
</label>
<textarea
value={deployForm.bytecode}
onChange={(e) =>
setDeployForm({ ...deployForm, bytecode: e.target.value })
}
placeholder="0x608060405234801561001057600080fd5b50..."
rows={4}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Constructor Arguments (ABI-encoded, optional)
</label>
<input
type="text"
value={deployForm.constructorArgs}
onChange={(e) =>
setDeployForm({ ...deployForm, constructorArgs: e.target.value })
}
placeholder="0x0000000000000000000000000000000000000000000000000000000000000001"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Gas Limit
</label>
<input
type="number"
value={deployForm.gasLimit}
onChange={(e) =>
setDeployForm({ ...deployForm, gasLimit: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Value (in smallest unit)
</label>
<input
type="number"
value={deployForm.value}
onChange={(e) =>
setDeployForm({ ...deployForm, value: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
ABI JSON (optional, for interaction)
</label>
<textarea
value={deployForm.abi}
onChange={(e) =>
setDeployForm({ ...deployForm, abi: e.target.value })
}
placeholder='[{"inputs": [], "name": "getValue", "outputs": [...]}]'
rows={3}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<button
onClick={handleDeploy}
disabled={isDeploying || !deployForm.bytecode || !nodeStatus.isConnected}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeploying ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Plus size={18} />
)}
Deploy Contract
</button>
</div>
)}
{/* Interact Tab */}
{activeTab === 'interact' && (
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Contract Address
</label>
<select
value={selectedContract || ''}
onChange={(e) => setSelectedContract(e.target.value || null)}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select a contract...</option>
{deployedContracts.map((contract) => (
<option key={contract.address} value={contract.address}>
{contract.name} - {truncateAddress(contract.address)}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Or paste a contract address directly
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Method Name
</label>
<input
type="text"
value={interactForm.method}
onChange={(e) =>
setInteractForm({ ...interactForm, method: e.target.value })
}
placeholder="transfer"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Arguments (ABI-encoded, optional)
</label>
<input
type="text"
value={interactForm.args}
onChange={(e) =>
setInteractForm({ ...interactForm, args: e.target.value })
}
placeholder="0x..."
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={interactForm.isRead}
onChange={(e) =>
setInteractForm({ ...interactForm, isRead: e.target.checked })
}
className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-synor-500 focus:ring-synor-500"
/>
<span className="text-sm text-gray-400">
Read-only (no transaction)
</span>
</label>
</div>
{!interactForm.isRead && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Gas Limit
</label>
<input
type="number"
value={interactForm.gasLimit}
onChange={(e) =>
setInteractForm({ ...interactForm, gasLimit: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Value (in smallest unit)
</label>
<input
type="number"
value={interactForm.value}
onChange={(e) =>
setInteractForm({ ...interactForm, value: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
</div>
</div>
)}
<button
onClick={handleInteract}
disabled={
(isCalling || isReading) ||
!selectedContract ||
!interactForm.method ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{(isCalling || isReading) ? (
<RefreshCw size={18} className="animate-spin" />
) : interactForm.isRead ? (
<BookOpen size={18} />
) : (
<Play size={18} />
)}
{interactForm.isRead ? 'Read' : 'Execute'}
</button>
{/* Result display */}
{interactResult && (
<div className="mt-4 p-4 rounded-lg bg-gray-800 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">Result</span>
<button
onClick={() => copyToClipboard(interactResult)}
className="text-gray-400 hover:text-white"
>
<Copy size={14} />
</button>
</div>
<pre className="text-sm text-white font-mono whitespace-pre-wrap break-all">
{interactResult}
</pre>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Help Section */}
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-400 mb-3 flex items-center gap-2">
<ExternalLink size={16} />
Getting Started
</h3>
<ul className="space-y-2 text-sm text-gray-500">
<li> Compile your contract using a Solidity compiler to get the bytecode</li>
<li> Constructor arguments must be ABI-encoded if your constructor has parameters</li>
<li> Use "Read-only" mode for view/pure functions that don't modify state</li>
<li> Save the ABI JSON for easier interaction with complex contracts</li>
</ul>
</div>
</div>
);
}

View file

@ -0,0 +1,320 @@
import { useState, useEffect } from 'react';
import {
Globe,
Link2,
Link2Off,
ExternalLink,
Search,
RefreshCw,
Shield,
AlertCircle,
Zap,
Image,
Coins,
} from 'lucide-react';
import { useDAppsStore, POPULAR_DAPPS } from '../../store/dapps';
import { useWalletStore } from '../../store/wallet';
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
DeFi: <Coins size={16} />,
NFT: <Image size={16} />,
Gaming: <Zap size={16} />,
};
export default function DAppBrowser() {
const { addresses } = useWalletStore();
const {
connectedDApps,
isLoading,
error,
clearError,
fetchConnected,
connect,
disconnect,
} = useDAppsStore();
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'discover' | 'connected'>('discover');
const [connectingDApp, setConnectingDApp] = useState<string | null>(null);
const userAddress = addresses[0]?.address;
useEffect(() => {
fetchConnected();
}, [fetchConnected]);
const filteredDApps = POPULAR_DAPPS.filter(
(dapp) =>
dapp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dapp.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
dapp.category.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleConnect = async (dapp: (typeof POPULAR_DAPPS)[0]) => {
if (!userAddress) return;
setConnectingDApp(dapp.url);
try {
await connect(
dapp.url,
dapp.name,
userAddress,
['eth_accounts', 'eth_sendTransaction', 'personal_sign']
);
setActiveTab('connected');
} catch {
// Error handled by store
} finally {
setConnectingDApp(null);
}
};
const handleDisconnect = async (origin: string) => {
try {
await disconnect(origin);
} catch {
// Error handled by store
}
};
const isConnected = (url: string) =>
connectedDApps.some((d) => d.origin === url);
const categories = [...new Set(POPULAR_DAPPS.map((d) => d.category))];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">DApp Browser</h1>
<p className="text-gray-400 mt-1">Discover and connect to decentralized apps</p>
</div>
<button
onClick={fetchConnected}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Search */}
<div className="relative">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search DApps..."
className="w-full pl-10 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('discover')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'discover'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Discover
</button>
<button
onClick={() => setActiveTab('connected')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'connected'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Connected ({connectedDApps.length})
</button>
</div>
{/* Discover Tab */}
{activeTab === 'discover' && (
<div className="space-y-6">
{categories.map((category) => {
const categoryDApps = filteredDApps.filter((d) => d.category === category);
if (categoryDApps.length === 0) return null;
return (
<div key={category}>
<h2 className="flex items-center gap-2 text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">
{CATEGORY_ICONS[category] || <Globe size={16} />}
{category}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryDApps.map((dapp) => (
<div
key={dapp.url}
className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors"
>
<div className="flex items-start gap-3">
<div className="w-12 h-12 bg-gray-800 rounded-xl flex items-center justify-center text-2xl">
{dapp.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">{dapp.name}</h3>
<p className="text-sm text-gray-400 line-clamp-2">
{dapp.description}
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
{isConnected(dapp.url) ? (
<>
<button
onClick={() =>
window.open(dapp.url, '_blank', 'noopener,noreferrer')
}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white text-sm font-medium transition-colors"
>
<ExternalLink size={14} />
Open
</button>
<button
onClick={() => handleDisconnect(dapp.url)}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 text-sm font-medium transition-colors"
>
<Link2Off size={14} />
</button>
</>
) : (
<button
onClick={() => handleConnect(dapp)}
disabled={connectingDApp === dapp.url || !userAddress}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{connectingDApp === dapp.url ? (
<>
<RefreshCw size={14} className="animate-spin" />
Connecting...
</>
) : (
<>
<Link2 size={14} />
Connect
</>
)}
</button>
)}
</div>
</div>
))}
</div>
</div>
);
})}
{filteredDApps.length === 0 && (
<div className="text-center py-12 text-gray-500">
No DApps found matching your search
</div>
)}
</div>
)}
{/* Connected Tab */}
{activeTab === 'connected' && (
<div className="space-y-4">
{connectedDApps.map((dapp) => (
<div
key={dapp.origin}
className="bg-gray-900 rounded-xl p-4 border border-gray-800"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-600/20 rounded-lg">
<Shield className="text-green-400" size={20} />
</div>
<div>
<h3 className="font-semibold text-white">{dapp.name}</h3>
<p className="text-sm text-gray-400">{dapp.origin}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
window.open(dapp.origin, '_blank', 'noopener,noreferrer')
}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<ExternalLink size={18} className="text-gray-400" />
</button>
<button
onClick={() => handleDisconnect(dapp.origin)}
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 text-sm font-medium transition-colors"
>
Disconnect
</button>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-800">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Connected Address</p>
<code className="text-white text-xs">
{dapp.connectedAddress.slice(0, 12)}...
{dapp.connectedAddress.slice(-8)}
</code>
</div>
<div>
<p className="text-gray-500">Connected Since</p>
<p className="text-white">
{new Date(dapp.connectedAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="mt-3">
<p className="text-gray-500 text-sm mb-1">Permissions</p>
<div className="flex flex-wrap gap-1">
{dapp.permissions.map((perm) => (
<span
key={perm}
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
>
{perm}
</span>
))}
</div>
</div>
</div>
</div>
))}
{connectedDApps.length === 0 && (
<div className="text-center py-12">
<Globe className="mx-auto mb-4 text-gray-600" size={48} />
<p className="text-gray-500">No connected DApps</p>
<p className="text-sm text-gray-600 mt-1">
Connect to a DApp from the Discover tab
</p>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,350 @@
import { useState, useEffect } from 'react';
import {
Database,
Plus,
Trash2,
RefreshCw,
AlertCircle,
Search,
Play,
FileJson,
Key,
Clock,
GitBranch,
Table,
Braces,
} from 'lucide-react';
import { useDatabaseStore, DATABASE_TYPES, REGIONS, DatabaseType } from '../../store/database';
const TYPE_ICONS: Record<DatabaseType, React.ReactNode> = {
kv: <Key size={20} className="text-blue-400" />,
document: <FileJson size={20} className="text-green-400" />,
vector: <Braces size={20} className="text-purple-400" />,
timeseries: <Clock size={20} className="text-yellow-400" />,
graph: <GitBranch size={20} className="text-pink-400" />,
sql: <Table size={20} className="text-cyan-400" />,
};
const TYPE_DESCRIPTIONS: Record<DatabaseType, string> = {
kv: 'Fast key-value storage for caching and simple data',
document: 'JSON document storage with flexible schemas',
vector: 'Vector embeddings for AI/ML and semantic search',
timeseries: 'Time-indexed data for metrics and analytics',
graph: 'Connected data with relationships and traversals',
sql: 'Traditional relational database with ACID compliance',
};
export default function DatabaseDashboard() {
const {
instances,
isLoading,
isCreating,
error,
clearError,
fetchInstances,
createDatabase,
deleteDatabase,
executeQuery,
} = useDatabaseStore();
const [showCreateForm, setShowCreateForm] = useState(false);
const [newDbName, setNewDbName] = useState('');
const [newDbType, setNewDbType] = useState<DatabaseType>('document');
const [newDbRegion, setNewDbRegion] = useState('us-east');
const [selectedDb, setSelectedDb] = useState<string | null>(null);
const [queryInput, setQueryInput] = useState('');
const [isQuerying, setIsQuerying] = useState(false);
const [queryResult, setQueryResult] = useState<unknown>(null);
useEffect(() => {
fetchInstances();
}, [fetchInstances]);
const handleCreateDatabase = async () => {
if (!newDbName) return;
try {
await createDatabase(newDbName, newDbType, newDbRegion);
setShowCreateForm(false);
setNewDbName('');
fetchInstances();
} catch {
// Error handled by store
}
};
const handleDeleteDatabase = async (id: string) => {
if (!confirm('Are you sure you want to delete this database? This action cannot be undone.')) {
return;
}
await deleteDatabase(id);
if (selectedDb === id) {
setSelectedDb(null);
}
fetchInstances();
};
const handleQuery = async () => {
if (!selectedDb || !queryInput) return;
setIsQuerying(true);
try {
const result = await executeQuery(selectedDb, queryInput);
setQueryResult(result);
} catch {
// Error handled by store
} finally {
setIsQuerying(false);
}
};
const formatSize = (bytes: number) => {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(2)} KB`;
return `${bytes} B`;
};
const selectedDatabase = instances.find((db) => db.id === selectedDb);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Database Services</h1>
<p className="text-gray-400 mt-1">Multi-model decentralized databases</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchInstances}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={16} />
Create Database
</button>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Create Database Modal */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg">
<h2 className="text-xl font-bold text-white mb-4">Create Database</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Database Name</label>
<input
type="text"
value={newDbName}
onChange={(e) => setNewDbName(e.target.value)}
placeholder="my-database"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Database Type</label>
<div className="grid grid-cols-2 gap-2">
{DATABASE_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setNewDbType(type.value)}
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
newDbType === type.value
? 'border-synor-500 bg-synor-600/20'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
{TYPE_ICONS[type.value]}
<div className="text-left">
<p className="text-white font-medium">{type.label}</p>
<p className="text-xs text-gray-500">{type.description.split(' ').slice(0, 3).join(' ')}...</p>
</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Region</label>
<select
value={newDbRegion}
onChange={(e) => setNewDbRegion(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
{REGIONS.map((region) => (
<option key={region.value} value={region.value}>
{region.label}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleCreateDatabase}
disabled={!newDbName || isCreating}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Database List */}
<div className="lg:col-span-1 space-y-4">
<h2 className="text-lg font-semibold text-white">Your Databases</h2>
{instances.map((db) => (
<div
key={db.id}
onClick={() => setSelectedDb(db.id)}
className={`bg-gray-900 rounded-xl p-4 border cursor-pointer transition-colors ${
selectedDb === db.id
? 'border-synor-500 bg-synor-600/10'
: 'border-gray-800 hover:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{TYPE_ICONS[db.dbType]}
<div>
<h3 className="text-white font-medium">{db.name}</h3>
<p className="text-sm text-gray-500 capitalize">{db.dbType} {db.region}</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteDatabase(db.id);
}}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
<Trash2 size={16} className="text-gray-500 hover:text-red-400" />
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-gray-500">Size</p>
<p className="text-white">{formatSize(db.storageUsed)}</p>
</div>
<div>
<p className="text-gray-500">Status</p>
<p className="text-white capitalize">{db.status}</p>
</div>
</div>
</div>
))}
{instances.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Database size={32} className="mx-auto mb-2 opacity-50" />
<p>No databases yet</p>
</div>
)}
</div>
{/* Query Panel */}
<div className="lg:col-span-2">
{selectedDatabase ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800">
<div className="flex items-center gap-3">
{TYPE_ICONS[selectedDatabase.dbType]}
<div>
<h2 className="text-lg font-semibold text-white">{selectedDatabase.name}</h2>
<p className="text-sm text-gray-400">{TYPE_DESCRIPTIONS[selectedDatabase.dbType]}</p>
</div>
</div>
</div>
<div className="p-4 border-b border-gray-800">
<label className="block text-sm text-gray-400 mb-2">
<Search size={14} className="inline mr-1" />
Query
</label>
<textarea
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
placeholder={
selectedDatabase.dbType === 'sql'
? 'SELECT * FROM users WHERE active = true'
: selectedDatabase.dbType === 'document'
? '{"filter": {"status": "active"}, "limit": 10}'
: selectedDatabase.dbType === 'kv'
? 'GET user:123'
: selectedDatabase.dbType === 'vector'
? '{"vector": [0.1, 0.2, ...], "topK": 10}'
: selectedDatabase.dbType === 'graph'
? 'MATCH (n:User)-[:FOLLOWS]->(m) RETURN m'
: '{"start": "2024-01-01", "end": "2024-01-31"}'
}
rows={4}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<button
onClick={handleQuery}
disabled={!queryInput || isQuerying}
className="mt-2 flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<Play size={16} />
{isQuerying ? 'Executing...' : 'Execute Query'}
</button>
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-gray-400">Result</label>
{queryResult !== null && (
<button
onClick={() => setQueryResult(null)}
className="text-xs text-gray-500 hover:text-gray-400"
>
Clear
</button>
)}
</div>
<div className="bg-gray-800 rounded-lg p-4 min-h-[200px] max-h-[400px] overflow-auto">
{queryResult ? (
<pre className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
{JSON.stringify(queryResult, null, 2)}
</pre>
) : (
<p className="text-gray-500 text-sm">Execute a query to see results</p>
)}
</div>
</div>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-12 text-center">
<Database size={48} className="mx-auto mb-4 text-gray-600" />
<h3 className="text-lg font-medium text-white mb-2">Select a Database</h3>
<p className="text-gray-500">Choose a database from the list to query it</p>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from 'react';
import {
EyeOff,
Plus,
Trash2,
RefreshCw,
AlertCircle,
ShieldAlert,
Info,
Wallet,
} from 'lucide-react';
import { useDecoyStore, DecoyWallet } from '../../store/decoy';
function SetupDecoyModal({ onClose }: { onClose: () => void }) {
const { setup, isLoading } = useDecoyStore();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
try {
await setup(password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup failed');
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<ShieldAlert className="text-synor-400" />
Setup Decoy Wallets
</h2>
<p className="text-gray-400 text-sm mb-4">
Create a "duress password" that opens decoy wallets instead of your real wallet.
This provides plausible deniability if forced to unlock your wallet.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Duress Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Confirm Password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
{isLoading ? 'Setting up...' : 'Enable'}
</button>
</div>
</form>
</div>
</div>
);
}
function CreateDecoyModal({ onClose }: { onClose: () => void }) {
const { createDecoy, isLoading } = useDecoyStore();
const [name, setName] = useState('');
const [balance, setBalance] = useState('0.1');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createDecoy(name, parseFloat(balance));
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
<h2 className="text-xl font-bold mb-4">Create Decoy Wallet</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Wallet Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Savings"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Fake Balance (SYN)</label>
<input
type="number"
step="0.001"
value={balance}
onChange={(e) => setBalance(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-3">
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
Create
</button>
</div>
</form>
</div>
</div>
);
}
function DecoyCard({ decoy }: { decoy: DecoyWallet }) {
const { deleteDecoy } = useDecoyStore();
const [showConfirm, setShowConfirm] = useState(false);
return (
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-medium">{decoy.name}</h4>
<p className="text-xs text-gray-500 font-mono">{decoy.address.slice(0, 20)}...</p>
</div>
{!showConfirm ? (
<button onClick={() => setShowConfirm(true)} className="text-gray-500 hover:text-red-400">
<Trash2 size={16} />
</button>
) : (
<div className="flex gap-2">
<button onClick={() => setShowConfirm(false)} className="px-2 py-1 text-xs bg-gray-700 rounded">
No
</button>
<button onClick={() => deleteDecoy(decoy.id)} className="px-2 py-1 text-xs bg-red-600 rounded">
Yes
</button>
</div>
)}
</div>
<p className="text-xl font-bold text-synor-400">{decoy.balanceHuman}</p>
</div>
);
}
export default function DecoyDashboard() {
const { isEnabled, decoys, isLoading, error, checkEnabled, fetchDecoys } = useDecoyStore();
const [showSetup, setShowSetup] = useState(false);
const [showCreate, setShowCreate] = useState(false);
useEffect(() => {
checkEnabled();
fetchDecoys();
}, [checkEnabled, fetchDecoys]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<EyeOff className="text-synor-400" />
Decoy Wallets
</h1>
<p className="text-gray-400 mt-1">Plausible deniability for your crypto</p>
</div>
<div className="flex gap-3">
<button onClick={fetchDecoys} className="p-2 bg-gray-800 rounded-lg">
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
{isEnabled && (
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
<Plus size={18} />
Add Decoy
</button>
)}
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="text-red-400" />
<span className="text-red-200">{error}</span>
</div>
)}
{!isEnabled ? (
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
<ShieldAlert size={48} className="mx-auto text-gray-600 mb-4" />
<h3 className="text-lg font-medium mb-2">Decoy Wallets Not Enabled</h3>
<p className="text-gray-500 mb-4 max-w-md mx-auto">
Set up a duress password that opens fake wallets to protect your real funds
under coercion.
</p>
<button onClick={() => setShowSetup(true)} className="px-6 py-2 bg-synor-600 rounded-lg">
Enable Decoy Wallets
</button>
</div>
) : (
<>
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-4 flex items-center gap-3">
<ShieldAlert className="text-green-400" />
<div>
<p className="font-medium">Decoy Protection Active</p>
<p className="text-sm text-gray-400">
Using your duress password will show decoy wallets instead of real funds
</p>
</div>
</div>
{decoys.length === 0 ? (
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
<Wallet size={32} className="mx-auto text-gray-600 mb-2" />
<p className="text-gray-500">No decoy wallets created yet</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{decoys.map((d) => (
<DecoyCard key={d.id} decoy={d} />
))}
</div>
)}
</>
)}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<div className="text-sm text-gray-400">
<p className="font-medium text-gray-300 mb-1">How Decoy Wallets Work</p>
<p>
When unlocking with your duress password, decoy wallets are shown instead of your
real wallet. The decoys appear legitimate but contain minimal funds, protecting your
actual holdings.
</p>
</div>
</div>
{showSetup && <SetupDecoyModal onClose={() => setShowSetup(false)} />}
{showCreate && <CreateDecoyModal onClose={() => setShowCreate(false)} />}
</div>
);
}

View file

@ -0,0 +1,439 @@
import { useEffect, useState, useMemo } from 'react';
import {
Activity,
TrendingUp,
Clock,
Zap,
RefreshCw,
Gauge,
DollarSign,
BarChart3,
AlertCircle,
CheckCircle,
Info,
} from 'lucide-react';
import {
useFeeAnalyticsStore,
getCongestionColor,
getCongestionBgColor,
formatDuration,
FeeRecommendation,
} from '../../store/feeAnalytics';
import { LoadingSpinner } from '../../components/LoadingStates';
/**
* Fee tier selection card
*/
function FeeTierCard({
recommendation,
isSelected,
onSelect,
}: {
recommendation: FeeRecommendation;
isSelected: boolean;
onSelect: () => void;
}) {
const tierIcons = {
economy: Clock,
standard: CheckCircle,
priority: TrendingUp,
instant: Zap,
};
const tierColors = {
economy: 'text-blue-400 border-blue-500/30',
standard: 'text-green-400 border-green-500/30',
priority: 'text-yellow-400 border-yellow-500/30',
instant: 'text-red-400 border-red-500/30',
};
const Icon = tierIcons[recommendation.tier];
return (
<button
onClick={onSelect}
className={`
p-4 rounded-xl border-2 transition-all text-left w-full
${isSelected
? `${tierColors[recommendation.tier]} bg-gray-800`
: 'border-gray-700 hover:border-gray-600 bg-gray-900'
}
`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Icon size={18} className={tierColors[recommendation.tier].split(' ')[0]} />
<span className="font-semibold capitalize">{recommendation.tier}</span>
</div>
{isSelected && (
<span className="text-xs bg-synor-600 px-2 py-0.5 rounded">Selected</span>
)}
</div>
<div className="text-2xl font-bold text-white mb-1">
{recommendation.feeRate.toFixed(2)}
<span className="text-sm text-gray-400 ml-1">sompi/byte</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mt-2">
<span className="flex items-center gap-1">
<BarChart3 size={14} />
~{recommendation.estimatedBlocks} blocks
</span>
<span className="flex items-center gap-1">
<Clock size={14} />
{formatDuration(recommendation.estimatedTimeSecs)}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">{recommendation.description}</p>
</button>
);
}
/**
* Mempool visualization bar
*/
function MempoolVisualization({
txCount,
percentile10,
percentile50,
percentile90,
}: {
txCount: number;
percentile10: number;
percentile50: number;
percentile90: number;
}) {
const maxTx = 300; // Visualization max
const fillPercent = Math.min((txCount / maxTx) * 100, 100);
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-400">Mempool Transactions</span>
<span className="text-lg font-bold">{txCount.toLocaleString()}</span>
</div>
{/* Mempool fill bar */}
<div className="h-6 bg-gray-800 rounded-lg overflow-hidden relative">
<div
className="h-full bg-gradient-to-r from-green-500 via-yellow-500 to-red-500 transition-all duration-500"
style={{ width: `${fillPercent}%` }}
/>
{/* Fee distribution markers */}
<div className="absolute inset-0 flex items-center justify-between px-2">
<span className="text-xs text-white/70">Low</span>
<span className="text-xs text-white/70">High</span>
</div>
</div>
{/* Fee distribution */}
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="bg-gray-800 rounded p-2">
<p className="text-xs text-gray-500">10th %ile</p>
<p className="text-sm font-mono">{percentile10.toFixed(2)}</p>
</div>
<div className="bg-gray-800 rounded p-2">
<p className="text-xs text-gray-500">Median</p>
<p className="text-sm font-mono">{percentile50.toFixed(2)}</p>
</div>
<div className="bg-gray-800 rounded p-2">
<p className="text-xs text-gray-500">90th %ile</p>
<p className="text-sm font-mono">{percentile90.toFixed(2)}</p>
</div>
</div>
</div>
);
}
/**
* Simple fee history chart (text-based)
*/
function FeeHistoryChart() {
const { analytics } = useFeeAnalyticsStore();
const history = analytics?.feeHistory || [];
if (history.length === 0) {
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-gray-500 text-center">No fee history available</p>
</div>
);
}
// Find min/max for scaling
const maxFee = Math.max(...history.map((h) => h.maxFeeRate));
const minFee = Math.min(...history.map((h) => h.minFeeRate));
const range = maxFee - minFee || 1;
// Take last 12 hours for display
const recentHistory = history.slice(-12);
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-400">Fee Rate History (24h)</span>
<span className="text-xs text-gray-500">sompi/byte</span>
</div>
{/* Simple bar chart */}
<div className="flex items-end gap-1 h-24">
{recentHistory.map((point, i) => {
const height = ((point.avgFeeRate - minFee) / range) * 100;
const date = new Date(point.timestamp * 1000);
const hour = date.getHours();
return (
<div key={i} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-synor-500 rounded-t transition-all hover:bg-synor-400"
style={{ height: `${Math.max(height, 5)}%` }}
title={`${point.avgFeeRate.toFixed(2)} sompi/byte at ${date.toLocaleTimeString()}`}
/>
{i % 3 === 0 && (
<span className="text-xs text-gray-600 mt-1">{hour}:00</span>
)}
</div>
);
})}
</div>
{/* Y-axis labels */}
<div className="flex justify-between text-xs text-gray-600 mt-2">
<span>{minFee.toFixed(1)}</span>
<span>{((maxFee + minFee) / 2).toFixed(1)}</span>
<span>{maxFee.toFixed(1)}</span>
</div>
</div>
);
}
/**
* Fee calculator component
*/
function FeeCalculator() {
const { analytics, selectedTier } = useFeeAnalyticsStore();
const [txSize, setTxSize] = useState(250); // Default tx size
const [calculatedFee, setCalculatedFee] = useState<number | null>(null);
const selectedRec = useMemo(() => {
return analytics?.recommendations.find((r) => r.tier === selectedTier);
}, [analytics, selectedTier]);
useEffect(() => {
if (selectedRec) {
setCalculatedFee(Math.ceil(txSize * selectedRec.feeRate));
}
}, [txSize, selectedRec]);
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="text-sm font-medium text-gray-400 mb-3 flex items-center gap-2">
<DollarSign size={16} />
Fee Calculator
</h3>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 block mb-1">
Transaction Size (bytes)
</label>
<input
type="number"
value={txSize}
onChange={(e) => setTxSize(parseInt(e.target.value) || 0)}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Tier:</span>
<span className="capitalize font-medium">{selectedTier}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Rate:</span>
<span className="font-mono">{selectedRec?.feeRate.toFixed(2) || '0'} sompi/byte</span>
</div>
<div className="border-t border-gray-800 pt-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">Estimated Fee:</span>
<span className="text-xl font-bold text-synor-400">
{calculatedFee?.toLocaleString() || '0'} sompi
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
= {((calculatedFee || 0) / 100_000_000).toFixed(8)} SYN
</p>
</div>
</div>
</div>
);
}
/**
* Main Fee Analytics Dashboard
*/
export default function FeeAnalyticsDashboard() {
const {
analytics,
selectedTier,
isLoading,
error,
autoRefresh,
fetchAnalytics,
setSelectedTier,
setAutoRefresh,
} = useFeeAnalyticsStore();
// Fetch analytics on mount and set up auto-refresh
useEffect(() => {
fetchAnalytics();
let interval: ReturnType<typeof setInterval> | null = null;
if (autoRefresh) {
interval = setInterval(fetchAnalytics, 30000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [autoRefresh, fetchAnalytics]);
if (isLoading && !analytics) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size={32} />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Activity className="text-synor-400" />
Fee Market Analytics
</h1>
<p className="text-gray-400 mt-1">
Monitor network fees and choose optimal transaction costs
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded bg-gray-800 border-gray-600"
/>
Auto-refresh
</label>
<button
onClick={fetchAnalytics}
disabled={isLoading}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="text-red-400" />
<span className="text-red-200">{error}</span>
</div>
)}
{analytics && (
<>
{/* Network Status Bar */}
<div className={`rounded-xl p-4 border ${getCongestionBgColor(analytics.networkCongestion)}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Gauge className={getCongestionColor(analytics.networkCongestion)} size={24} />
<div>
<p className="text-sm text-gray-400">Network Congestion</p>
<p className={`text-lg font-bold capitalize ${getCongestionColor(analytics.networkCongestion)}`}>
{analytics.networkCongestion}
</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="text-sm text-gray-400">Block Time</p>
<p className="text-lg font-mono">{analytics.blockTargetTimeSecs}s</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-400">Avg Fee Rate</p>
<p className="text-lg font-mono">{analytics.mempool.avgFeeRate.toFixed(2)} sompi/b</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-400">Mempool Size</p>
<p className="text-lg font-mono">
{(analytics.mempool.totalSizeBytes / 1024).toFixed(1)} KB
</p>
</div>
</div>
</div>
</div>
{/* Fee Tier Selection */}
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Zap className="text-synor-400" size={20} />
Select Fee Tier
</h2>
<div className="grid grid-cols-4 gap-4">
{analytics.recommendations.map((rec) => (
<FeeTierCard
key={rec.tier}
recommendation={rec}
isSelected={selectedTier === rec.tier}
onSelect={() => setSelectedTier(rec.tier)}
/>
))}
</div>
</div>
{/* Bottom Grid */}
<div className="grid grid-cols-3 gap-4">
{/* Mempool Visualization */}
<MempoolVisualization
txCount={analytics.mempool.txCount}
percentile10={analytics.mempool.percentile10}
percentile50={analytics.mempool.percentile50}
percentile90={analytics.mempool.percentile90}
/>
{/* Fee History Chart */}
<FeeHistoryChart />
{/* Fee Calculator */}
<FeeCalculator />
</div>
{/* Info Box */}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<div className="text-sm text-gray-400">
<p className="font-medium text-gray-300 mb-1">About Fee Selection</p>
<p>
Fee rates are measured in sompi per byte. Higher fees generally result in faster
confirmation times, especially during network congestion. The recommendations above
are based on current mempool conditions and adjust automatically as network activity
changes.
</p>
</div>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,501 @@
import { useState, useEffect } from 'react';
import {
Vote,
Plus,
RefreshCw,
AlertCircle,
Clock,
CheckCircle,
XCircle,
Users,
TrendingUp,
ChevronDown,
ChevronUp,
Zap,
} from 'lucide-react';
import {
useGovernanceStore,
getStatusLabel,
getStatusColor,
calculateVotePercentage,
} from '../../store/governance';
export default function GovernanceDashboard() {
const {
proposals,
votingPower,
isLoading,
isVoting,
error,
clearError,
fetchProposals,
fetchVotingPower,
createProposal,
vote,
delegate,
} = useGovernanceStore();
const [activeTab, setActiveTab] = useState<'proposals' | 'voting-power'>('proposals');
const [showCreateForm, setShowCreateForm] = useState(false);
const [showDelegateForm, setShowDelegateForm] = useState(false);
const [expandedProposal, setExpandedProposal] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
// Create form state
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newActions, setNewActions] = useState('');
// Delegate form state
const [delegateAddress, setDelegateAddress] = useState('');
useEffect(() => {
fetchProposals();
fetchVotingPower();
}, [fetchProposals, fetchVotingPower]);
const handleCreateProposal = async () => {
if (!newTitle || !newDescription) return;
setIsCreating(true);
try {
// Parse actions as string array
let actions: string[] = [];
if (newActions) {
try {
actions = JSON.parse(newActions);
} catch {
// If not valid JSON, treat as single action
actions = [newActions];
}
}
await createProposal(newTitle, newDescription, actions);
setShowCreateForm(false);
setNewTitle('');
setNewDescription('');
setNewActions('');
fetchProposals();
} catch {
// Error handled by store
} finally {
setIsCreating(false);
}
};
const handleVote = async (proposalId: string, support: 'for' | 'against' | 'abstain') => {
await vote(proposalId, support);
fetchProposals();
};
const handleDelegate = async () => {
if (!delegateAddress) return;
await delegate(delegateAddress);
setShowDelegateForm(false);
setDelegateAddress('');
fetchVotingPower();
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock size={16} className="text-yellow-400" />;
case 'active':
return <Vote size={16} className="text-blue-400" />;
case 'passed':
return <CheckCircle size={16} className="text-green-400" />;
case 'rejected':
return <XCircle size={16} className="text-red-400" />;
case 'executed':
return <Zap size={16} className="text-purple-400" />;
default:
return <Clock size={16} className="text-gray-400" />;
}
};
const formatVotes = (votes: string) => {
const num = parseFloat(votes);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return votes;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Governance</h1>
<p className="text-gray-400 mt-1">Participate in Synor DAO decisions</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
fetchProposals();
fetchVotingPower();
}}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={16} />
Create Proposal
</button>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Create Proposal Modal */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-white mb-4">Create Proposal</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Title</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Proposal title"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Description</label>
<textarea
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Describe your proposal in detail..."
rows={4}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Actions (optional JSON array)
</label>
<textarea
value={newActions}
onChange={(e) => setNewActions(e.target.value)}
placeholder='["action1", "action2"]'
rows={3}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleCreateProposal}
disabled={!newTitle || !newDescription || isCreating}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delegate Modal */}
{showDelegateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-md">
<h2 className="text-xl font-bold text-white mb-4">Delegate Voting Power</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400">
Delegate your voting power to another address. They will be able to vote on your
behalf, but you retain ownership of your tokens.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Delegate Address</label>
<input
type="text"
value={delegateAddress}
onChange={(e) => setDelegateAddress(e.target.value)}
placeholder="synor1..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowDelegateForm(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleDelegate}
disabled={!delegateAddress}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Delegate
</button>
</div>
</div>
</div>
</div>
)}
{/* Voting Power Card */}
{votingPower && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-synor-600/20 rounded-lg">
<TrendingUp size={24} className="text-synor-400" />
</div>
<div>
<p className="text-sm text-gray-400">Your Voting Power</p>
<p className="text-2xl font-bold text-white">{formatVotes(votingPower.votingPower)} SYN</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs text-gray-500">Delegated Out</p>
<p className="text-white">{formatVotes(votingPower.delegatedOut)}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Delegated In</p>
<p className="text-white">{formatVotes(votingPower.delegatedIn)}</p>
</div>
<button
onClick={() => setShowDelegateForm(true)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
<Users size={16} className="inline mr-2" />
Delegate
</button>
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('proposals')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'proposals'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Proposals ({proposals.length})
</button>
<button
onClick={() => setActiveTab('voting-power')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'voting-power'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Voting Power
</button>
</div>
{/* Proposals Tab */}
{activeTab === 'proposals' && (
<div className="space-y-4">
{proposals.map((proposal) => {
const votePercentages = calculateVotePercentage(
proposal.forVotes,
proposal.againstVotes,
proposal.abstainVotes
);
return (
<div
key={proposal.id}
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
>
<div
className="flex items-start justify-between p-4 cursor-pointer hover:bg-gray-800/50"
onClick={() => setExpandedProposal(expandedProposal === proposal.id ? null : proposal.id)}
>
<div className="flex items-start gap-4">
{getStatusIcon(proposal.status)}
<div>
<h3 className="text-white font-medium">{proposal.title}</h3>
<p className="text-sm text-gray-400">
#{proposal.id.slice(0, 8)} by {proposal.proposer.slice(0, 8)}...
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 text-xs rounded ${getStatusColor(proposal.status)}`}>
{getStatusLabel(proposal.status)}
</span>
{expandedProposal === proposal.id ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</div>
</div>
{expandedProposal === proposal.id && (
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
<p className="text-gray-300 mb-4">{proposal.description}</p>
{/* Vote Progress */}
<div className="mb-4 space-y-2">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-green-400">For</span>
<span className="text-white">{formatVotes(proposal.forVotes)} ({votePercentages.for.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${votePercentages.for}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-red-400">Against</span>
<span className="text-white">{formatVotes(proposal.againstVotes)} ({votePercentages.against.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-red-500" style={{ width: `${votePercentages.against}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Abstain</span>
<span className="text-white">{formatVotes(proposal.abstainVotes)} ({votePercentages.abstain.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-gray-500" style={{ width: `${votePercentages.abstain}%` }} />
</div>
</div>
</div>
{/* Timeline */}
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div>
<p className="text-gray-500">Start Block</p>
<p className="text-white">#{proposal.startBlock.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500">End Block</p>
<p className="text-white">#{proposal.endBlock.toLocaleString()}</p>
</div>
</div>
{/* Voting Buttons */}
{proposal.status === 'active' && !proposal.userVoted && (
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleVote(proposal.id, 'for');
}}
disabled={isVoting}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Vote For
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleVote(proposal.id, 'against');
}}
disabled={isVoting}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Vote Against
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleVote(proposal.id, 'abstain');
}}
disabled={isVoting}
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Abstain
</button>
</div>
)}
{proposal.userVoted && (
<div className="text-center py-2 text-gray-400">
You have already voted on this proposal {proposal.userVote && `(${proposal.userVote})`}
</div>
)}
</div>
)}
</div>
);
})}
{proposals.length === 0 && (
<div className="text-center py-12 text-gray-500">
No governance proposals yet
</div>
)}
</div>
)}
{/* Voting Power Tab */}
{activeTab === 'voting-power' && votingPower && (
<div className="space-y-6">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">Voting Power Breakdown</h3>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
<span className="text-gray-400">Your Voting Power</span>
<span className="text-white font-medium">{formatVotes(votingPower.votingPower)} SYN</span>
</div>
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
<span className="text-gray-400">Delegated Out</span>
<span className="text-white font-medium">{formatVotes(votingPower.delegatedOut)} SYN</span>
</div>
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
<span className="text-gray-400">Delegated In</span>
<span className="text-white font-medium">{formatVotes(votingPower.delegatedIn)} SYN</span>
</div>
</div>
</div>
{votingPower.delegate && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">Delegation</h3>
<div className="flex items-center justify-between p-4 bg-gray-800 rounded-lg">
<div>
<p className="text-gray-400 text-sm">Your votes are delegated to</p>
<code className="text-white font-mono">{votingPower.delegate}</code>
</div>
<button
onClick={() => delegate('')}
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
>
Revoke
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,323 @@
import { useState, useEffect } from 'react';
import {
Usb,
RefreshCw,
AlertCircle,
Check,
Copy,
Shield,
Cpu,
Key,
Fingerprint,
} from 'lucide-react';
import {
useHardwareStore,
HardwareDevice,
HardwareAddress,
} from '../../store/hardware';
export default function HardwareWalletPage() {
const {
devices,
selectedDevice,
isScanning,
error,
clearError,
detectDevices,
selectDevice,
getAddress,
} = useHardwareStore();
const [addresses, setAddresses] = useState<HardwareAddress[]>([]);
const [loadingAddress, setLoadingAddress] = useState(false);
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
const [accountIndex, setAccountIndex] = useState(0);
useEffect(() => {
detectDevices();
}, [detectDevices]);
const handleSelectDevice = (device: HardwareDevice) => {
selectDevice(device);
setAddresses([]);
};
const handleGetAddress = async () => {
if (!selectedDevice) return;
setLoadingAddress(true);
try {
const address = await getAddress(selectedDevice.id, accountIndex);
setAddresses((prev) => {
// Avoid duplicates
if (prev.some((a) => a.path === address.path)) return prev;
return [...prev, address];
});
setAccountIndex((prev) => prev + 1);
} catch {
// Error handled by store
} finally {
setLoadingAddress(false);
}
};
const copyAddress = (address: string) => {
navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
};
const getDeviceIcon = (type: string) => {
switch (type) {
case 'ledger':
return <Cpu className="text-blue-400" size={24} />;
case 'trezor':
return <Shield className="text-green-400" size={24} />;
default:
return <Usb className="text-gray-400" size={24} />;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Hardware Wallet</h1>
<p className="text-gray-400 mt-1">Connect Ledger or Trezor devices</p>
</div>
<button
onClick={() => detectDevices()}
disabled={isScanning}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isScanning ? 'animate-spin' : ''} />
Scan Devices
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Info Banner */}
<div className="bg-blue-900/20 border border-blue-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Fingerprint className="text-blue-400 mt-0.5" size={20} />
<div>
<h3 className="font-medium text-blue-400">Secure Hardware Signing</h3>
<p className="text-sm text-blue-300/70 mt-1">
Your private keys never leave the hardware device. All transactions are
signed directly on the device for maximum security.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Device List */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Available Devices</h2>
{isScanning ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="animate-spin text-synor-400" size={32} />
</div>
) : devices.length > 0 ? (
<div className="space-y-3">
{devices.map((device) => (
<button
key={device.id}
onClick={() => handleSelectDevice(device)}
className={`w-full flex items-center gap-4 p-4 rounded-lg border transition-colors ${
selectedDevice?.id === device.id
? 'bg-synor-600/20 border-synor-500'
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
}`}
>
<div className="p-2 bg-gray-900 rounded-lg">
{getDeviceIcon(device.deviceType)}
</div>
<div className="flex-1 text-left">
<h3 className="font-medium text-white">{device.name}</h3>
<p className="text-sm text-gray-400 capitalize">
{device.deviceType}
{device.firmwareVersion && ` • v${device.firmwareVersion}`}
</p>
</div>
{device.connected && (
<span className="px-2 py-1 bg-green-600/20 text-green-400 text-xs rounded-full">
Connected
</span>
)}
{selectedDevice?.id === device.id && (
<Check className="text-synor-400" size={20} />
)}
</button>
))}
</div>
) : (
<div className="text-center py-8">
<Usb className="mx-auto mb-4 text-gray-600" size={48} />
<p className="text-gray-500">No devices found</p>
<p className="text-sm text-gray-600 mt-1">
Connect your hardware wallet and click "Scan Devices"
</p>
</div>
)}
</div>
{/* Device Actions */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">
{selectedDevice ? selectedDevice.name : 'Select a Device'}
</h2>
{selectedDevice ? (
<div className="space-y-6">
{/* Get Address */}
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3">
Derive Addresses
</h3>
<button
onClick={handleGetAddress}
disabled={loadingAddress}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{loadingAddress ? (
<>
<RefreshCw size={16} className="animate-spin" />
Deriving...
</>
) : (
<>
<Key size={16} />
Get Next Address
</>
)}
</button>
<p className="text-xs text-gray-500 mt-2">
Confirm on your device to reveal the address
</p>
</div>
{/* Address List */}
{addresses.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3">
Derived Addresses
</h3>
<div className="space-y-2">
{addresses.map((addr, index) => (
<div
key={addr.path}
className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
>
<span className="text-sm text-gray-500 w-8">#{index}</span>
<div className="flex-1 min-w-0">
<code className="text-sm text-white truncate block">
{addr.address}
</code>
<span className="text-xs text-gray-500">{addr.path}</span>
</div>
<button
onClick={() => copyAddress(addr.address)}
className="p-2 hover:bg-gray-700 rounded transition-colors"
>
{copiedAddress === addr.address ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={16} className="text-gray-400" />
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Device Info */}
<div className="pt-4 border-t border-gray-800">
<h3 className="text-sm font-medium text-gray-400 mb-3">
Device Information
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Type</p>
<p className="text-white capitalize">{selectedDevice.deviceType}</p>
</div>
<div>
<p className="text-gray-500">Status</p>
<p className={selectedDevice.connected ? 'text-green-400' : 'text-red-400'}>
{selectedDevice.connected ? 'Connected' : 'Disconnected'}
</p>
</div>
{selectedDevice.firmwareVersion && (
<div>
<p className="text-gray-500">Firmware</p>
<p className="text-white">v{selectedDevice.firmwareVersion}</p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">
Select a device from the list to manage addresses and sign transactions
</p>
</div>
)}
</div>
</div>
{/* Instructions */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Setup Instructions</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
1
</div>
<div>
<h3 className="font-medium text-white">Connect Device</h3>
<p className="text-sm text-gray-400 mt-1">
Plug in your Ledger or Trezor via USB
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
2
</div>
<div>
<h3 className="font-medium text-white">Unlock Device</h3>
<p className="text-sm text-gray-400 mt-1">
Enter your PIN on the hardware wallet
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
3
</div>
<div>
<h3 className="font-medium text-white">Open App</h3>
<p className="text-sm text-gray-400 mt-1">
Open the Synor app on your device
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { invoke } from '../lib/tauri';
import {
ArrowUpRight,
ArrowDownLeft,

View file

@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
import {
Globe,
Plus,
Trash2,
ExternalLink,
RefreshCw,
AlertCircle,
Upload,
Shield,
Link2,
} from 'lucide-react';
import { useHostingStore } from '../../store/hosting';
export default function HostingDashboard() {
const {
sites,
isLoading,
isDeploying,
error,
clearError,
fetchSites,
registerName,
deploySite,
deleteSite,
} = useHostingStore();
const [showRegisterModal, setShowRegisterModal] = useState(false);
const [newName, setNewName] = useState('');
const [deployModal, setDeployModal] = useState<string | null>(null);
const [contentCid, setContentCid] = useState('');
useEffect(() => {
fetchSites();
}, [fetchSites]);
const handleRegister = async () => {
if (!newName) return;
await registerName(newName);
setShowRegisterModal(false);
setNewName('');
};
const handleDeploy = async () => {
if (!deployModal || !contentCid) return;
await deploySite(deployModal, contentCid);
setDeployModal(null);
setContentCid('');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Decentralized Hosting</h1>
<p className="text-gray-400 mt-1">Host websites on the Synor network</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchSites}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button
onClick={() => setShowRegisterModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={16} />
Register Name
</button>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Sites Grid */}
{sites.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sites.map((site) => (
<div
key={site.name}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-synor-600/20 rounded-lg">
<Globe size={24} className="text-synor-400" />
</div>
<div>
<h3 className="font-semibold text-white">{site.name}</h3>
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-synor-400 hover:text-synor-300 flex items-center gap-1"
>
{site.domain}
<ExternalLink size={12} />
</a>
</div>
</div>
<button
onClick={() => deleteSite(site.name)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Trash2 size={16} className="text-red-400" />
</button>
</div>
{site.customDomain && (
<div className="flex items-center gap-2 mb-3 text-sm">
<Link2 size={14} className="text-gray-500" />
<span className="text-gray-400">{site.customDomain}</span>
</div>
)}
<div className="flex items-center gap-2 mb-4">
{site.sslEnabled && (
<span className="flex items-center gap-1 px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-full">
<Shield size={12} />
SSL
</span>
)}
{site.contentCid && (
<span className="px-2 py-1 bg-gray-800 text-gray-400 text-xs rounded-full">
Deployed
</span>
)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Bandwidth Used</p>
<p className="text-white">{(site.bandwidthUsed / 1_073_741_824).toFixed(2)} GB</p>
</div>
<div>
<p className="text-gray-500">Monthly Cost</p>
<p className="text-white">{site.monthlyCost} SYN</p>
</div>
</div>
<button
onClick={() => setDeployModal(site.name)}
className="w-full mt-4 flex items-center justify-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
<Upload size={16} />
Deploy Content
</button>
</div>
))}
</div>
) : (
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
<Globe size={48} className="mx-auto mb-4 text-gray-600" />
<h3 className="text-lg font-medium text-white mb-2">No sites yet</h3>
<p className="text-gray-500 mb-4">Register a name to start hosting your website</p>
<button
onClick={() => setShowRegisterModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={16} />
Register Name
</button>
</div>
)}
{/* Register Modal */}
{showRegisterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
<h2 className="text-xl font-bold text-white mb-4">Register Name</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Site Name</label>
<div className="flex items-center">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="mysite"
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-l-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<span className="px-4 py-2 bg-gray-700 border border-gray-700 rounded-r-lg text-gray-400">
.synor.site
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
3-63 characters, lowercase letters, numbers, and hyphens only
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => { setShowRegisterModal(false); setNewName(''); }}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleRegister}
disabled={!newName || newName.length < 3 || isDeploying}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isDeploying ? 'Registering...' : 'Register'}
</button>
</div>
</div>
</div>
)}
{/* Deploy Modal */}
{deployModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
<h2 className="text-xl font-bold text-white mb-4">Deploy to {deployModal}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Content CID</label>
<input
type="text"
value={contentCid}
onChange={(e) => setContentCid(e.target.value)}
placeholder="bafybeig..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">
Upload your site files to Storage first, then paste the CID here
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => { setDeployModal(null); setContentCid(''); }}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleDeploy}
disabled={!contentCid || isDeploying}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isDeploying ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,280 @@
import { useEffect, useState } from 'react';
import { ArrowUpDown, Info, AlertCircle, Plus, RefreshCw, Loader2, X, TrendingUp, TrendingDown } from 'lucide-react';
import { useLimitOrdersStore, formatAmount } from '../../store/limitOrders';
export default function LimitOrdersDashboard() {
const {
orders,
orderBook,
isLoading,
error,
listOrders,
getOrderBook,
createOrder,
cancelOrder,
clearError,
} = useLimitOrdersStore();
const [showCreateModal, setShowCreateModal] = useState(false);
const [orderType, setOrderType] = useState<'buy' | 'sell'>('buy');
const [tradingPair, setTradingPair] = useState('SYN/USDT');
const [amount, setAmount] = useState('');
const [price, setPrice] = useState('');
useEffect(() => {
listOrders();
getOrderBook('SYN/USDT');
}, [listOrders, getOrderBook]);
const handleCreateOrder = async () => {
if (!amount || !price) return;
try {
await createOrder(
orderType,
tradingPair,
parseFloat(amount) * 100_000_000,
parseFloat(price)
);
setShowCreateModal(false);
setAmount('');
setPrice('');
} catch {
// Error handled by store
}
};
const activeOrders = orders.filter(o => o.status === 'open');
const filledOrders = orders.filter(o => o.status === 'filled');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<ArrowUpDown className="text-synor-400" />
Limit Orders
</h1>
<p className="text-gray-400 mt-1">Set buy/sell orders at specific prices</p>
</div>
<div className="flex gap-2">
<button
onClick={() => { listOrders(); getOrderBook('SYN/USDT'); }}
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
disabled={isLoading}
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 hover:bg-synor-700"
>
<Plus size={18} />
New Order
</button>
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="text-red-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-red-200">Error</p>
<p className="text-sm text-red-200/70">{error}</p>
</div>
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
</div>
)}
{/* Order Book */}
{orderBook && (
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3 text-green-400 flex items-center gap-2">
<TrendingUp size={16} />
Buy Orders ({orderBook.bids.length})
</h3>
{orderBook.bids.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No buy orders</p>
) : (
<div className="space-y-1">
{orderBook.bids.slice(0, 5).map((bid, i) => (
<div key={i} className="flex justify-between text-sm p-2 bg-green-500/10 rounded">
<span className="text-green-400">${bid.price.toFixed(4)}</span>
<span className="text-gray-400">{formatAmount(bid.amount)}</span>
</div>
))}
</div>
)}
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3 text-red-400 flex items-center gap-2">
<TrendingDown size={16} />
Sell Orders ({orderBook.asks.length})
</h3>
{orderBook.asks.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No sell orders</p>
) : (
<div className="space-y-1">
{orderBook.asks.slice(0, 5).map((ask, i) => (
<div key={i} className="flex justify-between text-sm p-2 bg-red-500/10 rounded">
<span className="text-red-400">${ask.price.toFixed(4)}</span>
<span className="text-gray-400">{formatAmount(ask.amount)}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Active Orders */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Active Orders ({activeOrders.length})</h3>
{activeOrders.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No active orders</p>
) : (
<div className="space-y-2">
{activeOrders.map((order) => (
<div key={order.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs ${
order.orderType === 'buy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}>
{order.orderType.toUpperCase()}
</span>
<div>
<p className="font-medium">{order.pair}</p>
<p className="text-xs text-gray-500">
{formatAmount(order.amount)} @ ${order.price.toFixed(4)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">
{((order.filledAmount / order.amount) * 100).toFixed(0)}% filled
</span>
<button
onClick={() => cancelOrder(order.id)}
className="p-1 text-red-400 hover:bg-red-500/20 rounded"
>
<X size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Order History */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Filled Orders ({filledOrders.length})</h3>
{filledOrders.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No filled orders</p>
) : (
<div className="space-y-2">
{filledOrders.slice(0, 10).map((order) => (
<div key={order.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs ${
order.orderType === 'buy' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}>
{order.orderType.toUpperCase()}
</span>
<div>
<p className="font-medium">{order.pair}</p>
<p className="text-xs text-gray-500">{formatAmount(order.amount)}</p>
</div>
</div>
<span className="text-green-400 text-sm">Filled</span>
</div>
))}
</div>
)}
</div>
{/* Create Order Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Create Limit Order</h3>
<button onClick={() => setShowCreateModal(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={() => setOrderType('buy')}
className={`flex-1 py-2 rounded-lg font-medium ${
orderType === 'buy' ? 'bg-green-600 text-white' : 'bg-gray-800 text-gray-400'
}`}
>
Buy
</button>
<button
onClick={() => setOrderType('sell')}
className={`flex-1 py-2 rounded-lg font-medium ${
orderType === 'sell' ? 'bg-red-600 text-white' : 'bg-gray-800 text-gray-400'
}`}
>
Sell
</button>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Trading Pair</label>
<select
value={tradingPair}
onChange={(e) => setTradingPair(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
>
<option value="SYN/USDT">SYN/USDT</option>
<option value="SYN/BTC">SYN/BTC</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Amount (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Price (USD)</label>
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.0000"
step="0.0001"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
/>
</div>
<button
onClick={handleCreateOrder}
disabled={isLoading || !amount || !price}
className={`w-full py-3 rounded-lg font-medium disabled:opacity-50 ${
orderType === 'buy' ? 'bg-green-600 hover:bg-green-700' : 'bg-red-600 hover:bg-red-700'
}`}
>
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : `Place ${orderType.toUpperCase()} Order`}
</button>
</div>
</div>
</div>
)}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Limit orders are executed on-chain through smart contracts, ensuring trustless
and decentralized trading.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,312 @@
import { useState, useEffect } from 'react';
import {
TrendingUp,
TrendingDown,
RefreshCw,
AlertCircle,
DollarSign,
BarChart3,
Clock,
} from 'lucide-react';
import { useMarketStore, formatPrice, formatChange } from '../../store/market';
const TIME_RANGES = [
{ label: '1H', value: '1h' },
{ label: '24H', value: '24h' },
{ label: '7D', value: '7d' },
{ label: '30D', value: '30d' },
] as const;
const LIMIT_MAP: Record<string, number> = {
'1h': 60,
'24h': 96,
'7d': 168,
'30d': 720,
};
export default function MarketDashboard() {
const {
prices,
history,
isLoading,
error,
clearError,
fetchPrices,
fetchHistory,
} = useMarketStore();
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
useEffect(() => {
fetchPrices(['SYN', 'BTC', 'ETH']);
const interval = setInterval(() => fetchPrices(['SYN', 'BTC', 'ETH']), 60000);
return () => clearInterval(interval);
}, [fetchPrices]);
useEffect(() => {
if (selectedSymbol) {
fetchHistory(selectedSymbol, timeRange, LIMIT_MAP[timeRange]);
}
}, [selectedSymbol, timeRange, fetchHistory]);
const selectedPrice = prices.find((p) => p.symbol === selectedSymbol);
const priceHistory = selectedSymbol ? history[selectedSymbol] || [] : [];
// Simple chart rendering
const renderChart = () => {
if (!priceHistory || priceHistory.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-gray-500">
No price data available
</div>
);
}
const minPrice = Math.min(...priceHistory.map((p) => p.price));
const maxPrice = Math.max(...priceHistory.map((p) => p.price));
const priceRange = maxPrice - minPrice || 1;
const isPositive =
priceHistory[priceHistory.length - 1]?.price >= priceHistory[0]?.price;
return (
<div className="h-64 relative">
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between text-xs text-gray-500">
<span>${maxPrice.toFixed(4)}</span>
<span>${((maxPrice + minPrice) / 2).toFixed(4)}</span>
<span>${minPrice.toFixed(4)}</span>
</div>
{/* Chart area */}
<div className="ml-16 h-full relative">
<svg
viewBox={`0 0 ${priceHistory.length} 100`}
className="w-full h-full"
preserveAspectRatio="none"
>
{/* Grid lines */}
<line
x1="0"
y1="25"
x2={priceHistory.length}
y2="25"
stroke="#374151"
strokeWidth="0.5"
/>
<line
x1="0"
y1="50"
x2={priceHistory.length}
y2="50"
stroke="#374151"
strokeWidth="0.5"
/>
<line
x1="0"
y1="75"
x2={priceHistory.length}
y2="75"
stroke="#374151"
strokeWidth="0.5"
/>
{/* Price line */}
<polyline
fill="none"
stroke={isPositive ? '#10b981' : '#ef4444'}
strokeWidth="2"
points={priceHistory
.map((p, i) => {
const y = 100 - ((p.price - minPrice) / priceRange) * 100;
return `${i},${y}`;
})
.join(' ')}
/>
{/* Area fill */}
<polygon
fill={isPositive ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)'}
points={`0,100 ${priceHistory
.map((p, i) => {
const y = 100 - ((p.price - minPrice) / priceRange) * 100;
return `${i},${y}`;
})
.join(' ')} ${priceHistory.length - 1},100`}
/>
</svg>
</div>
</div>
);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Market</h1>
<p className="text-gray-400 mt-1">Price charts and market data</p>
</div>
<button
onClick={() => fetchPrices(['SYN', 'BTC', 'ETH'])}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Price List */}
<div className="lg:col-span-1 bg-gray-900 rounded-xl p-4 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Prices</h2>
<div className="space-y-2">
{prices.map((price) => (
<button
key={price.symbol}
onClick={() => setSelectedSymbol(price.symbol)}
className={`w-full flex items-center justify-between p-3 rounded-lg transition-colors ${
selectedSymbol === price.symbol
? 'bg-synor-600/20 border border-synor-500'
: 'bg-gray-800 hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-700 rounded-full flex items-center justify-center text-sm font-bold text-white">
{price.symbol.slice(0, 2)}
</div>
<div className="text-left">
<p className="font-medium text-white">{price.symbol}</p>
</div>
</div>
<div className="text-right">
<p className="font-medium text-white">{formatPrice(price.priceUsd)}</p>
<p
className={`text-xs flex items-center gap-1 ${
price.change24h >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{price.change24h >= 0 ? (
<TrendingUp size={12} />
) : (
<TrendingDown size={12} />
)}
{formatChange(price.change24h)}
</p>
</div>
</button>
))}
{prices.length === 0 && !isLoading && (
<div className="text-center py-8 text-gray-500">No price data available</div>
)}
</div>
</div>
{/* Chart */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl p-6 border border-gray-800">
{selectedPrice ? (
<>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">{selectedPrice.symbol}</h2>
</div>
<div className="flex items-baseline gap-3 mt-1">
<span className="text-3xl font-bold text-white">
{formatPrice(selectedPrice.priceUsd)}
</span>
<span
className={`flex items-center gap-1 text-lg ${
selectedPrice.change24h >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{selectedPrice.change24h >= 0 ? (
<TrendingUp size={20} />
) : (
<TrendingDown size={20} />
)}
{formatChange(selectedPrice.change24h)}
</span>
</div>
</div>
{/* Time range selector */}
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
{TIME_RANGES.map((range) => (
<button
key={range.value}
onClick={() => setTimeRange(range.value)}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
timeRange === range.value
? 'bg-synor-600 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{range.label}
</button>
))}
</div>
</div>
{/* Chart */}
{renderChart()}
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mt-6 pt-6 border-t border-gray-800">
<div>
<p className="text-sm text-gray-500 flex items-center gap-1">
<BarChart3 size={14} />
24h Volume
</p>
<p className="text-lg font-medium text-white">
{formatPrice(selectedPrice.volume24h)}
</p>
</div>
<div>
<p className="text-sm text-gray-500 flex items-center gap-1">
<DollarSign size={14} />
Market Cap
</p>
<p className="text-lg font-medium text-white">
{formatPrice(selectedPrice.marketCap)}
</p>
</div>
</div>
</>
) : (
<div className="h-96 flex items-center justify-center text-gray-500">
<div className="text-center">
<BarChart3 size={48} className="mx-auto mb-4 opacity-50" />
<p>Select a token to view chart</p>
</div>
</div>
)}
</div>
</div>
{/* Last Updated */}
{prices.length > 0 && (
<div className="flex items-center justify-center gap-2 text-sm text-gray-500">
<Clock size={14} />
Last updated: {new Date().toLocaleTimeString()}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,388 @@
import { useEffect, useState } from 'react';
import {
Hammer,
Play,
Pause,
Square,
Cpu,
Blocks,
TrendingUp,
Clock,
Zap,
} from 'lucide-react';
import { useMiningStore, formatHashrate } from '../../store/mining';
import { useNodeStore } from '../../store/node';
import { useWalletStore } from '../../store/wallet';
export default function MiningDashboard() {
const {
status,
recentBlocks,
hashrateHistory,
defaultThreads,
defaultCoinbaseAddress,
startMining,
stopMining,
pauseMining,
resumeMining,
setThreads,
refreshStatus,
refreshStats,
setupEventListeners,
cleanupEventListeners,
} = useMiningStore();
const nodeStatus = useNodeStore((state) => state.status);
const addresses = useWalletStore((state) => state.addresses);
const [threads, setThreadsLocal] = useState(defaultThreads);
const [coinbaseAddress, setCoinbaseAddress] = useState(
defaultCoinbaseAddress || addresses[0]?.address || ''
);
const [isStarting, setIsStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Max threads available
const maxThreads = navigator.hardwareConcurrency || 8;
// Setup event listeners on mount
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
// Refresh stats periodically when mining
useEffect(() => {
if (!status.isMining) return;
const interval = setInterval(() => {
refreshStatus();
refreshStats();
}, 1000);
return () => clearInterval(interval);
}, [status.isMining, refreshStatus, refreshStats]);
// Update coinbase address when addresses change
useEffect(() => {
if (!coinbaseAddress && addresses.length > 0) {
setCoinbaseAddress(addresses[0].address);
}
}, [addresses, coinbaseAddress]);
const handleStart = async () => {
if (!nodeStatus.isConnected) {
setError('Please connect to a node first');
return;
}
if (!coinbaseAddress) {
setError('Please select a coinbase address for rewards');
return;
}
setIsStarting(true);
setError(null);
try {
await startMining(coinbaseAddress, threads);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start mining');
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
try {
await stopMining();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop mining');
}
};
const handlePauseResume = async () => {
try {
if (status.isPaused) {
await resumeMining();
} else {
await pauseMining();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Action failed');
}
};
const handleThreadsChange = async (newThreads: number) => {
setThreadsLocal(newThreads);
if (status.isMining) {
try {
await setThreads(newThreads);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update threads');
}
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Hammer size={28} />
Mining
</h1>
<p className="text-gray-400 mt-1">
Mine SYN coins using your computer's CPU
</p>
</div>
{/* Control buttons */}
<div className="flex items-center gap-2">
{status.isMining ? (
<>
<button
onClick={handlePauseResume}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
status.isPaused
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
}`}
>
{status.isPaused ? <Play size={18} /> : <Pause size={18} />}
{status.isPaused ? 'Resume' : 'Pause'}
</button>
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<Square size={18} />
Stop
</button>
</>
) : (
<button
onClick={handleStart}
disabled={isStarting || !nodeStatus.isConnected}
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStarting ? (
<Cpu size={18} className="animate-spin" />
) : (
<Play size={18} />
)}
Start Mining
</button>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400">
{error}
</div>
)}
{/* Warning if not connected */}
{!nodeStatus.isConnected && (
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
Please connect to a node before starting mining
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Zap size={20} className={status.isMining && !status.isPaused ? 'animate-pulse' : ''} />}
label="Hashrate"
value={formatHashrate(status.hashrate)}
highlight={status.isMining && !status.isPaused}
/>
<StatCard
icon={<Blocks size={20} />}
label="Blocks Found"
value={status.blocksFound.toString()}
/>
<StatCard
icon={<Cpu size={20} />}
label="Threads"
value={`${status.threads || threads}/${maxThreads}`}
/>
<StatCard
icon={<Clock size={20} />}
label="Status"
value={
status.isMining
? status.isPaused
? 'Paused'
: 'Mining'
: 'Idle'
}
highlight={status.isMining && !status.isPaused}
/>
</div>
{/* Configuration (when not mining) */}
{!status.isMining && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">
Mining Configuration
</h3>
<div className="space-y-4">
{/* Coinbase address */}
<div>
<label className="block text-sm text-gray-400 mb-2">
Reward Address
</label>
<select
value={coinbaseAddress}
onChange={(e) => setCoinbaseAddress(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select address...</option>
{addresses.map((addr) => (
<option key={addr.address} value={addr.address}>
{addr.label || `Address ${addr.index}`} - {addr.address.slice(0, 20)}...
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Mining rewards will be sent to this address
</p>
</div>
{/* Thread slider */}
<div>
<label className="block text-sm text-gray-400 mb-2">
Mining Threads: {threads}
</label>
<input
type="range"
min={1}
max={maxThreads}
value={threads}
onChange={(e) => handleThreadsChange(parseInt(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-synor-500"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (Low Power)</span>
<span>{maxThreads} (Max Performance)</span>
</div>
</div>
</div>
</div>
)}
{/* Hashrate Chart (simplified) */}
{status.isMining && hashrateHistory.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp size={20} />
Hashrate History
</h3>
<div className="h-32 flex items-end gap-0.5">
{hashrateHistory.slice(-60).map((point, i) => {
const maxHash = Math.max(...hashrateHistory.map((h) => h.hashrate));
const height = maxHash > 0 ? (point.hashrate / maxHash) * 100 : 0;
return (
<div
key={i}
className="flex-1 bg-synor-500 rounded-t transition-all duration-300"
style={{ height: `${height}%` }}
title={`${formatHashrate(point.hashrate)}`}
/>
);
})}
</div>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>60s ago</span>
<span>Now</span>
</div>
</div>
)}
{/* Recent Blocks */}
{recentBlocks.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Blocks size={20} />
Blocks Found
</h3>
<div className="space-y-2">
{recentBlocks.slice(0, 5).map((block, i) => (
<div
key={i}
className="flex items-center justify-between p-3 rounded-lg bg-gray-800"
>
<div>
<p className="text-sm text-white font-mono">
Block #{block.height.toLocaleString()}
</p>
<p className="text-xs text-gray-500">
{block.hash.slice(0, 24)}...
</p>
</div>
<div className="text-right">
<p className="text-sm text-synor-400 font-medium">
+{(block.reward / 100_000_000).toFixed(2)} SYN
</p>
<p className="text-xs text-gray-500">
{new Date(block.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Mining Tips */}
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-400 mb-3">Mining Tips</h3>
<ul className="space-y-2 text-sm text-gray-500">
<li> Use fewer threads to keep your computer responsive</li>
<li> Mining profitability depends on network difficulty</li>
<li> Ensure adequate cooling for sustained mining</li>
<li> For best results, use an embedded node for lower latency</li>
</ul>
</div>
</div>
);
}
// Stat card component
function StatCard({
icon,
label,
value,
highlight = false,
}: {
icon: React.ReactNode;
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={`p-4 rounded-xl border ${
highlight
? 'bg-synor-900/30 border-synor-700'
: 'bg-gray-900 border-gray-800'
}`}
>
<div className="flex items-center gap-2 text-gray-400 mb-2">
{icon}
<span className="text-sm">{label}</span>
</div>
<p
className={`text-xl font-bold ${
highlight ? 'text-synor-400' : 'text-white'
}`}
>
{value}
</p>
</div>
);
}

View file

@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import { Shuffle, Info, AlertCircle, RefreshCw, Loader2, X } from 'lucide-react';
import { useMixerStore, formatAmount, formatDenomination } from '../../store/mixer';
export default function MixerDashboard() {
const {
denominations,
poolStatus,
requests,
isLoading,
error,
loadDenominations,
getPoolStatus,
createRequest,
listRequests,
cancelRequest,
clearError,
} = useMixerStore();
const [selectedDenom, setSelectedDenom] = useState<number | null>(null);
const [outputAddress, setOutputAddress] = useState('');
useEffect(() => {
loadDenominations();
listRequests();
}, [loadDenominations, listRequests]);
useEffect(() => {
// Load pool status for first denomination when available
if (denominations.length > 0 && !selectedDenom) {
setSelectedDenom(denominations[0]);
getPoolStatus(denominations[0]);
}
}, [denominations, selectedDenom, getPoolStatus]);
const handleSelectDenom = (denom: number) => {
setSelectedDenom(denom);
getPoolStatus(denom);
};
const handleCreateRequest = async () => {
if (!selectedDenom || !outputAddress) return;
try {
await createRequest(selectedDenom, selectedDenom, outputAddress);
setOutputAddress('');
} catch {
// Error handled by store
}
};
const pendingRequests = requests.filter(r => r.status === 'pending' || r.status === 'mixing');
const completedRequests = requests.filter(r => r.status === 'completed');
const currentPool = selectedDenom ? poolStatus[selectedDenom] : null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Shuffle className="text-synor-400" />
Transaction Mixer
</h1>
<p className="text-gray-400 mt-1">Enhanced privacy through coin mixing</p>
</div>
<button
onClick={() => { loadDenominations(); listRequests(); }}
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
disabled={isLoading}
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="text-red-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-red-200">Error</p>
<p className="text-sm text-red-200/70">{error}</p>
</div>
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
</div>
)}
{/* Denomination Selection */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3">Select Mixing Denomination</h3>
<div className="flex flex-wrap gap-2">
{denominations.length === 0 ? (
<p className="text-sm text-gray-500">Loading denominations...</p>
) : (
denominations.map((denom) => (
<button
key={denom}
onClick={() => handleSelectDenom(denom)}
className={`px-4 py-2 rounded-lg font-medium transition ${
selectedDenom === denom
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{formatDenomination(denom)}
</button>
))
)}
</div>
</div>
{/* Pool Status */}
{currentPool && (
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Denomination</p>
<p className="text-xl font-bold">{formatDenomination(currentPool.denomination)}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Participants</p>
<p className="text-xl font-bold">{currentPool.participants}/{currentPool.requiredParticipants}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Status</p>
<p className={`text-xl font-bold ${
currentPool.status === 'mixing' ? 'text-yellow-400' :
currentPool.status === 'completed' ? 'text-green-400' : 'text-gray-400'
}`}>
{currentPool.status.charAt(0).toUpperCase() + currentPool.status.slice(1)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Est. Time</p>
<p className="text-xl font-bold text-synor-400">
{currentPool.estimatedTimeSecs ? `${Math.ceil(currentPool.estimatedTimeSecs / 60)}m` : '--'}
</p>
</div>
</div>
)}
{/* Create Mix Request */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="font-medium mb-4">Join Mixing Pool</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Selected Amount</label>
<div className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-lg font-bold">
{selectedDenom ? formatDenomination(selectedDenom) : 'Select a denomination above'}
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Output Address</label>
<input
type="text"
value={outputAddress}
onChange={(e) => setOutputAddress(e.target.value)}
placeholder="synor1... (fresh address for privacy)"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:border-synor-500 outline-none font-mono text-sm"
/>
<p className="text-xs text-gray-500 mt-1">Use a new address that hasn't received funds before</p>
</div>
<button
onClick={handleCreateRequest}
disabled={isLoading || !selectedDenom || !outputAddress}
className="w-full py-3 bg-synor-600 rounded-lg font-medium hover:bg-synor-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <Shuffle size={18} />}
Join Pool
</button>
</div>
</div>
{/* Pending Requests */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4 text-yellow-400">Pending Mixes ({pendingRequests.length})</h3>
{pendingRequests.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No pending mix requests</p>
) : (
<div className="space-y-2">
{pendingRequests.map((req) => (
<div key={req.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
<div>
<p className="font-mono text-sm">{formatAmount(req.amount)}</p>
<p className="text-xs text-gray-500">Status: {req.status}</p>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded text-xs ${
req.status === 'mixing' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-gray-700 text-gray-400'
}`}>
{req.status}
</span>
{req.status === 'pending' && (
<button
onClick={() => cancelRequest(req.id)}
className="p-1 text-red-400 hover:bg-red-500/20 rounded"
>
<X size={16} />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Completed Requests */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4 text-green-400">Completed Mixes ({completedRequests.length})</h3>
{completedRequests.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No completed mixes</p>
) : (
<div className="space-y-2">
{completedRequests.map((req) => (
<div key={req.id} className="p-3 bg-gray-800 rounded-lg flex justify-between items-center">
<div>
<p className="font-mono text-sm">{formatAmount(req.amount)}</p>
<p className="text-xs text-gray-500 font-mono truncate max-w-xs"> {req.outputAddress}</p>
{req.txId && (
<p className="text-xs text-synor-400 font-mono truncate max-w-xs">TX: {req.txId}</p>
)}
</div>
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs">
Complete
</span>
</div>
))}
</div>
)}
</div>
{/* How It Works */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="font-medium mb-4">How Mixing Works</h3>
<ol className="space-y-3 text-sm text-gray-400">
<li className="flex gap-3">
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">1</span>
<span>Select a denomination and provide an output address</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">2</span>
<span>Wait for enough participants to join the pool</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">3</span>
<span>All participants' coins are mixed together cryptographically</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs flex-shrink-0">4</span>
<span>Receive coins at your output address with broken transaction history</span>
</li>
</ol>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Mixing uses cryptographic techniques to make transactions untraceable while remaining
fully on-chain and trustless. Fixed denominations ensure all outputs look identical.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,541 @@
import { useState, useEffect } from 'react';
import {
Users,
Plus,
RefreshCw,
AlertCircle,
Check,
Clock,
Shield,
Copy,
Send,
Key,
} from 'lucide-react';
import {
useMultisigStore,
MultisigWalletInfo,
PendingMultisigTx,
} from '../../store/multisig';
import { useWalletStore } from '../../store/wallet';
export default function MultisigDashboard() {
const { addresses } = useWalletStore();
const {
wallets,
pendingTxs,
isCreating,
isProposing,
isSigning,
isExecuting,
error,
clearError,
createWallet,
proposeTx,
signTx,
executeTx,
fetchPendingTxs,
} = useMultisigStore();
const [activeTab, setActiveTab] = useState<'wallets' | 'pending'>('wallets');
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedWallet, setSelectedWallet] = useState<MultisigWalletInfo | null>(null);
const [showProposeModal, setShowProposeModal] = useState(false);
// Create wallet form
const [walletName, setWalletName] = useState('');
const [threshold, setThreshold] = useState(2);
const [owners, setOwners] = useState<string[]>(['', '']);
// Propose tx form
const [txTo, setTxTo] = useState('');
const [txAmount, setTxAmount] = useState('');
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
const userAddress = addresses[0]?.address;
const pendingTransactions = selectedWallet
? pendingTxs[selectedWallet.address] || []
: [];
useEffect(() => {
if (selectedWallet) {
fetchPendingTxs(selectedWallet.address);
}
}, [fetchPendingTxs, selectedWallet]);
const handleCreateWallet = async () => {
if (!walletName || owners.filter(Boolean).length < 2) return;
try {
const wallet = await createWallet(walletName, owners.filter(Boolean), threshold);
setShowCreateModal(false);
setWalletName('');
setThreshold(2);
setOwners(['', '']);
setSelectedWallet(wallet);
} catch {
// Error handled by store
}
};
const handleProposeTx = async () => {
if (!selectedWallet || !txTo || !txAmount) return;
const valueSats = (parseFloat(txAmount) * 100_000_000).toString();
try {
await proposeTx(selectedWallet.address, txTo, valueSats);
setShowProposeModal(false);
setTxTo('');
setTxAmount('');
fetchPendingTxs(selectedWallet.address);
} catch {
// Error handled by store
}
};
const handleSign = async (tx: PendingMultisigTx) => {
if (!selectedWallet) return;
try {
await signTx(selectedWallet.address, tx.txId);
fetchPendingTxs(selectedWallet.address);
} catch {
// Error handled by store
}
};
const handleExecute = async (tx: PendingMultisigTx) => {
if (!selectedWallet) return;
try {
await executeTx(selectedWallet.address, tx.txId);
fetchPendingTxs(selectedWallet.address);
} catch {
// Error handled by store
}
};
const copyAddress = (address: string) => {
navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
};
const addOwner = () => {
if (owners.length < 10) {
setOwners([...owners, '']);
}
};
const updateOwner = (index: number, value: string) => {
const newOwners = [...owners];
newOwners[index] = value;
setOwners(newOwners);
};
const removeOwner = (index: number) => {
if (owners.length > 2) {
setOwners(owners.filter((_, i) => i !== index));
}
};
const isLoading = isCreating || isProposing || isSigning || isExecuting;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Multi-Signature Wallets</h1>
<p className="text-gray-400 mt-1">Manage wallets requiring multiple signatures</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={18} />
Create Multisig
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('wallets')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'wallets'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Wallets ({wallets.length})
</button>
<button
onClick={() => setActiveTab('pending')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'pending'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Transactions ({pendingTransactions.length})
</button>
</div>
{/* Wallets Tab */}
{activeTab === 'wallets' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{wallets.map((wallet) => (
<div
key={wallet.address}
className={`bg-gray-900 rounded-xl p-6 border cursor-pointer transition-colors ${
selectedWallet?.address === wallet.address
? 'border-synor-500'
: 'border-gray-800 hover:border-gray-700'
}`}
onClick={() => {
setSelectedWallet(wallet);
setActiveTab('pending');
}}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-synor-600/20 rounded-lg">
<Users className="text-synor-400" size={20} />
</div>
<div>
<h3 className="font-semibold text-white">{wallet.name}</h3>
<p className="text-sm text-gray-400">
{wallet.threshold} of {wallet.owners.length} signatures required
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
copyAddress(wallet.address);
}}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
{copiedAddress === wallet.address ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={16} className="text-gray-400" />
)}
</button>
</div>
<div className="text-sm mb-4">
<p className="text-gray-500">Address</p>
<code className="text-white text-xs">
{wallet.address.slice(0, 20)}...{wallet.address.slice(-12)}
</code>
</div>
<div className="flex flex-wrap gap-1">
{wallet.owners.slice(0, 3).map((owner, i) => (
<span
key={i}
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
title={owner}
>
{owner.slice(0, 8)}...
</span>
))}
{wallet.owners.length > 3 && (
<span className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded">
+{wallet.owners.length - 3} more
</span>
)}
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-800">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedWallet(wallet);
setShowProposeModal(true);
}}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white text-sm font-medium transition-colors"
>
<Send size={14} />
Propose TX
</button>
</div>
</div>
))}
{wallets.length === 0 && !isLoading && (
<div className="col-span-2 text-center py-12 text-gray-500">
No multisig wallets yet. Create one to get started.
</div>
)}
</div>
)}
{/* Pending Tab */}
{activeTab === 'pending' && (
<div className="space-y-4">
{selectedWallet && (
<div className="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-800">
<div className="flex items-center gap-3">
<Shield className="text-synor-400" size={20} />
<span className="text-white font-medium">{selectedWallet.name}</span>
</div>
<button
onClick={() => fetchPendingTxs(selectedWallet.address)}
disabled={isLoading}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<RefreshCw
size={16}
className={`text-gray-400 ${isLoading ? 'animate-spin' : ''}`}
/>
</button>
</div>
)}
{pendingTransactions.map((tx) => (
<div
key={tx.txId}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-semibold text-white">
Send {(parseFloat(tx.value) / 100_000_000).toFixed(4)} SYN
</h3>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-yellow-600/20 text-yellow-400 text-xs rounded-full flex items-center gap-1">
<Clock size={12} />
{tx.signatures.length} / {tx.threshold}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div>
<p className="text-gray-500">To</p>
<code className="text-white text-xs">
{tx.to.slice(0, 12)}...{tx.to.slice(-8)}
</code>
</div>
<div>
<p className="text-gray-500">Proposed By</p>
<code className="text-white text-xs">{tx.proposer.slice(0, 12)}...</code>
</div>
</div>
<div className="mb-4">
<p className="text-gray-500 text-sm mb-2">Signatures</p>
<div className="flex flex-wrap gap-1">
{tx.signatures.map((sig, i) => (
<span
key={i}
className="px-2 py-0.5 bg-green-600/20 text-green-400 text-xs rounded flex items-center gap-1"
>
<Check size={10} />
{sig.slice(0, 8)}...
</span>
))}
</div>
</div>
<div className="flex gap-2">
{!tx.signatures.includes(userAddress || '') && (
<button
onClick={() => handleSign(tx)}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<Key size={16} />
Sign
</button>
)}
{tx.signatures.length >= tx.threshold && (
<button
onClick={() => handleExecute(tx)}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<Send size={16} />
Execute
</button>
)}
</div>
</div>
))}
{pendingTransactions.length === 0 && !isLoading && (
<div className="text-center py-12 text-gray-500">
{selectedWallet
? 'No pending transactions'
: 'Select a wallet to view pending transactions'}
</div>
)}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-800 max-h-[80vh] overflow-y-auto">
<h2 className="text-xl font-bold text-white mb-4">Create Multisig Wallet</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Wallet Name</label>
<input
type="text"
value={walletName}
onChange={(e) => setWalletName(e.target.value)}
placeholder="e.g., Company Treasury"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Threshold ({threshold} of {owners.filter(Boolean).length})
</label>
<input
type="range"
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value))}
min={1}
max={Math.max(2, owners.filter(Boolean).length)}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Number of signatures required to execute a transaction
</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm text-gray-400">Owners</label>
<button
onClick={addOwner}
className="text-sm text-synor-400 hover:text-synor-300"
>
+ Add Owner
</button>
</div>
<div className="space-y-2">
{owners.map((owner, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={owner}
onChange={(e) => updateOwner(index, e.target.value)}
placeholder={`Owner ${index + 1} address`}
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
{owners.length > 2 && (
<button
onClick={() => removeOwner(index)}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 rounded-lg text-red-400 transition-colors"
>
×
</button>
)}
</div>
))}
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setWalletName('');
setThreshold(2);
setOwners(['', '']);
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleCreateWallet}
disabled={!walletName || owners.filter(Boolean).length < 2 || isCreating}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Create Wallet'}
</button>
</div>
</div>
</div>
)}
{/* Propose TX Modal */}
{showProposeModal && selectedWallet && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-800">
<h2 className="text-xl font-bold text-white mb-4">Propose Transaction</h2>
<p className="text-sm text-gray-400 mb-4">From: {selectedWallet.name}</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Recipient</label>
<input
type="text"
value={txTo}
onChange={(e) => setTxTo(e.target.value)}
placeholder="synor1..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
value={txAmount}
onChange={(e) => setTxAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => {
setShowProposeModal(false);
setTxTo('');
setTxAmount('');
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleProposeTx}
disabled={!txTo || !txAmount || isProposing}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isProposing ? 'Proposing...' : 'Propose'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,921 @@
import { useState, useEffect } from 'react';
import {
Image,
Plus,
Send,
RefreshCw,
Trash2,
Copy,
Flame,
Sparkles,
ChevronDown,
ChevronRight,
ExternalLink,
Grid3X3,
Layers,
} from 'lucide-react';
import {
useNftsStore,
formatRoyalty,
OwnedNft,
} from '../../store/nfts';
import { useNodeStore } from '../../store/node';
import { useWalletStore } from '../../store/wallet';
import { truncateAddress } from '../../store/contracts';
type TabType = 'gallery' | 'collections' | 'create' | 'mint' | 'transfer';
export default function NftsDashboard() {
const {
trackedCollections,
ownedNfts,
isCreatingCollection,
isMinting,
isTransferring,
isBurning,
isLoadingOwned,
error,
clearError,
createCollection,
mintNft,
transferNft,
burnNft,
refreshOwnedNfts,
removeTrackedCollection,
addTrackedCollection,
getCollectionInfo,
} = useNftsStore();
const nodeStatus = useNodeStore((state) => state.status);
const addresses = useWalletStore((state) => state.addresses);
const currentAddress = addresses[0]?.address || '';
// View state
const [activeTab, setActiveTab] = useState<TabType>('gallery');
const [expandedCollections, setExpandedCollections] = useState<Set<string>>(
new Set()
);
const [selectedNft, setSelectedNft] = useState<OwnedNft | null>(null);
// Create collection form state
const [createForm, setCreateForm] = useState({
name: '',
symbol: '',
baseUri: '',
maxSupply: '0',
royaltyBps: '250',
soulbound: false,
});
// Mint form state
const [mintForm, setMintForm] = useState({
collectionAddress: '',
to: '',
tokenUri: '',
attributes: '',
});
// Transfer form state
const [transferForm, setTransferForm] = useState({
collectionAddress: '',
tokenId: '',
to: '',
});
// Import collection form
const [importAddress, setImportAddress] = useState('');
// Refresh owned NFTs on mount and when address changes
useEffect(() => {
if (currentAddress && nodeStatus.isConnected) {
refreshOwnedNfts(currentAddress);
}
}, [currentAddress, nodeStatus.isConnected, refreshOwnedNfts]);
// Set default mint recipient
useEffect(() => {
if (currentAddress && !mintForm.to) {
setMintForm((f) => ({ ...f, to: currentAddress }));
}
}, [currentAddress, mintForm.to]);
// Clear error on tab change
useEffect(() => {
clearError();
}, [activeTab, clearError]);
const handleCreateCollection = async () => {
if (!createForm.name || !createForm.symbol) return;
try {
await createCollection({
name: createForm.name,
symbol: createForm.symbol,
baseUri: createForm.baseUri,
maxSupply: parseInt(createForm.maxSupply) || 0,
royaltyBps: parseInt(createForm.royaltyBps) || 0,
soulbound: createForm.soulbound,
});
// Clear form
setCreateForm({
name: '',
symbol: '',
baseUri: '',
maxSupply: '0',
royaltyBps: '250',
soulbound: false,
});
// Switch to collections tab
setActiveTab('collections');
} catch {
// Error handled by store
}
};
const handleMint = async () => {
if (!mintForm.collectionAddress || !mintForm.to || !mintForm.tokenUri) return;
try {
await mintNft({
collectionAddress: mintForm.collectionAddress,
to: mintForm.to,
tokenUri: mintForm.tokenUri,
attributes: mintForm.attributes || undefined,
});
// Clear form except collection address
setMintForm({
...mintForm,
tokenUri: '',
attributes: '',
});
// Refresh gallery
if (currentAddress) {
refreshOwnedNfts(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleTransfer = async () => {
if (!transferForm.collectionAddress || !transferForm.tokenId || !transferForm.to)
return;
try {
await transferNft(
transferForm.collectionAddress,
transferForm.tokenId,
transferForm.to
);
// Clear form and refresh
setTransferForm({
collectionAddress: '',
tokenId: '',
to: '',
});
if (currentAddress) {
refreshOwnedNfts(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleBurn = async (nft: OwnedNft) => {
if (!confirm(`Are you sure you want to burn NFT #${nft.tokenId}? This cannot be undone.`))
return;
try {
await burnNft(nft.collectionAddress, nft.tokenId);
setSelectedNft(null);
if (currentAddress) {
refreshOwnedNfts(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleImportCollection = async () => {
if (!importAddress) return;
try {
const info = await getCollectionInfo(importAddress);
addTrackedCollection({
address: importAddress,
name: info.name,
symbol: info.symbol,
isCreatedByUser: false,
addedAt: Date.now(),
});
setImportAddress('');
} catch {
// Error handled by store
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const toggleCollectionExpanded = (address: string) => {
const newExpanded = new Set(expandedCollections);
if (newExpanded.has(address)) {
newExpanded.delete(address);
} else {
newExpanded.add(address);
}
setExpandedCollections(newExpanded);
};
// Group NFTs by collection for display
const nftsByCollection = ownedNfts.reduce(
(acc, nft) => {
if (!acc[nft.collectionAddress]) {
acc[nft.collectionAddress] = [];
}
acc[nft.collectionAddress].push(nft);
return acc;
},
{} as Record<string, OwnedNft[]>
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Image size={28} />
NFTs
</h1>
<p className="text-gray-400 mt-1">
Create, collect, and manage non-fungible tokens
</p>
</div>
<button
onClick={() => currentAddress && refreshOwnedNfts(currentAddress)}
disabled={isLoadingOwned || !nodeStatus.isConnected}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white transition-colors disabled:opacity-50"
>
<RefreshCw size={18} className={isLoadingOwned ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Not connected warning */}
{!nodeStatus.isConnected && (
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
Please connect to a node to manage NFTs
</div>
)}
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
<Trash2 size={18} />
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{[
{ id: 'gallery' as TabType, icon: Grid3X3, label: 'Gallery' },
{ id: 'collections' as TabType, icon: Layers, label: 'Collections' },
{ id: 'create' as TabType, icon: Plus, label: 'Create Collection' },
{ id: 'mint' as TabType, icon: Sparkles, label: 'Mint' },
{ id: 'transfer' as TabType, icon: Send, label: 'Transfer' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === id
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
<Icon size={18} />
{label}
</button>
))}
</div>
{/* Tab Content */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
{/* Gallery Tab */}
{activeTab === 'gallery' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white">Your NFTs</h3>
{ownedNfts.length === 0 ? (
<div className="text-center py-12">
<Image size={64} className="mx-auto text-gray-600 mb-4" />
<p className="text-gray-500">No NFTs in your collection</p>
<p className="text-gray-600 text-sm mt-1">
Create a collection and mint some NFTs to get started
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{ownedNfts.map((nft) => (
<div
key={`${nft.collectionAddress}-${nft.tokenId}`}
onClick={() => setSelectedNft(nft)}
className="group relative rounded-lg overflow-hidden bg-gray-800 border border-gray-700 hover:border-synor-500 transition-colors cursor-pointer"
>
{/* NFT Image */}
<div className="aspect-square bg-gray-700 flex items-center justify-center">
{nft.image ? (
<img
src={nft.image}
alt={nft.name || `#${nft.tokenId}`}
className="w-full h-full object-cover"
/>
) : (
<Image size={48} className="text-gray-600" />
)}
</div>
{/* NFT Info */}
<div className="p-3">
<p className="text-white font-medium truncate">
{nft.name || `#${nft.tokenId}`}
</p>
<p className="text-gray-500 text-sm truncate">
{nft.collectionName}
</p>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-white font-medium">View Details</span>
</div>
</div>
))}
</div>
)}
{/* Selected NFT Modal */}
{selectedNft && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Image */}
<div className="aspect-square bg-gray-800 flex items-center justify-center">
{selectedNft.image ? (
<img
src={selectedNft.image}
alt={selectedNft.name || `#${selectedNft.tokenId}`}
className="w-full h-full object-contain"
/>
) : (
<Image size={96} className="text-gray-600" />
)}
</div>
{/* Details */}
<div className="p-6 space-y-4">
<div>
<h3 className="text-xl font-bold text-white">
{selectedNft.name || `Token #${selectedNft.tokenId}`}
</h3>
<p className="text-synor-400">
{selectedNft.collectionName} ({selectedNft.collectionSymbol})
</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Token ID</span>
<span className="text-white">{selectedNft.tokenId}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Collection</span>
<span className="text-gray-300 font-mono text-xs">
{truncateAddress(selectedNft.collectionAddress)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4">
<button
onClick={() => {
setTransferForm({
collectionAddress: selectedNft.collectionAddress,
tokenId: selectedNft.tokenId,
to: '',
});
setSelectedNft(null);
setActiveTab('transfer');
}}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white"
>
<Send size={16} />
Transfer
</button>
<button
onClick={() => handleBurn(selectedNft)}
disabled={isBurning}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600/20 hover:bg-red-600/30 text-red-400"
>
{isBurning ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Flame size={16} />
)}
Burn
</button>
<button
onClick={() => setSelectedNft(null)}
className="px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* Collections Tab */}
{activeTab === 'collections' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Your Collections</h3>
</div>
{/* Import collection */}
<div className="flex gap-2">
<input
type="text"
value={importAddress}
onChange={(e) => setImportAddress(e.target.value)}
placeholder="Import collection by contract address..."
className="flex-1 px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
<button
onClick={handleImportCollection}
disabled={!importAddress || !nodeStatus.isConnected}
className="px-4 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white transition-colors disabled:opacity-50"
>
Import
</button>
</div>
{trackedCollections.length === 0 ? (
<div className="text-center py-8">
<Layers size={48} className="mx-auto text-gray-600 mb-4" />
<p className="text-gray-500">No collections tracked</p>
<p className="text-gray-600 text-sm mt-1">
Create a new collection or import an existing one
</p>
</div>
) : (
<div className="space-y-2">
{trackedCollections.map((collection) => {
const collectionNfts = nftsByCollection[collection.address] || [];
return (
<div
key={collection.address}
className="p-4 rounded-lg bg-gray-800 border border-gray-700"
>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleCollectionExpanded(collection.address)}
>
<div className="flex items-center gap-3">
{expandedCollections.has(collection.address) ? (
<ChevronDown size={18} className="text-gray-400" />
) : (
<ChevronRight size={18} className="text-gray-400" />
)}
<div className="w-10 h-10 rounded-lg bg-synor-600/30 flex items-center justify-center">
<Layers size={20} className="text-synor-400" />
</div>
<div>
<p className="text-white font-medium">{collection.name}</p>
<p className="text-gray-500 text-sm">{collection.symbol}</p>
</div>
</div>
<div className="text-right">
<p className="text-white">{collectionNfts.length} owned</p>
<p className="text-gray-500 text-sm">
{truncateAddress(collection.address)}
</p>
</div>
</div>
{expandedCollections.has(collection.address) && (
<div className="mt-4 pt-4 border-t border-gray-700 space-y-3">
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(collection.address);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-gray-700 text-gray-300 hover:text-white text-sm"
>
<Copy size={14} />
Copy Address
</button>
<button
onClick={(e) => {
e.stopPropagation();
setMintForm({
...mintForm,
collectionAddress: collection.address,
});
setActiveTab('mint');
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-synor-600 text-white text-sm"
>
<Sparkles size={14} />
Mint
</button>
<button
onClick={(e) => {
e.stopPropagation();
removeTrackedCollection(collection.address);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-red-900/30 text-red-400 hover:text-red-300 text-sm"
>
<Trash2 size={14} />
Remove
</button>
</div>
{/* Show NFTs in this collection */}
{collectionNfts.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{collectionNfts.slice(0, 8).map((nft) => (
<div
key={nft.tokenId}
onClick={() => setSelectedNft(nft)}
className="aspect-square rounded bg-gray-700 flex items-center justify-center cursor-pointer hover:ring-2 hover:ring-synor-500"
>
{nft.image ? (
<img
src={nft.image}
alt={nft.name || `#${nft.tokenId}`}
className="w-full h-full object-cover rounded"
/>
) : (
<span className="text-gray-500 text-xs">
#{nft.tokenId}
</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* Create Collection Tab */}
{activeTab === 'create' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">Create NFT Collection</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Collection Name *
</label>
<input
type="text"
value={createForm.name}
onChange={(e) =>
setCreateForm({ ...createForm, name: e.target.value })
}
placeholder="My NFT Collection"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Symbol *</label>
<input
type="text"
value={createForm.symbol}
onChange={(e) =>
setCreateForm({
...createForm,
symbol: e.target.value.toUpperCase(),
})
}
placeholder="MNFT"
maxLength={8}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 uppercase"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Base URI (metadata server URL)
</label>
<input
type="text"
value={createForm.baseUri}
onChange={(e) =>
setCreateForm({ ...createForm, baseUri: e.target.value })
}
placeholder="https://api.example.com/metadata/"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">
Token URI will be baseUri + tokenId
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Max Supply (0 = unlimited)
</label>
<input
type="number"
value={createForm.maxSupply}
onChange={(e) =>
setCreateForm({ ...createForm, maxSupply: e.target.value })
}
min="0"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Royalty (basis points)
</label>
<input
type="number"
value={createForm.royaltyBps}
onChange={(e) =>
setCreateForm({ ...createForm, royaltyBps: e.target.value })
}
min="0"
max="10000"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">
{formatRoyalty(parseInt(createForm.royaltyBps) || 0)} (250 = 2.5%)
</p>
</div>
</div>
<div className="p-4 rounded-lg bg-gray-800">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={createForm.soulbound}
onChange={(e) =>
setCreateForm({ ...createForm, soulbound: e.target.checked })
}
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
/>
<div>
<span className="text-gray-300">Soulbound (non-transferable)</span>
<p className="text-xs text-gray-500">
Tokens cannot be transferred after minting
</p>
</div>
</label>
</div>
<button
onClick={handleCreateCollection}
disabled={
isCreatingCollection ||
!createForm.name ||
!createForm.symbol ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCreatingCollection ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Plus size={18} />
)}
Create Collection
</button>
</div>
)}
{/* Mint Tab */}
{activeTab === 'mint' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">Mint NFT</h3>
<div>
<label className="block text-sm text-gray-400 mb-2">Collection *</label>
<select
value={mintForm.collectionAddress}
onChange={(e) =>
setMintForm({ ...mintForm, collectionAddress: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select collection...</option>
{trackedCollections
.filter((c) => c.isCreatedByUser)
.map((collection) => (
<option key={collection.address} value={collection.address}>
{collection.symbol} - {collection.name}
</option>
))}
</select>
{trackedCollections.filter((c) => c.isCreatedByUser).length === 0 && (
<p className="text-xs text-gray-500 mt-1">
Create a collection first to mint NFTs
</p>
)}
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Recipient *</label>
<input
type="text"
value={mintForm.to}
onChange={(e) => setMintForm({ ...mintForm, to: e.target.value })}
placeholder="synor1..."
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Token URI *</label>
<input
type="text"
value={mintForm.tokenUri}
onChange={(e) =>
setMintForm({ ...mintForm, tokenUri: e.target.value })
}
placeholder="https://api.example.com/metadata/1"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">
URL to JSON metadata for this NFT
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Attributes (JSON, optional)
</label>
<textarea
value={mintForm.attributes}
onChange={(e) =>
setMintForm({ ...mintForm, attributes: e.target.value })
}
placeholder='[{"trait_type": "Color", "value": "Blue"}]'
rows={3}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<button
onClick={handleMint}
disabled={
isMinting ||
!mintForm.collectionAddress ||
!mintForm.to ||
!mintForm.tokenUri ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isMinting ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Sparkles size={18} />
)}
Mint NFT
</button>
</div>
)}
{/* Transfer Tab */}
{activeTab === 'transfer' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">Transfer NFT</h3>
<div>
<label className="block text-sm text-gray-400 mb-2">Collection *</label>
<select
value={transferForm.collectionAddress}
onChange={(e) =>
setTransferForm({ ...transferForm, collectionAddress: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select collection...</option>
{Object.keys(nftsByCollection).map((address) => {
const collection = trackedCollections.find(
(c) => c.address === address
);
return (
<option key={address} value={address}>
{collection?.name || truncateAddress(address)} (
{nftsByCollection[address].length} owned)
</option>
);
})}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Token ID *</label>
<select
value={transferForm.tokenId}
onChange={(e) =>
setTransferForm({ ...transferForm, tokenId: e.target.value })
}
disabled={!transferForm.collectionAddress}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500 disabled:opacity-50"
>
<option value="">Select NFT...</option>
{(nftsByCollection[transferForm.collectionAddress] || []).map(
(nft) => (
<option key={nft.tokenId} value={nft.tokenId}>
#{nft.tokenId} - {nft.name || 'Untitled'}
</option>
)
)}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Recipient Address *
</label>
<input
type="text"
value={transferForm.to}
onChange={(e) =>
setTransferForm({ ...transferForm, to: e.target.value })
}
placeholder="synor1..."
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono"
/>
</div>
<button
onClick={handleTransfer}
disabled={
isTransferring ||
!transferForm.collectionAddress ||
!transferForm.tokenId ||
!transferForm.to ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTransferring ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
Transfer
</button>
</div>
)}
</div>
{/* Help Section */}
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-400 mb-3 flex items-center gap-2">
<ExternalLink size={16} />
About NFTs
</h3>
<ul className="space-y-2 text-sm text-gray-500">
<li> NFTs are unique digital assets stored on the blockchain</li>
<li> Create a collection first, then mint individual tokens</li>
<li> Royalties are paid to creators on secondary sales</li>
<li> Soulbound tokens cannot be transferred after minting</li>
<li> Token metadata is stored at the URI you specify</li>
</ul>
</div>
</div>
);
}

View file

@ -0,0 +1,342 @@
import { useEffect, useState } from 'react';
import {
Server,
Wifi,
WifiOff,
Users,
Blocks,
RefreshCw,
Power,
PowerOff,
Globe,
HardDrive,
} from 'lucide-react';
import { useNodeStore, ConnectionMode } from '../../store/node';
export default function NodeDashboard() {
const {
status,
syncProgress,
peers,
lastExternalUrl,
lastNetwork,
connectExternal,
startEmbeddedNode,
disconnect,
refreshStatus,
refreshPeers,
setupEventListeners,
cleanupEventListeners,
} = useNodeStore();
const [isConnecting, setIsConnecting] = useState(false);
const [externalUrl, setExternalUrl] = useState(lastExternalUrl);
const [embeddedNetwork, setEmbeddedNetwork] = useState(lastNetwork);
const [error, setError] = useState<string | null>(null);
// Setup event listeners on mount
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
// Refresh status periodically when connected
useEffect(() => {
if (!status.isConnected) return;
const interval = setInterval(() => {
refreshStatus();
refreshPeers();
}, 5000);
return () => clearInterval(interval);
}, [status.isConnected, refreshStatus, refreshPeers]);
const handleConnectExternal = async () => {
setIsConnecting(true);
setError(null);
try {
await connectExternal(externalUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsConnecting(false);
}
};
const handleStartEmbedded = async () => {
setIsConnecting(true);
setError(null);
try {
await startEmbeddedNode({ network: embeddedNetwork });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start node');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
setError(null);
try {
await disconnect();
} catch (err) {
setError(err instanceof Error ? err.message : 'Disconnect failed');
}
};
const getModeLabel = (mode: ConnectionMode): string => {
switch (mode.type) {
case 'disconnected':
return 'Disconnected';
case 'external':
return `External: ${mode.http_url}`;
case 'embedded':
return `Embedded: ${mode.network}`;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Server size={28} />
Node
</h1>
<p className="text-gray-400 mt-1">
Manage your connection to the Synor network
</p>
</div>
{status.isConnected && (
<button
onClick={handleDisconnect}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<PowerOff size={18} />
Disconnect
</button>
)}
</div>
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400">
{error}
</div>
)}
{/* Connection Status Card */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-4">
{status.isConnected ? (
<div className="p-3 rounded-full bg-green-500/20">
<Wifi size={24} className="text-green-400" />
</div>
) : (
<div className="p-3 rounded-full bg-gray-700">
<WifiOff size={24} className="text-gray-400" />
</div>
)}
<div>
<h2 className="text-lg font-semibold text-white">
{status.isConnected ? 'Connected' : 'Not Connected'}
</h2>
<p className="text-sm text-gray-400">{getModeLabel(status.mode)}</p>
</div>
</div>
{/* Sync progress bar */}
{status.isConnected && status.isSyncing && syncProgress && (
<div className="mt-4">
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>Syncing...</span>
<span>{syncProgress.progress.toFixed(1)}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-synor-500 transition-all duration-300"
style={{ width: `${syncProgress.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{syncProgress.status}</p>
</div>
)}
</div>
{/* Stats Grid (when connected) */}
{status.isConnected && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Blocks size={20} />}
label="Block Height"
value={status.blockHeight.toLocaleString()}
/>
<StatCard
icon={<HardDrive size={20} />}
label="Blue Score"
value={status.blueScore.toLocaleString()}
/>
<StatCard
icon={<Users size={20} />}
label="Peers"
value={status.peerCount.toString()}
/>
<StatCard
icon={<Globe size={20} />}
label="Network"
value={status.network || 'Unknown'}
/>
</div>
)}
{/* Connection Options (when disconnected) */}
{!status.isConnected && (
<div className="grid gap-6 md:grid-cols-2">
{/* External Node */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<Globe size={24} className="text-synor-400" />
<h3 className="text-lg font-semibold text-white">External Node</h3>
</div>
<p className="text-sm text-gray-400 mb-4">
Connect to a remote Synor node via RPC. No local resources required.
</p>
<div className="space-y-3">
<input
type="text"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="http://localhost:16110"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<button
onClick={handleConnectExternal}
disabled={isConnecting || !externalUrl}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Power size={18} />
)}
Connect
</button>
</div>
</div>
{/* Embedded Node */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<Server size={24} className="text-synor-400" />
<h3 className="text-lg font-semibold text-white">Embedded Node</h3>
</div>
<p className="text-sm text-gray-400 mb-4">
Run a full node inside the wallet. Requires more resources but provides maximum security.
</p>
<div className="space-y-3">
<select
value={embeddedNetwork}
onChange={(e) => setEmbeddedNetwork(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="mainnet">Mainnet</option>
<option value="testnet">Testnet</option>
<option value="devnet">Devnet</option>
</select>
<button
onClick={handleStartEmbedded}
disabled={isConnecting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-gray-700 hover:bg-gray-600 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Power size={18} />
)}
Start Node
</button>
<p className="text-xs text-gray-500 text-center">
Requires embedded-node feature enabled
</p>
</div>
</div>
</div>
)}
{/* Peers List (when connected) */}
{status.isConnected && peers.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Users size={20} />
Connected Peers
</h3>
<button
onClick={refreshPeers}
className="p-2 rounded-lg hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
>
<RefreshCw size={18} />
</button>
</div>
<div className="space-y-2">
{peers.slice(0, 10).map((peer) => (
<div
key={peer.peerId}
className="flex items-center justify-between p-3 rounded-lg bg-gray-800"
>
<div className="flex items-center gap-3">
<div
className={`w-2 h-2 rounded-full ${
peer.status === 'connected' ? 'bg-green-400' : 'bg-gray-500'
}`}
/>
<div>
<p className="text-sm text-white font-mono">
{peer.peerId.slice(0, 16)}...
</p>
<p className="text-xs text-gray-500">{peer.address}</p>
</div>
</div>
<div className="text-right">
<p className="text-xs text-gray-400">{peer.direction}</p>
{peer.latencyMs !== undefined && (
<p className="text-xs text-gray-500">{peer.latencyMs}ms</p>
)}
</div>
</div>
))}
</div>
{peers.length > 10 && (
<p className="text-xs text-gray-500 text-center mt-3">
And {peers.length - 10} more peers...
</p>
)}
</div>
)}
</div>
);
}
// Stat card component
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-2 text-gray-400 mb-2">
{icon}
<span className="text-sm">{label}</span>
</div>
<p className="text-xl font-bold text-white">{value}</p>
</div>
);
}

View file

@ -0,0 +1,112 @@
import { Puzzle, Info, AlertTriangle, Download, Trash2, Settings } from 'lucide-react';
interface Plugin {
id: string;
name: string;
description: string;
version: string;
author: string;
enabled: boolean;
}
export default function PluginsDashboard() {
const plugins: Plugin[] = [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Puzzle className="text-synor-400" />
Plugin System
</h1>
<p className="text-gray-400 mt-1">Third-party extensions and integrations</p>
</div>
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
<Download size={18} />
Browse Plugins
</button>
</div>
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertTriangle className="text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-200">Coming Soon</p>
<p className="text-sm text-yellow-200/70">
The plugin system will allow third-party developers to extend wallet
functionality with custom features and integrations.
</p>
</div>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Installed Plugins</h3>
{plugins.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Puzzle size={32} className="mx-auto mb-2 opacity-50" />
<p>No plugins installed</p>
<p className="text-sm mt-1">Plugin marketplace coming soon</p>
</div>
) : (
<div className="space-y-3">
{plugins.map((plugin) => (
<div key={plugin.id} className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
<div>
<p className="font-medium">{plugin.name}</p>
<p className="text-sm text-gray-500">{plugin.description}</p>
<p className="text-xs text-gray-600">v{plugin.version} by {plugin.author}</p>
</div>
<div className="flex items-center gap-2">
<button className="p-2 bg-gray-700 rounded-lg">
<Settings size={16} />
</button>
<button className="p-2 bg-gray-700 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3">Plugin Categories</h3>
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">DeFi</p>
<p className="text-xs text-gray-500">Yield, lending, swaps</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">NFT</p>
<p className="text-xs text-gray-500">Galleries, marketplaces</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">Analytics</p>
<p className="text-xs text-gray-500">Charts, tracking</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">Privacy</p>
<p className="text-xs text-gray-500">Mixing, stealth</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">Gaming</p>
<p className="text-xs text-gray-500">Web3 games</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="font-medium text-synor-400">Social</p>
<p className="text-xs text-gray-500">Messaging, identity</p>
</div>
</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Plugins run in a sandboxed environment with limited permissions. Always verify
plugin sources before installation.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react';
import { PieChart, Info, AlertCircle, Download, RefreshCw, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
import { usePortfolioStore, formatUSD, formatAmount } from '../../store/portfolio';
export default function PortfolioDashboard() {
const {
summary,
holdings,
taxReport,
isLoading,
error,
loadSummary,
loadHoldings,
loadTaxReport,
exportTaxReport,
clearError,
} = usePortfolioStore();
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
useEffect(() => {
loadSummary();
loadHoldings();
}, [loadSummary, loadHoldings]);
const handleLoadTaxReport = async () => {
await loadTaxReport(selectedYear);
};
const handleExportTaxReport = async () => {
try {
const data = await exportTaxReport(selectedYear, 'csv');
// Create a download link
const blob = new Blob([data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tax-report-${selectedYear}.csv`;
a.click();
window.URL.revokeObjectURL(url);
} catch {
// Error handled by store
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<PieChart className="text-synor-400" />
Portfolio Analytics
</h1>
<p className="text-gray-400 mt-1">P&L tracking and tax reports</p>
</div>
<div className="flex gap-2">
<button
onClick={() => { loadSummary(); loadHoldings(); }}
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
disabled={isLoading}
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button
onClick={handleExportTaxReport}
className="px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 hover:bg-gray-700"
>
<Download size={18} />
Export CSV
</button>
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="text-red-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-red-200">Error</p>
<p className="text-sm text-red-200/70">{error}</p>
</div>
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
</div>
)}
{/* Summary Stats */}
{summary && (
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Total Value</p>
<p className="text-2xl font-bold">{formatUSD(summary.totalValueUsd)}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">24h Change</p>
<p className={`text-2xl font-bold flex items-center gap-1 ${
summary.dayChangePercent >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{summary.dayChangePercent >= 0 ? <TrendingUp size={20} /> : <TrendingDown size={20} />}
{summary.dayChangePercent >= 0 ? '+' : ''}{summary.dayChangePercent.toFixed(2)}%
</p>
<p className={`text-xs ${summary.dayChangeUsd >= 0 ? 'text-green-400/70' : 'text-red-400/70'}`}>
{formatUSD(summary.dayChangeUsd)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Total P&L</p>
<p className={`text-2xl font-bold ${
summary.totalPnlUsd >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{summary.totalPnlUsd >= 0 ? '+' : ''}{formatUSD(summary.totalPnlUsd)}
</p>
<p className={`text-xs ${summary.totalPnlPercent >= 0 ? 'text-green-400/70' : 'text-red-400/70'}`}>
{summary.totalPnlPercent >= 0 ? '+' : ''}{summary.totalPnlPercent.toFixed(2)}%
</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Cost Basis</p>
<p className="text-2xl font-bold">{formatUSD(summary.totalCostBasisUsd)}</p>
</div>
</div>
)}
{/* Holdings */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Asset Holdings ({holdings.length})</h3>
{holdings.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No holdings found</p>
) : (
<div className="space-y-2">
{holdings.map((holding) => (
<div key={holding.asset} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-synor-600 rounded-full flex items-center justify-center font-bold">
{holding.symbol.substring(0, 2)}
</div>
<div>
<p className="font-medium">{holding.asset}</p>
<p className="text-sm text-gray-500">{holding.balanceFormatted}</p>
</div>
</div>
<div className="text-right">
<p className="font-medium">{formatUSD(holding.valueUsd)}</p>
<p className={`text-sm ${holding.pnlPercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{holding.pnlPercent >= 0 ? '+' : ''}{holding.pnlPercent.toFixed(2)}%
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-400">Allocation</p>
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-synor-500 rounded-full"
style={{ width: `${holding.allocationPercent}%` }}
/>
</div>
<span className="text-sm">{holding.allocationPercent.toFixed(1)}%</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Tax Report Section */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium">Tax Report</h3>
<div className="flex items-center gap-2">
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-3 py-1 bg-gray-800 border border-gray-700 rounded-lg text-sm"
>
{[2026, 2025, 2024, 2023].map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
<button
onClick={handleLoadTaxReport}
className="px-3 py-1 bg-synor-600 rounded-lg text-sm hover:bg-synor-700"
disabled={isLoading}
>
{isLoading ? <Loader2 className="animate-spin" size={14} /> : 'Load'}
</button>
</div>
</div>
{taxReport.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-gray-500 mb-2">Select a year and click Load to generate your tax report</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-6 text-xs text-gray-500 p-2">
<span>Date</span>
<span>Type</span>
<span>Asset</span>
<span>Amount</span>
<span>Total</span>
<span>Gain/Loss</span>
</div>
{taxReport.slice(0, 10).map((tx) => (
<div key={tx.id} className="grid grid-cols-6 text-sm p-2 bg-gray-800 rounded items-center">
<span>{new Date(tx.timestamp * 1000).toLocaleDateString()}</span>
<span className={
tx.txType === 'sell' ? 'text-red-400' :
tx.txType === 'buy' ? 'text-green-400' :
'text-yellow-400'
}>
{tx.txType.toUpperCase()}
</span>
<span>{tx.asset}</span>
<span>{formatAmount(tx.amount)}</span>
<span>{formatUSD(tx.totalUsd)}</span>
<span className={tx.gainLossUsd && tx.gainLossUsd >= 0 ? 'text-green-400' : 'text-red-400'}>
{tx.gainLossUsd !== undefined ? formatUSD(tx.gainLossUsd) : '--'}
{tx.isLongTerm && <span className="text-xs ml-1">(LT)</span>}
</span>
</div>
))}
{taxReport.length > 10 && (
<p className="text-center text-sm text-gray-500 pt-2">
Showing 10 of {taxReport.length} transactions. Export to see all.
</p>
)}
</div>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Portfolio analytics helps you understand your investment performance and simplifies
tax reporting with exportable transaction history. LT = Long-term capital gains.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,340 @@
import { useState, useEffect } from 'react';
import {
Shield,
EyeOff,
Send,
ArrowDownToLine,
ArrowUpFromLine,
RefreshCw,
AlertCircle,
Plus,
Copy,
Check,
} from 'lucide-react';
import { usePrivacyStore, RING_SIZES } from '../../store/privacy';
export default function PrivacyDashboard() {
const {
confidentialBalance,
isLoading,
isSending,
error,
clearError,
fetchBalance,
sendPrivate,
generateStealthAddress,
shield,
unshield,
} = usePrivacyStore();
const [activeTab, setActiveTab] = useState<'send' | 'shield' | 'unshield'>('send');
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [useStealthAddress, setUseStealthAddress] = useState(true);
const [useRingSignature, setUseRingSignature] = useState(true);
const [ringSize, setRingSize] = useState(5);
const [stealthAddress, setStealthAddress] = useState('');
const [copiedAddress, setCopiedAddress] = useState(false);
useEffect(() => {
fetchBalance();
}, [fetchBalance]);
const handleSend = async () => {
if (!recipient || !amount) return;
await sendPrivate({
to: recipient,
amount,
useStealthAddress,
useRingSignature,
ringSize: useRingSignature ? ringSize : undefined,
});
setRecipient('');
setAmount('');
fetchBalance();
};
const handleShield = async () => {
if (!amount) return;
await shield(amount);
setAmount('');
fetchBalance();
};
const handleUnshield = async () => {
if (!amount) return;
await unshield(amount);
setAmount('');
fetchBalance();
};
const handleGenerateStealth = async () => {
const address = await generateStealthAddress();
setStealthAddress(address);
};
const copyAddress = () => {
navigator.clipboard.writeText(stealthAddress);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Privacy Features</h1>
<p className="text-gray-400 mt-1">Confidential transactions and privacy tools</p>
</div>
<button
onClick={fetchBalance}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Confidential Balance */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Shield size={20} className="text-synor-400" />
<h2 className="text-lg font-semibold text-white">Confidential Balance</h2>
</div>
{confidentialBalance ? (
<>
<p className="text-3xl font-bold text-white mb-2">
{confidentialBalance.balance} SYN
</p>
<p className="text-sm text-gray-500">
{confidentialBalance.utxoCount} confidential UTXOs
</p>
<div className="mt-4 p-3 bg-gray-800 rounded-lg">
<p className="text-xs text-gray-500 mb-1">Commitment</p>
<code className="text-xs text-gray-400 break-all">
{confidentialBalance.commitment || 'N/A'}
</code>
</div>
</>
) : (
<p className="text-gray-500">No confidential balance</p>
)}
</div>
{/* Stealth Address Generator */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<EyeOff size={20} className="text-synor-400" />
<h2 className="text-lg font-semibold text-white">Stealth Address</h2>
</div>
<p className="text-sm text-gray-400 mb-4">
Generate a one-time address for receiving private payments. Each stealth address
can only be linked to your wallet by you.
</p>
{stealthAddress ? (
<div className="flex items-center gap-2">
<code className="flex-1 px-4 py-2 bg-gray-800 rounded-lg text-sm text-white font-mono truncate">
{stealthAddress}
</code>
<button
onClick={copyAddress}
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
{copiedAddress ? (
<Check size={18} className="text-green-400" />
) : (
<Copy size={18} className="text-gray-400" />
)}
</button>
</div>
) : (
<button
onClick={handleGenerateStealth}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
<Plus size={16} />
Generate Stealth Address
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="bg-gray-900 rounded-xl border border-gray-800">
<div className="flex border-b border-gray-800">
<button
onClick={() => setActiveTab('send')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'send'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<Send size={16} className="inline mr-2" />
Private Send
</button>
<button
onClick={() => setActiveTab('shield')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'shield'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<ArrowDownToLine size={16} className="inline mr-2" />
Shield
</button>
<button
onClick={() => setActiveTab('unshield')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'unshield'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<ArrowUpFromLine size={16} className="inline mr-2" />
Unshield
</button>
</div>
<div className="p-6">
{activeTab === 'send' && (
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Recipient Address</label>
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="synor1... or stealth address"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={useStealthAddress}
onChange={(e) => setUseStealthAddress(e.target.checked)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
<span className="text-sm text-gray-300">Use stealth address (unlinkable recipient)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={useRingSignature}
onChange={(e) => setUseRingSignature(e.target.checked)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
<span className="text-sm text-gray-300">Use ring signature (unlinkable sender)</span>
</label>
</div>
{useRingSignature && (
<div>
<label className="block text-sm text-gray-400 mb-1">Ring Size</label>
<select
value={ringSize}
onChange={(e) => setRingSize(Number(e.target.value))}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
{RING_SIZES.map((size) => (
<option key={size} value={size}>
{size} (hides among {size} possible senders)
</option>
))}
</select>
</div>
)}
<button
onClick={handleSend}
disabled={!recipient || !amount || isSending}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isSending ? 'Sending...' : 'Send Privately'}
</button>
</div>
)}
{activeTab === 'shield' && (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Shield your regular SYN tokens to convert them into confidential tokens.
The amount will be hidden on the blockchain using Pedersen commitments.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount to Shield (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleShield}
disabled={!amount || isSending}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<ArrowDownToLine size={18} />
{isSending ? 'Shielding...' : 'Shield Tokens'}
</button>
</div>
)}
{activeTab === 'unshield' && (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Unshield your confidential tokens to convert them back to regular SYN tokens.
The amount will become visible on the blockchain.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount to Unshield (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleUnshield}
disabled={!amount || isSending}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<ArrowUpFromLine size={18} />
{isSending ? 'Unshielding...' : 'Unshield Tokens'}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,405 @@
import { useState } from 'react';
import {
QrCode,
Camera,
Copy,
Check,
AlertCircle,
Download,
Upload,
RefreshCw,
} from 'lucide-react';
import { invoke } from '../../lib/tauri';
interface PaymentRequest {
address: string;
amount?: number;
label?: string;
message?: string;
}
export default function QRScannerPage() {
const [activeTab, setActiveTab] = useState<'generate' | 'scan'>('generate');
const [address, setAddress] = useState('');
const [amount, setAmount] = useState('');
const [label, setLabel] = useState('');
const [message, setMessage] = useState('');
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copiedUri, setCopiedUri] = useState(false);
const [scannedData, setScannedData] = useState<PaymentRequest | null>(null);
const [error, setError] = useState<string | null>(null);
const generatePaymentUri = () => {
let uri = `synor:${address}`;
const params: string[] = [];
if (amount) params.push(`amount=${amount}`);
if (label) params.push(`label=${encodeURIComponent(label)}`);
if (message) params.push(`message=${encodeURIComponent(message)}`);
if (params.length > 0) {
uri += '?' + params.join('&');
}
return uri;
};
const handleGenerateQR = async () => {
if (!address) return;
setIsGenerating(true);
setError(null);
try {
const uri = generatePaymentUri();
const qrData = await invoke<string>('qr_generate', {
data: uri,
size: 256,
});
setQrCodeData(qrData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate QR code');
} finally {
setIsGenerating(false);
}
};
const handlePasteUri = async () => {
try {
const text = await navigator.clipboard.readText();
parsePaymentUri(text);
} catch {
setError('Failed to read clipboard');
}
};
const parsePaymentUri = async (uri: string) => {
setError(null);
try {
const parsed = await invoke<PaymentRequest>('qr_parse_payment', { uri });
setScannedData(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid payment URI');
}
};
const copyUri = () => {
const uri = generatePaymentUri();
navigator.clipboard.writeText(uri);
setCopiedUri(true);
setTimeout(() => setCopiedUri(false), 2000);
};
const downloadQR = () => {
if (!qrCodeData) return;
const link = document.createElement('a');
link.download = `synor-payment-${Date.now()}.png`;
link.href = qrCodeData;
link.click();
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">QR Code</h1>
<p className="text-gray-400 mt-1">Generate and scan payment QR codes</p>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('generate')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'generate'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Generate QR
</button>
<button
onClick={() => setActiveTab('scan')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'scan'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Parse Payment
</button>
</div>
{/* Generate Tab */}
{activeTab === 'generate' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Form */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Payment Details</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
Receiving Address *
</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="synor1..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Amount (SYN) - Optional
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Label - Optional
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Coffee Shop"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Message - Optional
</label>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="e.g., Payment for order #123"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleGenerateQR}
disabled={!address || isGenerating}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isGenerating ? (
<>
<RefreshCw size={18} className="animate-spin" />
Generating...
</>
) : (
<>
<QrCode size={18} />
Generate QR Code
</>
)}
</button>
</div>
</div>
{/* QR Display */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">QR Code</h2>
<div className="flex flex-col items-center">
{qrCodeData ? (
<>
<div className="p-4 bg-white rounded-xl">
<img
src={qrCodeData}
alt="Payment QR Code"
className="w-64 h-64"
/>
</div>
<div className="mt-4 w-full">
<label className="block text-sm text-gray-400 mb-1">
Payment URI
</label>
<div className="flex gap-2">
<input
type="text"
value={generatePaymentUri()}
readOnly
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm font-mono"
/>
<button
onClick={copyUri}
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
{copiedUri ? (
<Check size={18} className="text-green-400" />
) : (
<Copy size={18} className="text-gray-400" />
)}
</button>
</div>
</div>
<button
onClick={downloadQR}
className="mt-4 flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
>
<Download size={18} />
Download QR Code
</button>
</>
) : (
<div className="w-64 h-64 bg-gray-800 rounded-xl flex items-center justify-center">
<div className="text-center text-gray-500">
<QrCode size={48} className="mx-auto mb-2 opacity-50" />
<p>Enter details to generate</p>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Scan Tab */}
{activeTab === 'scan' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Input */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Parse Payment URI</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400">
Paste a Synor payment URI to decode its contents. The URI format is:
</p>
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-gray-300 break-all">
synor:{'<address>'}?amount={'<amount>'}&label={'<label>'}
</code>
<div>
<label className="block text-sm text-gray-400 mb-1">
Payment URI
</label>
<textarea
placeholder="Paste synor:... URI here"
rows={3}
onChange={(e) => {
if (e.target.value) {
parsePaymentUri(e.target.value);
} else {
setScannedData(null);
}
}}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
/>
</div>
<button
onClick={handlePasteUri}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
>
<Upload size={18} />
Paste from Clipboard
</button>
<div className="pt-4 border-t border-gray-800">
<p className="text-sm text-gray-500">
Note: Camera scanning is not available in desktop apps. You can use
your phone's camera to scan QR codes and copy the resulting URI here.
</p>
</div>
</div>
</div>
{/* Parsed Result */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-lg font-semibold text-white mb-4">Payment Request</h2>
{scannedData ? (
<div className="space-y-4">
<div className="p-4 bg-green-900/20 border border-green-800 rounded-lg">
<div className="flex items-center gap-2 text-green-400 mb-2">
<Check size={18} />
<span className="font-medium">Valid Payment Request</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-500 mb-1">Address</label>
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-white break-all">
{scannedData.address}
</code>
</div>
{scannedData.amount !== undefined && (
<div>
<label className="block text-sm text-gray-500 mb-1">Amount</label>
<p className="text-2xl font-bold text-white">
{scannedData.amount} SYN
</p>
</div>
)}
{scannedData.label && (
<div>
<label className="block text-sm text-gray-500 mb-1">Label</label>
<p className="text-white">{scannedData.label}</p>
</div>
)}
{scannedData.message && (
<div>
<label className="block text-sm text-gray-500 mb-1">Message</label>
<p className="text-white">{scannedData.message}</p>
</div>
)}
<button
onClick={() => {
// Navigate to send page with pre-filled data
window.location.href = `/send?to=${scannedData.address}${
scannedData.amount ? `&amount=${scannedData.amount}` : ''
}`;
}}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
>
Send Payment
</button>
</div>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
<div className="text-center">
<Camera size={48} className="mx-auto mb-2 opacity-50" />
<p>Paste a payment URI to decode</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -1,7 +1,8 @@
import { useState } from 'react';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Copy, Check, Plus, QrCode } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { QRCodeSVG } from 'qrcode.react';
import { invoke } from '../lib/tauri';
import { useWalletStore } from '../store/wallet';
export default function Receive() {
@ -51,12 +52,16 @@ export default function Receive() {
<span className="text-sm text-gray-400">Primary Address</span>
</div>
{/* QR Code placeholder */}
<div className="w-48 h-48 mx-auto mb-4 bg-white rounded-lg flex items-center justify-center">
<div className="text-gray-400 text-center p-4">
<QrCode size={64} className="mx-auto mb-2 text-gray-300" />
<span className="text-xs">QR Code</span>
</div>
{/* QR Code */}
<div className="w-48 h-48 mx-auto mb-4 bg-white rounded-lg p-3 flex items-center justify-center">
<QRCodeSVG
value={primaryAddress}
size={168}
level="M"
includeMargin={false}
bgColor="#ffffff"
fgColor="#000000"
/>
</div>
{/* Address with copy */}

View file

@ -0,0 +1,546 @@
import { useEffect, useState } from 'react';
import {
ShieldCheck,
UserPlus,
UserMinus,
RefreshCw,
AlertCircle,
CheckCircle,
Clock,
Users,
Mail,
Wallet,
Info,
AlertTriangle,
} from 'lucide-react';
import {
useRecoveryStore,
Guardian,
RecoveryRequest,
getGuardianStatusColor,
getRequestStatusColor,
} from '../../store/recovery';
import { LoadingSpinner } from '../../components/LoadingStates';
/**
* Setup Recovery Modal
*/
function SetupRecoveryModal({ onClose }: { onClose: () => void }) {
const { setupRecovery, isLoading } = useRecoveryStore();
const [threshold, setThreshold] = useState(2);
const [delaySecs, setDelaySecs] = useState(86400); // 24 hours
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await setupRecovery(threshold, delaySecs);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to setup recovery');
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<ShieldCheck className="text-synor-400" />
Setup Social Recovery
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
Required Approvals (Threshold)
</label>
<input
type="number"
min="1"
max="10"
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value) || 1)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<p className="text-xs text-gray-500 mt-1">
Number of guardians required to approve recovery
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Recovery Delay
</label>
<select
value={delaySecs}
onChange={(e) => setDelaySecs(parseInt(e.target.value))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
>
<option value={3600}>1 hour</option>
<option value={86400}>24 hours</option>
<option value={259200}>3 days</option>
<option value={604800}>1 week</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Delay before recovery completes (gives time to cancel if fraudulent)
</p>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
>
{isLoading ? <LoadingSpinner size={18} /> : <ShieldCheck size={18} />}
Enable
</button>
</div>
</form>
</div>
</div>
);
}
/**
* Add Guardian Modal
*/
function AddGuardianModal({ onClose }: { onClose: () => void }) {
const { addGuardian, isLoading } = useRecoveryStore();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError('Guardian name is required');
return;
}
try {
await addGuardian(name.trim(), email.trim() || undefined, address.trim() || undefined);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add guardian');
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<UserPlus className="text-synor-400" />
Add Guardian
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Mom, Best Friend"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Email (Optional)</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="guardian@example.com"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<p className="text-xs text-gray-500 mt-1">
For sending recovery notifications
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Synor Address (Optional)</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="synor1..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<p className="text-xs text-gray-500 mt-1">
If they have a Synor wallet
</p>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
>
{isLoading ? <LoadingSpinner size={18} /> : <UserPlus size={18} />}
Add Guardian
</button>
</div>
</form>
</div>
</div>
);
}
/**
* Guardian Card
*/
function GuardianCard({ guardian }: { guardian: Guardian }) {
const { removeGuardian, isLoading } = useRecoveryStore();
const [showConfirm, setShowConfirm] = useState(false);
const handleRemove = async () => {
try {
await removeGuardian(guardian.id);
} catch (err) {
// Error handled by store
}
};
return (
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-medium text-white">{guardian.name}</h4>
<span className={`text-sm ${getGuardianStatusColor(guardian.status)}`}>
{guardian.status}
</span>
</div>
{!showConfirm ? (
<button
onClick={() => setShowConfirm(true)}
className="text-gray-500 hover:text-red-400 transition-colors"
>
<UserMinus size={18} />
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setShowConfirm(false)}
className="px-2 py-1 text-xs bg-gray-700 rounded"
>
Cancel
</button>
<button
onClick={handleRemove}
disabled={isLoading}
className="px-2 py-1 text-xs bg-red-600 rounded"
>
Remove
</button>
</div>
)}
</div>
{guardian.email && (
<div className="flex items-center gap-2 text-sm text-gray-400 mt-2">
<Mail size={14} />
{guardian.email}
</div>
)}
{guardian.address && (
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
<Wallet size={14} />
<span className="font-mono text-xs">{guardian.address.slice(0, 20)}...</span>
</div>
)}
<div className="text-xs text-gray-500 mt-2">
Added {new Date(guardian.addedAt * 1000).toLocaleDateString()}
</div>
</div>
);
}
/**
* Recovery Request Card
*/
function RecoveryRequestCard({ request }: { request: RecoveryRequest }) {
const { cancelRecovery, isLoading } = useRecoveryStore();
const handleCancel = async () => {
try {
await cancelRecovery(request.id);
} catch (err) {
// Error handled by store
}
};
const progress = (request.approvals.length / request.requiredApprovals) * 100;
return (
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<span className={`text-sm font-medium ${getRequestStatusColor(request.status)}`}>
{request.status.toUpperCase()}
</span>
<p className="text-xs text-gray-500 mt-1">ID: {request.id}</p>
</div>
{request.status === 'pending' && (
<button
onClick={handleCancel}
disabled={isLoading}
className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 rounded"
>
Cancel
</button>
)}
</div>
{/* Approval progress */}
<div className="mt-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Approvals</span>
<span>
{request.approvals.length} / {request.requiredApprovals}
</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-synor-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="mt-3 text-xs text-gray-500">
<p>Expires: {new Date(request.expiresAt * 1000).toLocaleString()}</p>
</div>
</div>
);
}
/**
* Main Recovery Dashboard
*/
export default function RecoveryDashboard() {
const {
config,
requests,
isLoading,
error,
fetchConfig,
fetchRequests,
disableRecovery,
} = useRecoveryStore();
const [showSetupModal, setShowSetupModal] = useState(false);
const [showAddGuardian, setShowAddGuardian] = useState(false);
useEffect(() => {
fetchConfig();
fetchRequests();
}, [fetchConfig, fetchRequests]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<ShieldCheck className="text-synor-400" />
Social Recovery
</h1>
<p className="text-gray-400 mt-1">
Recover your wallet using trusted guardians
</p>
</div>
<button
onClick={() => {
fetchConfig();
fetchRequests();
}}
disabled={isLoading}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="text-red-400" />
<span className="text-red-200">{error}</span>
</div>
)}
{/* Not Setup State */}
{!config && !isLoading && (
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
<ShieldCheck size={48} className="mx-auto text-gray-600 mb-4" />
<h3 className="text-lg font-medium text-gray-400 mb-2">Social Recovery Not Configured</h3>
<p className="text-gray-500 mb-4 max-w-md mx-auto">
Set up social recovery to allow trusted friends or family members to help you
recover access to your wallet if you lose your keys.
</p>
<button
onClick={() => setShowSetupModal(true)}
className="px-6 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg inline-flex items-center gap-2"
>
<ShieldCheck size={18} />
Setup Social Recovery
</button>
</div>
)}
{/* Configured State */}
{config && (
<>
{/* Status Card */}
<div className={`rounded-xl p-4 border ${config.enabled ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{config.enabled ? (
<CheckCircle className="text-green-400" size={24} />
) : (
<AlertTriangle className="text-red-400" size={24} />
)}
<div>
<p className="font-medium text-white">
{config.enabled ? 'Recovery Enabled' : 'Recovery Disabled'}
</p>
<p className="text-sm text-gray-400">
{config.threshold} of {config.totalGuardians} guardians required
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-400">Recovery Delay</p>
<p className="font-mono">
{config.recoveryDelaySecs >= 86400
? `${Math.floor(config.recoveryDelaySecs / 86400)} days`
: `${Math.floor(config.recoveryDelaySecs / 3600)} hours`}
</p>
</div>
{config.enabled && (
<button
onClick={disableRecovery}
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm"
>
Disable
</button>
)}
</div>
</div>
</div>
{/* Guardians Section */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Users className="text-synor-400" size={20} />
Guardians ({config.guardians.length})
</h2>
<button
onClick={() => setShowAddGuardian(true)}
className="px-3 py-1.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-sm flex items-center gap-2"
>
<UserPlus size={16} />
Add Guardian
</button>
</div>
{config.guardians.length === 0 ? (
<div className="text-center py-6 text-gray-500">
<Users size={32} className="mx-auto mb-2 opacity-50" />
<p>No guardians added yet</p>
<p className="text-sm">Add trusted contacts to enable recovery</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{config.guardians.map((guardian) => (
<GuardianCard key={guardian.id} guardian={guardian} />
))}
</div>
)}
{config.guardians.length > 0 && config.guardians.length < config.threshold && (
<div className="mt-4 bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-yellow-200">
<AlertTriangle size={16} />
You need at least {config.threshold} guardians for recovery to work
</div>
)}
</div>
{/* Recovery Requests Section */}
{requests.length > 0 && (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock className="text-synor-400" size={20} />
Recovery Requests
</h2>
<div className="space-y-3">
{requests.map((request) => (
<RecoveryRequestCard key={request.id} request={request} />
))}
</div>
</div>
)}
</>
)}
{/* Info Box */}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<div className="text-sm text-gray-400">
<p className="font-medium text-gray-300 mb-1">How Social Recovery Works</p>
<ol className="list-decimal list-inside space-y-1">
<li>Add trusted friends or family as guardians</li>
<li>If you lose access, initiate a recovery request</li>
<li>Guardians approve the request (threshold required)</li>
<li>After the delay period, recovery completes</li>
<li>You can cancel fraudulent requests during the delay</li>
</ol>
</div>
</div>
{/* Modals */}
{showSetupModal && <SetupRecoveryModal onClose={() => setShowSetupModal(false)} />}
{showAddGuardian && <AddGuardianModal onClose={() => setShowAddGuardian(false)} />}
</div>
);
}

View file

@ -0,0 +1,99 @@
import { useState } from 'react';
import { Server, Plus, Trash2, CheckCircle, RefreshCw, Info, Wifi } from 'lucide-react';
interface RpcProfile {
id: string;
name: string;
url: string;
isActive: boolean;
latency?: number;
}
export default function RpcProfilesDashboard() {
const [profiles] = useState<RpcProfile[]>([
{ id: '1', name: 'Default Mainnet', url: 'https://rpc.synor.io', isActive: true, latency: 45 },
{ id: '2', name: 'Testnet', url: 'https://testnet.synor.io', isActive: false, latency: 52 },
]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Server className="text-synor-400" />
Custom RPC Profiles
</h1>
<p className="text-gray-400 mt-1">Multiple endpoints with automatic failover</p>
</div>
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
<Plus size={18} />
Add Profile
</button>
</div>
<div className="space-y-3">
{profiles.map((profile) => (
<div
key={profile.id}
className={`bg-gray-900 rounded-xl p-4 border ${
profile.isActive ? 'border-synor-500' : 'border-gray-800'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{profile.isActive ? (
<CheckCircle className="text-green-400" size={20} />
) : (
<Wifi className="text-gray-500" size={20} />
)}
<div>
<p className="font-medium">{profile.name}</p>
<p className="text-sm text-gray-500 font-mono">{profile.url}</p>
</div>
</div>
<div className="flex items-center gap-3">
{profile.latency && (
<span className={`text-sm ${profile.latency < 100 ? 'text-green-400' : 'text-yellow-400'}`}>
{profile.latency}ms
</span>
)}
<button className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700">
<RefreshCw size={16} />
</button>
<button className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-3">Failover Settings</h3>
<div className="space-y-2 text-sm">
<label className="flex items-center justify-between">
<span className="text-gray-400">Auto-failover on disconnect</span>
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-400">Retry failed requests</span>
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-400">Latency-based routing</span>
<input type="checkbox" className="rounded bg-gray-800" />
</label>
</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Multiple RPC profiles ensure reliability. If one endpoint fails, the wallet
automatically switches to the next available endpoint.
</p>
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { invoke } from '../lib/tauri';
import { Send as SendIcon, AlertTriangle, Check } from 'lucide-react';
import { useWalletStore } from '../store/wallet';

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { invoke } from '../lib/tauri';
import {
Server,
Shield,

View file

@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import {
Coins,
TrendingUp,
Clock,
Lock,
Unlock,
RefreshCw,
Gift,
AlertCircle,
} from 'lucide-react';
import { useStakingStore, formatApy, formatLockPeriod } from '../../store/staking';
import { useWalletStore } from '../../store/wallet';
export default function StakingDashboard() {
const { addresses } = useWalletStore();
const {
pools,
userStakes,
isLoading,
isStaking,
isUnstaking,
isClaiming,
error,
clearError,
fetchPools,
fetchUserStakes,
stake,
unstake,
claimRewards,
} = useStakingStore();
const [selectedPool, setSelectedPool] = useState<string | null>(null);
const [stakeAmount, setStakeAmount] = useState('');
const [activeTab, setActiveTab] = useState<'pools' | 'stakes'>('pools');
const userAddress = addresses[0]?.address;
useEffect(() => {
fetchPools();
if (userAddress) {
fetchUserStakes(userAddress);
}
}, [fetchPools, fetchUserStakes, userAddress]);
const handleStake = async (poolAddress: string) => {
if (!stakeAmount) return;
try {
await stake(poolAddress, stakeAmount);
setStakeAmount('');
setSelectedPool(null);
if (userAddress) fetchUserStakes(userAddress);
} catch {
// Error handled by store
}
};
const handleUnstake = async (poolAddress: string) => {
try {
await unstake(poolAddress, '0');
if (userAddress) fetchUserStakes(userAddress);
} catch {
// Error handled by store
}
};
const handleClaimRewards = async (poolAddress: string) => {
try {
await claimRewards(poolAddress);
if (userAddress) fetchUserStakes(userAddress);
} catch {
// Error handled by store
}
};
const totalStaked = userStakes.reduce(
(sum, s) => sum + parseFloat(s.stakedAmount || '0'),
0
);
const totalRewards = userStakes.reduce(
(sum, s) => sum + parseFloat(s.pendingRewards || '0'),
0
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Staking</h1>
<p className="text-gray-400 mt-1">Stake SYN tokens to earn rewards</p>
</div>
<button
onClick={() => {
fetchPools();
if (userAddress) fetchUserStakes(userAddress);
}}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-synor-600/20 rounded-lg">
<Lock className="text-synor-400" size={20} />
</div>
<span className="text-gray-400">Total Staked</span>
</div>
<p className="text-2xl font-bold text-white">
{(totalStaked / 100_000_000).toFixed(4)} SYN
</p>
</div>
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-600/20 rounded-lg">
<Gift className="text-green-400" size={20} />
</div>
<span className="text-gray-400">Pending Rewards</span>
</div>
<p className="text-2xl font-bold text-white">
{(totalRewards / 100_000_000).toFixed(4)} SYN
</p>
</div>
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Coins className="text-purple-400" size={20} />
</div>
<span className="text-gray-400">Active Stakes</span>
</div>
<p className="text-2xl font-bold text-white">{userStakes.length}</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('pools')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'pools'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Staking Pools
</button>
<button
onClick={() => setActiveTab('stakes')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'stakes'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
My Stakes ({userStakes.length})
</button>
</div>
{/* Pools Tab */}
{activeTab === 'pools' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pools.map((pool) => (
<div
key={pool.poolAddress}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">{pool.name}</h3>
<p className="text-sm text-gray-400">
{pool.isActive ? 'Active' : 'Inactive'}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-green-400">
{formatApy(pool.apyBps)}
</p>
<p className="text-xs text-gray-500">APY</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
<div>
<p className="text-gray-500">Lock Period</p>
<p className="text-white flex items-center gap-1">
<Clock size={14} />
{formatLockPeriod(pool.lockPeriod)}
</p>
</div>
<div>
<p className="text-gray-500">Min Stake</p>
<p className="text-white">
{(parseFloat(pool.minStake) / 100_000_000).toFixed(2)} SYN
</p>
</div>
<div>
<p className="text-gray-500">Total Staked</p>
<p className="text-white">
{(parseFloat(pool.totalStaked) / 100_000_000).toLocaleString()} SYN
</p>
</div>
</div>
{selectedPool === pool.poolAddress ? (
<div className="space-y-3">
<input
type="number"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder={`Min: ${(parseFloat(pool.minStake) / 100_000_000).toFixed(2)} SYN`}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<div className="flex gap-2">
<button
onClick={() => handleStake(pool.poolAddress)}
disabled={isStaking || !stakeAmount}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isStaking ? 'Staking...' : 'Confirm Stake'}
</button>
<button
onClick={() => setSelectedPool(null)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setSelectedPool(pool.poolAddress)}
disabled={!pool.isActive}
className="w-full px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
Stake Now
</button>
)}
</div>
))}
{pools.length === 0 && !isLoading && (
<div className="col-span-2 text-center py-12 text-gray-500">
No staking pools available
</div>
)}
</div>
)}
{/* Stakes Tab */}
{activeTab === 'stakes' && (
<div className="space-y-4">
{userStakes.map((userStake) => {
const pool = pools.find((p) => p.poolAddress === userStake.poolAddress);
return (
<div
key={userStake.poolAddress}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">
{pool?.name || 'Staking Pool'}
</h3>
<p className="text-sm text-gray-400">
Staked: {new Date(userStake.stakedAt).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white">
{(parseFloat(userStake.stakedAmount) / 100_000_000).toFixed(4)} SYN
</p>
<p className="text-sm text-green-400 flex items-center gap-1 justify-end">
<TrendingUp size={14} />+
{(parseFloat(userStake.pendingRewards) / 100_000_000).toFixed(6)}{' '}
rewards
</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-800">
<div className="text-sm">
{userStake.unlockAt > Date.now() ? (
<span className="text-yellow-400 flex items-center gap-1">
<Lock size={14} />
Unlocks {new Date(userStake.unlockAt).toLocaleDateString()}
</span>
) : (
<span className="text-green-400 flex items-center gap-1">
<Unlock size={14} />
Ready to unstake
</span>
)}
</div>
<div className="flex gap-2">
{parseFloat(userStake.pendingRewards) > 0 && (
<button
onClick={() => handleClaimRewards(userStake.poolAddress)}
disabled={isClaiming}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isClaiming ? 'Claiming...' : 'Claim Rewards'}
</button>
)}
{userStake.unlockAt <= Date.now() && (
<button
onClick={() => handleUnstake(userStake.poolAddress)}
disabled={isUnstaking}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isUnstaking ? 'Unstaking...' : 'Unstake'}
</button>
)}
</div>
</div>
</div>
);
})}
{userStakes.length === 0 && !isLoading && (
<div className="text-center py-12 text-gray-500">
You don't have any active stakes
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import {
HardDrive,
Upload,
Trash2,
Pin,
PinOff,
RefreshCw,
AlertCircle,
File,
Lock,
Copy,
Check,
} from 'lucide-react';
import { useStorageStore, formatFileSize } from '../../store/storage';
import { open } from '@tauri-apps/plugin-dialog';
export default function StorageDashboard() {
const {
files,
usage,
isLoading,
isUploading,
error,
clearError,
fetchFiles,
fetchUsage,
uploadFile,
pinFile,
unpinFile,
deleteFile,
} = useStorageStore();
const [encrypt, setEncrypt] = useState(true);
const [pin, setPin] = useState(true);
const [copiedCid, setCopiedCid] = useState<string | null>(null);
useEffect(() => {
fetchFiles();
fetchUsage();
}, [fetchFiles, fetchUsage]);
const handleUpload = async () => {
const selected = await open({
multiple: false,
directory: false,
});
if (selected) {
await uploadFile(selected as string, encrypt, pin);
fetchUsage();
}
};
const copyCid = (cid: string) => {
navigator.clipboard.writeText(cid);
setCopiedCid(cid);
setTimeout(() => setCopiedCid(null), 2000);
};
const usagePercent = usage ? (usage.usedBytes / usage.limitBytes) * 100 : 0;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Decentralized Storage</h1>
<p className="text-gray-400 mt-1">Store files on the Synor network</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchFiles(); fetchUsage(); }}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button
onClick={handleUpload}
disabled={isUploading}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<Upload size={16} />
{isUploading ? 'Uploading...' : 'Upload File'}
</button>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Usage Stats */}
{usage && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<HardDrive size={20} className="text-synor-400" />
Storage Usage
</h2>
<span className="text-sm text-gray-400">
{formatFileSize(usage.usedBytes)} / {formatFileSize(usage.limitBytes)}
</span>
</div>
<div className="w-full bg-gray-800 rounded-full h-3 mb-4">
<div
className="bg-synor-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-white">{usage.fileCount}</p>
<p className="text-sm text-gray-500">Files</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-white">{usage.pinnedCount}</p>
<p className="text-sm text-gray-500">Pinned</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-white">{formatFileSize(usage.usedBytes)}</p>
<p className="text-sm text-gray-500">Used</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-white">{usage.monthlyCost} SYN</p>
<p className="text-sm text-gray-500">Monthly Cost</p>
</div>
</div>
</div>
)}
{/* Upload Options */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={encrypt}
onChange={(e) => setEncrypt(e.target.checked)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
<Lock size={16} className="text-gray-400" />
<span className="text-sm text-gray-300">Encrypt files</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={pin}
onChange={(e) => setPin(e.target.checked)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
/>
<Pin size={16} className="text-gray-400" />
<span className="text-sm text-gray-300">Pin for persistence</span>
</label>
</div>
</div>
{/* Files List */}
<div className="bg-gray-900 rounded-xl border border-gray-800">
<div className="p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Your Files</h2>
</div>
{files.length > 0 ? (
<div className="divide-y divide-gray-800">
{files.map((file) => (
<div key={file.cid} className="p-4 hover:bg-gray-800/50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="p-2 bg-gray-800 rounded-lg">
<File size={20} className="text-synor-400" />
</div>
<div className="min-w-0">
<p className="font-medium text-white truncate">{file.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-gray-500 truncate max-w-[200px]">
{file.cid}
</code>
<button
onClick={() => copyCid(file.cid)}
className="p-1 hover:bg-gray-700 rounded"
>
{copiedCid === file.cid ? (
<Check size={12} className="text-green-400" />
) : (
<Copy size={12} className="text-gray-500" />
)}
</button>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-white">{formatFileSize(file.size)}</p>
<div className="flex items-center gap-1 mt-1">
{file.isEncrypted && (
<Lock size={12} className="text-green-400" />
)}
{file.isPinned && (
<Pin size={12} className="text-yellow-400" />
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => file.isPinned ? unpinFile(file.cid) : pinFile(file.cid)}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
title={file.isPinned ? 'Unpin' : 'Pin'}
>
{file.isPinned ? (
<PinOff size={16} className="text-yellow-400" />
) : (
<Pin size={16} className="text-gray-400" />
)}
</button>
<button
onClick={() => deleteFile(file.cid)}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
title="Delete"
>
<Trash2 size={16} className="text-red-400" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-12 text-center text-gray-500">
<HardDrive size={48} className="mx-auto mb-4 opacity-50" />
<p>No files uploaded yet</p>
<p className="text-sm mt-1">Upload your first file to get started</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,352 @@
import { useState, useEffect } from 'react';
import {
ArrowDownUp,
RefreshCw,
Settings,
AlertCircle,
Droplets,
TrendingUp,
} from 'lucide-react';
import { useSwapStore, formatPriceImpact } from '../../store/swap';
import { useWalletStore } from '../../store/wallet';
import { useTokensStore } from '../../store/tokens';
export default function SwapDashboard() {
const { balance } = useWalletStore();
const { trackedTokens } = useTokensStore();
const {
quote,
pools,
isLoadingQuote,
isSwapping,
error,
clearError,
getQuote,
executeSwap,
fetchPools,
} = useSwapStore();
const [fromToken, setFromToken] = useState('SYN');
const [toToken, setToToken] = useState('');
const [fromAmount, setFromAmount] = useState('');
const [slippage, setSlippage] = useState(50); // 0.5% in bps
const [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState<'swap' | 'pools'>('swap');
useEffect(() => {
fetchPools();
}, [fetchPools]);
useEffect(() => {
if (fromToken && toToken && fromAmount && parseFloat(fromAmount) > 0) {
const debounce = setTimeout(() => {
const amountSats = (parseFloat(fromAmount) * 100_000_000).toString();
getQuote(fromToken, toToken, amountSats, slippage);
}, 500);
return () => clearTimeout(debounce);
}
}, [fromToken, toToken, fromAmount, slippage, getQuote]);
const handleSwap = async () => {
if (!quote) return;
try {
await executeSwap(quote.tokenIn, quote.tokenOut, quote.amountIn, quote.amountOutMin);
setFromAmount('');
} catch {
// Error handled by store
}
};
const swapTokens = () => {
setFromToken(toToken);
setToToken(fromToken);
setFromAmount('');
};
const formatSlippage = (bps: number) => `${(bps / 100).toFixed(1)}%`;
const availableTokens = [
{
symbol: 'SYN',
name: 'Synor',
balance: balance?.balanceHuman ? parseFloat(balance.balanceHuman) : 0,
},
...trackedTokens.map((t) => ({
symbol: t.symbol,
name: t.name,
balance: 0,
})),
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Swap</h1>
<p className="text-gray-400 mt-1">Exchange tokens instantly</p>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
<Settings size={20} />
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Settings Panel */}
{showSettings && (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="text-sm font-medium text-white mb-3">Slippage Tolerance</h3>
<div className="flex gap-2">
{[10, 50, 100].map((s) => (
<button
key={s}
onClick={() => setSlippage(s)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
slippage === s
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{formatSlippage(s)}
</button>
))}
<input
type="number"
value={slippage / 100}
onChange={(e) => setSlippage(Math.round(parseFloat(e.target.value || '0.5') * 100))}
className="w-20 px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm text-center"
step="0.1"
min="0.1"
max="50"
/>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b border-gray-800">
<button
onClick={() => setActiveTab('swap')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'swap'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Swap
</button>
<button
onClick={() => setActiveTab('pools')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'pools'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
Liquidity Pools
</button>
</div>
{/* Swap Tab */}
{activeTab === 'swap' && (
<div className="max-w-md mx-auto">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
{/* From Token */}
<div className="mb-2">
<label className="text-sm text-gray-400">From</label>
<div className="flex gap-2 mt-1">
<select
value={fromToken}
onChange={(e) => setFromToken(e.target.value)}
className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
{availableTokens.map((token) => (
<option key={token.symbol} value={token.symbol}>
{token.symbol} - {token.name}
</option>
))}
</select>
</div>
<input
type="number"
value={fromAmount}
onChange={(e) => setFromAmount(e.target.value)}
placeholder="0.0"
className="w-full mt-2 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-xl placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">
Balance:{' '}
{availableTokens.find((t) => t.symbol === fromToken)?.balance.toFixed(4) ||
'0'}{' '}
{fromToken}
</p>
</div>
{/* Swap Button */}
<div className="flex justify-center -my-2 relative z-10">
<button
onClick={swapTokens}
className="p-2 bg-gray-800 border border-gray-700 rounded-full hover:bg-gray-700 transition-colors"
>
<ArrowDownUp size={20} className="text-gray-400" />
</button>
</div>
{/* To Token */}
<div className="mb-4">
<label className="text-sm text-gray-400">To</label>
<div className="flex gap-2 mt-1">
<select
value={toToken}
onChange={(e) => setToToken(e.target.value)}
className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select token</option>
{availableTokens
.filter((t) => t.symbol !== fromToken)
.map((token) => (
<option key={token.symbol} value={token.symbol}>
{token.symbol} - {token.name}
</option>
))}
</select>
</div>
<div className="w-full mt-2 px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg">
<p className="text-xl text-white">
{quote
? (parseFloat(quote.amountOut) / 100_000_000).toFixed(6)
: '0.0'}
</p>
</div>
</div>
{/* Quote Details */}
{quote && (
<div className="mb-4 p-3 bg-gray-800/50 rounded-lg space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Price Impact</span>
<span
className={quote.priceImpactBps > 300 ? 'text-red-400' : 'text-gray-300'}
>
{formatPriceImpact(quote.priceImpactBps)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Min Received</span>
<span className="text-white">
{(parseFloat(quote.amountOutMin) / 100_000_000).toFixed(6)} {toToken}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Route</span>
<span className="text-white">{quote.route.join(' → ')}</span>
</div>
</div>
)}
{/* Swap Button */}
<button
onClick={handleSwap}
disabled={!quote || isSwapping || !fromAmount || !toToken}
className="w-full px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoadingQuote ? (
<span className="flex items-center justify-center gap-2">
<RefreshCw size={18} className="animate-spin" />
Getting quote...
</span>
) : isSwapping ? (
<span className="flex items-center justify-center gap-2">
<RefreshCw size={18} className="animate-spin" />
Swapping...
</span>
) : !toToken ? (
'Select a token'
) : !fromAmount ? (
'Enter an amount'
) : (
'Swap'
)}
</button>
</div>
</div>
)}
{/* Pools Tab */}
{activeTab === 'pools' && (
<div className="space-y-4">
{pools.map((pool) => (
<div
key={pool.poolAddress}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-synor-600/20 rounded-lg">
<Droplets className="text-synor-400" size={20} />
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{pool.symbolA} / {pool.symbolB}
</h3>
<p className="text-sm text-gray-400">Liquidity Pool</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-green-400 flex items-center gap-1">
<TrendingUp size={16} />
{(pool.feeBps / 100).toFixed(2)}% Fee
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">{pool.symbolA} Reserve</p>
<p className="text-white font-medium">
{(parseFloat(pool.reserveA) / 100_000_000).toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-500">{pool.symbolB} Reserve</p>
<p className="text-white font-medium">
{(parseFloat(pool.reserveB) / 100_000_000).toLocaleString()}
</p>
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-800">
<button className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors">
Add Liquidity
</button>
<button className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors">
Remove
</button>
</div>
</div>
))}
{pools.length === 0 && (
<div className="text-center py-12 text-gray-500">
No liquidity pools available
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,828 @@
import { useState, useEffect } from 'react';
import {
Coins,
Plus,
Send,
RefreshCw,
Trash2,
Copy,
Flame,
Sparkles,
ChevronDown,
ChevronRight,
ExternalLink,
} from 'lucide-react';
import {
useTokensStore,
formatTokenAmount,
parseTokenAmount,
TokenBalance,
} from '../../store/tokens';
import { useNodeStore } from '../../store/node';
import { useWalletStore } from '../../store/wallet';
import { truncateAddress } from '../../store/contracts';
type TabType = 'balances' | 'create' | 'transfer' | 'manage';
export default function TokensDashboard() {
const {
trackedTokens,
balances,
isCreating,
isTransferring,
isMinting,
isBurning,
isLoadingBalances,
error,
clearError,
createToken,
transferToken,
mintToken,
burnToken,
refreshBalances,
removeTrackedToken,
addTrackedToken,
getTokenInfo,
} = useTokensStore();
const nodeStatus = useNodeStore((state) => state.status);
const addresses = useWalletStore((state) => state.addresses);
const currentAddress = addresses[0]?.address || '';
// View state
const [activeTab, setActiveTab] = useState<TabType>('balances');
const [expandedTokens, setExpandedTokens] = useState<Set<string>>(new Set());
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
symbol: '',
decimals: '18',
initialSupply: '',
maxSupply: '',
mintable: true,
burnable: true,
pausable: false,
});
// Transfer form state
const [transferForm, setTransferForm] = useState({
contractAddress: '',
toAddress: '',
amount: '',
});
// Manage form state
const [manageForm, setManageForm] = useState({
contractAddress: '',
mintTo: '',
mintAmount: '',
burnAmount: '',
});
// Import token form
const [importAddress, setImportAddress] = useState('');
// Refresh balances on mount and when address changes
useEffect(() => {
if (currentAddress && nodeStatus.isConnected) {
refreshBalances(currentAddress);
}
}, [currentAddress, nodeStatus.isConnected, refreshBalances]);
// Clear error on tab change
useEffect(() => {
clearError();
}, [activeTab, clearError]);
const handleCreate = async () => {
if (!createForm.name || !createForm.symbol || !createForm.initialSupply) return;
try {
await createToken({
name: createForm.name,
symbol: createForm.symbol,
decimals: parseInt(createForm.decimals),
initialSupply: parseTokenAmount(createForm.initialSupply, parseInt(createForm.decimals)),
maxSupply: createForm.maxSupply
? parseTokenAmount(createForm.maxSupply, parseInt(createForm.decimals))
: undefined,
mintable: createForm.mintable,
burnable: createForm.burnable,
pausable: createForm.pausable,
});
// Clear form
setCreateForm({
name: '',
symbol: '',
decimals: '18',
initialSupply: '',
maxSupply: '',
mintable: true,
burnable: true,
pausable: false,
});
// Switch to balances and refresh
setActiveTab('balances');
if (currentAddress) {
refreshBalances(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleTransfer = async () => {
if (!transferForm.contractAddress || !transferForm.toAddress || !transferForm.amount) return;
const token = trackedTokens.find((t) => t.contractAddress === transferForm.contractAddress);
const decimals = token?.decimals || 18;
try {
await transferToken(
transferForm.contractAddress,
transferForm.toAddress,
parseTokenAmount(transferForm.amount, decimals)
);
// Clear form and refresh
setTransferForm({
contractAddress: '',
toAddress: '',
amount: '',
});
if (currentAddress) {
refreshBalances(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleMint = async () => {
if (!manageForm.contractAddress || !manageForm.mintTo || !manageForm.mintAmount) return;
const token = trackedTokens.find((t) => t.contractAddress === manageForm.contractAddress);
const decimals = token?.decimals || 18;
try {
await mintToken(
manageForm.contractAddress,
manageForm.mintTo,
parseTokenAmount(manageForm.mintAmount, decimals)
);
setManageForm({ ...manageForm, mintAmount: '' });
if (currentAddress) {
refreshBalances(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleBurn = async () => {
if (!manageForm.contractAddress || !manageForm.burnAmount) return;
const token = trackedTokens.find((t) => t.contractAddress === manageForm.contractAddress);
const decimals = token?.decimals || 18;
try {
await burnToken(
manageForm.contractAddress,
parseTokenAmount(manageForm.burnAmount, decimals)
);
setManageForm({ ...manageForm, burnAmount: '' });
if (currentAddress) {
refreshBalances(currentAddress);
}
} catch {
// Error handled by store
}
};
const handleImportToken = async () => {
if (!importAddress) return;
try {
const info = await getTokenInfo(importAddress);
addTrackedToken({
contractAddress: importAddress,
name: info.name,
symbol: info.symbol,
decimals: info.decimals,
addedAt: Date.now(),
isCreatedByUser: false,
});
setImportAddress('');
if (currentAddress) {
refreshBalances(currentAddress);
}
} catch {
// Error handled by store
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const toggleTokenExpanded = (address: string) => {
const newExpanded = new Set(expandedTokens);
if (newExpanded.has(address)) {
newExpanded.delete(address);
} else {
newExpanded.add(address);
}
setExpandedTokens(newExpanded);
};
const getBalanceForToken = (contractAddress: string): TokenBalance | undefined => {
return balances.find((b) => b.contractAddress === contractAddress);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Coins size={28} />
Tokens
</h1>
<p className="text-gray-400 mt-1">
Create, manage, and transfer custom tokens
</p>
</div>
<button
onClick={() => currentAddress && refreshBalances(currentAddress)}
disabled={isLoadingBalances || !nodeStatus.isConnected}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white transition-colors disabled:opacity-50"
>
<RefreshCw size={18} className={isLoadingBalances ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Not connected warning */}
{!nodeStatus.isConnected && (
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
Please connect to a node to manage tokens
</div>
)}
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
<Trash2 size={18} />
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{[
{ id: 'balances' as TabType, icon: Coins, label: 'Balances' },
{ id: 'create' as TabType, icon: Plus, label: 'Create Token' },
{ id: 'transfer' as TabType, icon: Send, label: 'Transfer' },
{ id: 'manage' as TabType, icon: Sparkles, label: 'Manage' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
activeTab === id
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
<Icon size={18} />
{label}
</button>
))}
</div>
{/* Tab Content */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
{/* Balances Tab */}
{activeTab === 'balances' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Your Token Balances</h3>
</div>
{/* Import token */}
<div className="flex gap-2">
<input
type="text"
value={importAddress}
onChange={(e) => setImportAddress(e.target.value)}
placeholder="Import token by contract address..."
className="flex-1 px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
<button
onClick={handleImportToken}
disabled={!importAddress || !nodeStatus.isConnected}
className="px-4 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white transition-colors disabled:opacity-50"
>
Import
</button>
</div>
{trackedTokens.length === 0 ? (
<div className="text-center py-8">
<Coins size={48} className="mx-auto text-gray-600 mb-4" />
<p className="text-gray-500">No tokens tracked yet</p>
<p className="text-gray-600 text-sm mt-1">
Create a new token or import an existing one
</p>
</div>
) : (
<div className="space-y-2">
{trackedTokens.map((token) => {
const balance = getBalanceForToken(token.contractAddress);
return (
<div
key={token.contractAddress}
className="p-4 rounded-lg bg-gray-800 border border-gray-700"
>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleTokenExpanded(token.contractAddress)}
>
<div className="flex items-center gap-3">
{expandedTokens.has(token.contractAddress) ? (
<ChevronDown size={18} className="text-gray-400" />
) : (
<ChevronRight size={18} className="text-gray-400" />
)}
<div className="w-10 h-10 rounded-full bg-synor-600/30 flex items-center justify-center">
<span className="text-synor-400 font-bold text-sm">
{token.symbol.slice(0, 2).toUpperCase()}
</span>
</div>
<div>
<p className="text-white font-medium">{token.name}</p>
<p className="text-gray-500 text-sm">{token.symbol}</p>
</div>
</div>
<div className="text-right">
<p className="text-white font-medium">
{balance
? formatTokenAmount(balance.balance, token.decimals)
: '—'}
</p>
<p className="text-gray-500 text-sm">
{truncateAddress(token.contractAddress)}
</p>
</div>
</div>
{expandedTokens.has(token.contractAddress) && (
<div className="mt-4 pt-4 border-t border-gray-700 space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Contract:</span>
<p className="text-gray-300 font-mono text-xs break-all">
{token.contractAddress}
</p>
</div>
<div>
<span className="text-gray-500">Decimals:</span>
<p className="text-gray-300">{token.decimals}</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(token.contractAddress);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-gray-700 text-gray-300 hover:text-white text-sm"
>
<Copy size={14} />
Copy Address
</button>
<button
onClick={(e) => {
e.stopPropagation();
setTransferForm({
...transferForm,
contractAddress: token.contractAddress,
});
setActiveTab('transfer');
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-synor-600 text-white text-sm"
>
<Send size={14} />
Transfer
</button>
{token.isCreatedByUser && (
<button
onClick={(e) => {
e.stopPropagation();
setManageForm({
...manageForm,
contractAddress: token.contractAddress,
});
setActiveTab('manage');
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-gray-700 text-gray-300 hover:text-white text-sm"
>
<Sparkles size={14} />
Manage
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
removeTrackedToken(token.contractAddress);
}}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-red-900/30 text-red-400 hover:text-red-300 text-sm"
>
<Trash2 size={14} />
Remove
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* Create Token Tab */}
{activeTab === 'create' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">Create New Token</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Token Name *</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder="My Token"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Symbol *</label>
<input
type="text"
value={createForm.symbol}
onChange={(e) =>
setCreateForm({ ...createForm, symbol: e.target.value.toUpperCase() })
}
placeholder="TKN"
maxLength={8}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 uppercase"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Decimals</label>
<input
type="number"
value={createForm.decimals}
onChange={(e) => setCreateForm({ ...createForm, decimals: e.target.value })}
min="0"
max="18"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
/>
<p className="text-xs text-gray-500 mt-1">Standard: 18</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Initial Supply *</label>
<input
type="text"
value={createForm.initialSupply}
onChange={(e) =>
setCreateForm({ ...createForm, initialSupply: e.target.value })
}
placeholder="1000000"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Max Supply (optional, leave blank for unlimited)
</label>
<input
type="text"
value={createForm.maxSupply}
onChange={(e) => setCreateForm({ ...createForm, maxSupply: e.target.value })}
placeholder="10000000"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Token features */}
<div className="p-4 rounded-lg bg-gray-800">
<p className="text-sm text-gray-400 mb-3">Token Features</p>
<div className="space-y-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={createForm.mintable}
onChange={(e) =>
setCreateForm({ ...createForm, mintable: e.target.checked })
}
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
/>
<span className="text-gray-300">Mintable</span>
<span className="text-gray-500 text-sm">
- Owner can create new tokens
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={createForm.burnable}
onChange={(e) =>
setCreateForm({ ...createForm, burnable: e.target.checked })
}
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
/>
<span className="text-gray-300">Burnable</span>
<span className="text-gray-500 text-sm">
- Holders can destroy their tokens
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={createForm.pausable}
onChange={(e) =>
setCreateForm({ ...createForm, pausable: e.target.checked })
}
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
/>
<span className="text-gray-300">Pausable</span>
<span className="text-gray-500 text-sm">
- Owner can pause transfers
</span>
</label>
</div>
</div>
<button
onClick={handleCreate}
disabled={
isCreating ||
!createForm.name ||
!createForm.symbol ||
!createForm.initialSupply ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCreating ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Plus size={18} />
)}
Create Token
</button>
</div>
)}
{/* Transfer Tab */}
{activeTab === 'transfer' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">Transfer Tokens</h3>
<div>
<label className="block text-sm text-gray-400 mb-2">Token *</label>
<select
value={transferForm.contractAddress}
onChange={(e) =>
setTransferForm({ ...transferForm, contractAddress: e.target.value })
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select token...</option>
{trackedTokens.map((token) => {
const balance = getBalanceForToken(token.contractAddress);
return (
<option key={token.contractAddress} value={token.contractAddress}>
{token.symbol} - {token.name}
{balance
? ` (${formatTokenAmount(balance.balance, token.decimals)})`
: ''}
</option>
);
})}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Recipient Address *</label>
<input
type="text"
value={transferForm.toAddress}
onChange={(e) =>
setTransferForm({ ...transferForm, toAddress: e.target.value })
}
placeholder="synor1..."
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Amount *</label>
<input
type="text"
value={transferForm.amount}
onChange={(e) =>
setTransferForm({ ...transferForm, amount: e.target.value })
}
placeholder="0.0"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleTransfer}
disabled={
isTransferring ||
!transferForm.contractAddress ||
!transferForm.toAddress ||
!transferForm.amount ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTransferring ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
Transfer
</button>
</div>
)}
{/* Manage Tab */}
{activeTab === 'manage' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white">Manage Token</h3>
<div>
<label className="block text-sm text-gray-400 mb-2">Token *</label>
<select
value={manageForm.contractAddress}
onChange={(e) =>
setManageForm({
...manageForm,
contractAddress: e.target.value,
mintTo: currentAddress,
})
}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select token...</option>
{trackedTokens
.filter((t) => t.isCreatedByUser)
.map((token) => (
<option key={token.contractAddress} value={token.contractAddress}>
{token.symbol} - {token.name}
</option>
))}
</select>
{trackedTokens.filter((t) => t.isCreatedByUser).length === 0 && (
<p className="text-xs text-gray-500 mt-1">
Only tokens you created can be managed
</p>
)}
</div>
{manageForm.contractAddress && (
<>
{/* Mint Section */}
<div className="p-4 rounded-lg bg-gray-800 space-y-3">
<div className="flex items-center gap-2 text-synor-400">
<Sparkles size={18} />
<span className="font-medium">Mint New Tokens</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
Recipient
</label>
<input
type="text"
value={manageForm.mintTo}
onChange={(e) =>
setManageForm({ ...manageForm, mintTo: e.target.value })
}
placeholder="synor1..."
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm font-mono focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Amount</label>
<input
type="text"
value={manageForm.mintAmount}
onChange={(e) =>
setManageForm({ ...manageForm, mintAmount: e.target.value })
}
placeholder="1000"
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-synor-500"
/>
</div>
</div>
<button
onClick={handleMint}
disabled={
isMinting ||
!manageForm.mintTo ||
!manageForm.mintAmount ||
!nodeStatus.isConnected
}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-synor-600 hover:bg-synor-700 text-white text-sm transition-colors disabled:opacity-50"
>
{isMinting ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Sparkles size={16} />
)}
Mint Tokens
</button>
</div>
{/* Burn Section */}
<div className="p-4 rounded-lg bg-gray-800 space-y-3">
<div className="flex items-center gap-2 text-red-400">
<Flame size={18} />
<span className="font-medium">Burn Tokens</span>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Amount to Burn
</label>
<input
type="text"
value={manageForm.burnAmount}
onChange={(e) =>
setManageForm({ ...manageForm, burnAmount: e.target.value })
}
placeholder="100"
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleBurn}
disabled={isBurning || !manageForm.burnAmount || !nodeStatus.isConnected}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-red-600 hover:bg-red-700 text-white text-sm transition-colors disabled:opacity-50"
>
{isBurning ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Flame size={16} />
)}
Burn Tokens
</button>
<p className="text-xs text-gray-500">
Warning: Burned tokens are permanently destroyed
</p>
</div>
</>
)}
</div>
)}
</div>
{/* Help Section */}
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-400 mb-3 flex items-center gap-2">
<ExternalLink size={16} />
About Tokens
</h3>
<ul className="space-y-2 text-sm text-gray-500">
<li> Tokens are fungible assets that live on the Synor blockchain</li>
<li> Created tokens follow the standard token interface (similar to ERC-20)</li>
<li> Mintable tokens allow the owner to create more supply</li>
<li> Burnable tokens allow holders to permanently destroy tokens</li>
<li> Import existing tokens by their contract address</li>
</ul>
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
import { useState } from 'react';
import { Wrench, Info, AlertTriangle, Plus, Trash2 } from 'lucide-react';
export default function TxBuilderDashboard() {
const [outputs, setOutputs] = useState([{ address: '', amount: '' }]);
const addOutput = () => {
setOutputs([...outputs, { address: '', amount: '' }]);
};
const removeOutput = (index: number) => {
if (outputs.length > 1) {
setOutputs(outputs.filter((_, i) => i !== index));
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<Wrench className="text-synor-400" />
Transaction Builder
</h1>
<p className="text-gray-400 mt-1">Advanced custom transaction crafting</p>
</div>
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertTriangle className="text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-200">Advanced Feature</p>
<p className="text-sm text-yellow-200/70">
Transaction builder is for advanced users. Incorrect transactions may result in
lost funds. Use with caution.
</p>
</div>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Outputs</h3>
<div className="space-y-3">
{outputs.map((output, index) => (
<div key={index} className="flex gap-3">
<input
type="text"
placeholder="Address"
value={output.address}
onChange={(e) => {
const newOutputs = [...outputs];
newOutputs[index].address = e.target.value;
setOutputs(newOutputs);
}}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm"
/>
<input
type="number"
placeholder="Amount (SYN)"
value={output.amount}
onChange={(e) => {
const newOutputs = [...outputs];
newOutputs[index].amount = e.target.value;
setOutputs(newOutputs);
}}
className="w-40 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<button
onClick={() => removeOutput(index)}
className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400"
disabled={outputs.length === 1}
>
<Trash2 size={18} />
</button>
</div>
))}
</div>
<button
onClick={addOutput}
className="mt-3 px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 text-sm"
>
<Plus size={16} />
Add Output
</button>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Advanced Options</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Fee Rate (sompi/byte)</label>
<input
type="number"
defaultValue="1"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Locktime</label>
<input
type="number"
defaultValue="0"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
</div>
</div>
<div className="flex gap-4">
<button className="flex-1 px-4 py-3 bg-gray-800 rounded-lg">
Preview Transaction
</button>
<button className="flex-1 px-4 py-3 bg-synor-600 rounded-lg">
Create & Sign
</button>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
The transaction builder allows you to craft custom transactions with multiple
outputs, specific fee rates, and advanced options like timelocks.
</p>
</div>
</div>
);
}

View file

@ -1,15 +1,16 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Lock } from 'lucide-react';
import { useNavigate, Link } from 'react-router-dom';
import { AlertTriangle, Lock, KeyRound, Download, Trash2 } from 'lucide-react';
import { useWalletStore } from '../store/wallet';
export default function Unlock() {
const navigate = useNavigate();
const { unlockWallet } = useWalletStore();
const { unlockWallet, setInitialized } = useWalletStore();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
@ -31,6 +32,16 @@ export default function Unlock() {
}
};
const handleResetWallet = () => {
// Clear wallet state and localStorage
setInitialized(false);
localStorage.removeItem('synor-wallet-storage');
localStorage.removeItem('synor-node-storage');
localStorage.removeItem('synor-mining-storage');
// Navigate to welcome page
navigate('/');
};
return (
<div className="h-full flex flex-col items-center justify-center p-8">
<div className="w-full max-w-sm">
@ -76,7 +87,72 @@ export default function Unlock() {
{loading ? 'Unlocking...' : 'Unlock'}
</button>
</form>
{/* Divider */}
<div className="my-6 border-t border-gray-800" />
{/* Alternative options */}
<div className="space-y-3">
<Link
to="/import"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white transition-colors text-sm"
>
<Download size={16} />
Import Different Wallet
</Link>
<button
onClick={() => setShowResetConfirm(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-400 hover:border-red-500/50 hover:text-red-400 transition-colors text-sm"
>
<KeyRound size={16} />
Forgot Password?
</button>
</div>
</div>
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 className="text-red-400" size={20} />
</div>
<h3 className="text-lg font-semibold text-white">Reset Wallet?</h3>
</div>
<div className="space-y-3 mb-6">
<p className="text-gray-300 text-sm">
This will permanently delete your wallet data from this device.
</p>
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
<p className="text-red-300 text-sm font-medium">
Warning: If you don't have your 24-word recovery phrase, you will lose access to your funds forever.
</p>
</div>
<p className="text-gray-400 text-sm">
After reset, you can create a new wallet or import an existing one using your recovery phrase.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowResetConfirm(false)}
className="flex-1 px-4 py-2.5 rounded-lg border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleResetWallet}
className="flex-1 px-4 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
>
Reset Wallet
</button>
</div>
</div>
</div>
)}
</div>
</div>
);

View file

@ -0,0 +1,517 @@
import { useEffect, useState } from 'react';
import {
Plus,
Lock,
Unlock,
Trash2,
RefreshCw,
AlertCircle,
CheckCircle,
Download,
Timer,
Info,
} from 'lucide-react';
// Using Timer as VaultIcon since lucide-react doesn't export Vault
const VaultIcon = Timer;
import {
useVaultsStore,
Vault,
formatTimeRemaining,
getVaultStatusColor,
LOCK_DURATION_PRESETS,
} from '../../store/vaults';
import { LoadingSpinner } from '../../components/LoadingStates';
/**
* Create Vault Modal
*/
function CreateVaultModal({ onClose }: { onClose: () => void }) {
const { createVault, isLoading } = useVaultsStore();
const [name, setName] = useState('');
const [amount, setAmount] = useState('');
const [duration, setDuration] = useState(86400); // 24 hours default
const [customDuration, setCustomDuration] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [description, setDescription] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const amountNum = parseFloat(amount);
if (!name.trim()) {
setError('Vault name is required');
return;
}
if (isNaN(amountNum) || amountNum <= 0) {
setError('Invalid amount');
return;
}
const lockDuration = useCustom
? parseInt(customDuration) * 3600 // Custom is in hours
: duration;
if (lockDuration < 60) {
setError('Lock duration must be at least 1 minute');
return;
}
try {
await createVault({
name: name.trim(),
amount: Math.floor(amountNum * 100_000_000), // Convert SYN to sompi
lockDurationSecs: lockDuration,
description: description.trim() || undefined,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create vault');
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<VaultIcon className="text-synor-400" size={24} />
Create Time-Locked Vault
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Vault Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Savings Goal, Emergency Fund"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
step="0.00000001"
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00000000"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Lock Duration</label>
<div className="grid grid-cols-4 gap-2 mb-2">
{LOCK_DURATION_PRESETS.slice(0, 4).map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => {
setDuration(preset.value);
setUseCustom(false);
}}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
!useCustom && duration === preset.value
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{preset.label}
</button>
))}
</div>
<div className="grid grid-cols-4 gap-2">
{LOCK_DURATION_PRESETS.slice(4).map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => {
setDuration(preset.value);
setUseCustom(false);
}}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
!useCustom && duration === preset.value
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{preset.label}
</button>
))}
<button
type="button"
onClick={() => setUseCustom(true)}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
useCustom
? 'bg-synor-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Custom
</button>
</div>
{useCustom && (
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min="1"
value={customDuration}
onChange={(e) => setCustomDuration(e.target.value)}
placeholder="Duration in hours"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<span className="text-gray-400">hours</span>
</div>
)}
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Description (Optional)</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What is this vault for?"
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white resize-none"
/>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-200">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-start gap-2 text-sm text-yellow-200">
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
<p>
<strong>Warning:</strong> Funds locked in a vault cannot be accessed until the lock
period expires. Make sure you don't need these funds during the lock period.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<LoadingSpinner size={18} />
Creating...
</>
) : (
<>
<Lock size={18} />
Lock Funds
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}
/**
* Individual Vault Card
*/
function VaultCard({ vault }: { vault: Vault }) {
const { withdrawVault, deleteVault, isLoading } = useVaultsStore();
const [timeRemaining, setTimeRemaining] = useState(vault.remainingSecs);
const [showConfirm, setShowConfirm] = useState<'withdraw' | 'delete' | null>(null);
// Update countdown timer
useEffect(() => {
if (vault.status !== 'locked') return;
const interval = setInterval(() => {
setTimeRemaining((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(interval);
}, [vault.status]);
const handleWithdraw = async () => {
try {
await withdrawVault(vault.id);
setShowConfirm(null);
} catch (err) {
// Error handled by store
}
};
const handleDelete = async () => {
try {
await deleteVault(vault.id);
setShowConfirm(null);
} catch (err) {
// Error handled by store
}
};
const StatusIcon = vault.status === 'locked' ? Lock : vault.status === 'unlocked' ? Unlock : CheckCircle;
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-white">{vault.name}</h3>
{vault.description && (
<p className="text-sm text-gray-500 mt-1">{vault.description}</p>
)}
</div>
<div className={`flex items-center gap-1 text-sm ${getVaultStatusColor(vault.status)}`}>
<StatusIcon size={14} />
<span className="capitalize">{vault.status}</span>
</div>
</div>
<div className="text-2xl font-bold text-white mb-2">
{vault.amountHuman}
</div>
{vault.status === 'locked' && (
<>
{/* Progress bar */}
<div className="h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-synor-500 transition-all duration-1000"
style={{ width: `${vault.progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400 flex items-center gap-1">
<Timer size={14} />
Unlocks in:
</span>
<span className="font-mono text-synor-400">
{formatTimeRemaining(timeRemaining)}
</span>
</div>
</>
)}
{vault.status === 'unlocked' && !showConfirm && (
<button
onClick={() => setShowConfirm('withdraw')}
disabled={isLoading}
className="w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Download size={18} />
Withdraw Funds
</button>
)}
{vault.status === 'withdrawn' && !showConfirm && (
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-gray-500">
Withdrawn {vault.txId && `(${vault.txId.slice(0, 12)}...)`}
</span>
<button
onClick={() => setShowConfirm('delete')}
className="text-gray-500 hover:text-red-400 transition-colors"
>
<Trash2 size={18} />
</button>
</div>
)}
{showConfirm && (
<div className="mt-3 bg-gray-800 rounded-lg p-3">
<p className="text-sm text-gray-300 mb-3">
{showConfirm === 'withdraw'
? 'Are you sure you want to withdraw funds from this vault?'
: 'Are you sure you want to delete this vault record?'}
</p>
<div className="flex gap-2">
<button
onClick={() => setShowConfirm(null)}
className="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm"
>
Cancel
</button>
<button
onClick={showConfirm === 'withdraw' ? handleWithdraw : handleDelete}
disabled={isLoading}
className={`flex-1 px-3 py-1.5 rounded text-sm ${
showConfirm === 'withdraw'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
}`}
>
{isLoading ? 'Processing...' : 'Confirm'}
</button>
</div>
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-800 flex justify-between text-xs text-gray-500">
<span>Created: {new Date(vault.createdAt * 1000).toLocaleDateString()}</span>
<span>Unlock: {new Date(vault.unlockAt * 1000).toLocaleDateString()}</span>
</div>
</div>
);
}
/**
* Main Vaults Dashboard
*/
export default function VaultsDashboard() {
const { vaults, summary, isLoading, error, fetchVaults, fetchSummary } = useVaultsStore();
const [showCreateModal, setShowCreateModal] = useState(false);
// Fetch vaults on mount
useEffect(() => {
fetchVaults();
fetchSummary();
// Refresh every minute
const interval = setInterval(() => {
fetchVaults();
fetchSummary();
}, 60000);
return () => clearInterval(interval);
}, [fetchVaults, fetchSummary]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<VaultIcon className="text-synor-400" />
Time-Locked Vaults
</h1>
<p className="text-gray-400 mt-1">
Lock funds for a period to enforce saving goals or vesting schedules
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
fetchVaults();
fetchSummary();
}}
disabled={isLoading}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center gap-2"
>
<Plus size={18} />
Create Vault
</button>
</div>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="text-red-400" />
<span className="text-red-200">{error}</span>
</div>
)}
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Total Locked</p>
<p className="text-xl font-bold text-synor-400">{summary.totalLockedHuman}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Active Vaults</p>
<p className="text-xl font-bold text-white">{summary.lockedVaults}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Ready to Withdraw</p>
<p className="text-xl font-bold text-green-400">{summary.unlockedVaults}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-400 mb-1">Next Unlock</p>
<p className="text-xl font-bold text-white">
{summary.nextUnlock
? new Date(summary.nextUnlock * 1000).toLocaleDateString()
: '-'}
</p>
</div>
</div>
)}
{/* Vaults List */}
{isLoading && vaults.length === 0 ? (
<div className="flex items-center justify-center h-48">
<LoadingSpinner size={32} />
</div>
) : vaults.length === 0 ? (
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
<VaultIcon size={48} className="mx-auto text-gray-600 mb-4" />
<h3 className="text-lg font-medium text-gray-400 mb-2">No Vaults Yet</h3>
<p className="text-gray-500 mb-4">
Create a time-locked vault to start saving with enforced holding periods.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors inline-flex items-center gap-2"
>
<Plus size={18} />
Create Your First Vault
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{vaults.map((vault) => (
<VaultCard key={vault.id} vault={vault} />
))}
</div>
)}
{/* Info Box */}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<div className="text-sm text-gray-400">
<p className="font-medium text-gray-300 mb-1">About Time-Locked Vaults</p>
<p>
Vaults use time-locked transactions to enforce a holding period. Once funds are
deposited, they cannot be withdrawn until the lock period expires. This is useful
for savings goals, vesting schedules, or preventing impulsive spending. The lock
is enforced at the protocol level, so even you cannot access the funds early.
</p>
</div>
</div>
{/* Create Modal */}
{showCreateModal && <CreateVaultModal onClose={() => setShowCreateModal(false)} />}
</div>
);
}

View file

@ -0,0 +1,714 @@
import { useState, useEffect } from 'react';
import {
Eye,
Plus,
RefreshCw,
Search,
Tag,
Trash2,
Edit2,
Copy,
Check,
ExternalLink,
AlertTriangle,
X,
} from 'lucide-react';
import {
useWatchOnlyStore,
useFilteredWatchOnlyAddresses,
formatWatchOnlyBalance,
WatchOnlyAddress,
} from '../../store/watchOnly';
export default function WatchOnlyDashboard() {
const {
tags,
selectedTag,
isLoading,
error,
loadAddresses,
loadTags,
addAddress,
updateAddress,
removeAddress,
refreshBalance,
refreshAllBalances,
setSelectedTag,
setError,
} = useWatchOnlyStore();
const addresses = useFilteredWatchOnlyAddresses();
const [showAddModal, setShowAddModal] = useState(false);
const [editingAddress, setEditingAddress] = useState<WatchOnlyAddress | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
// Load data on mount
useEffect(() => {
loadAddresses();
loadTags();
}, [loadAddresses, loadTags]);
// Filter by search query
const filteredAddresses = addresses.filter(
(a) =>
a.address.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.notes?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleCopy = async (address: string) => {
try {
await navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
} catch {
// Clipboard API failure
}
};
const handleDelete = async (address: string) => {
if (confirm('Are you sure you want to remove this watch-only address?')) {
try {
await removeAddress(address);
} catch {
// Error handled in store
}
}
};
// Calculate total balance
const totalBalance = addresses.reduce(
(sum, a) => sum + (a.cachedBalance || 0),
0
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Eye className="text-synor-400" />
Watch-Only Addresses
</h1>
<p className="text-gray-400 mt-1">
Monitor addresses without private keys
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refreshAllBalances()}
disabled={isLoading || addresses.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh All
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
>
<Plus size={16} />
Add Address
</button>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle size={16} />
{error}
</div>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
<X size={16} />
</button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-500 mb-1">Total Addresses</p>
<p className="text-2xl font-bold text-white">{addresses.length}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-500 mb-1">Total Balance</p>
<p className="text-2xl font-bold text-synor-400">
{formatWatchOnlyBalance(totalBalance)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<p className="text-sm text-gray-500 mb-1">Tags</p>
<p className="text-2xl font-bold text-white">{tags.length}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search addresses, labels, or notes..."
className="w-full bg-gray-900 border border-gray-800 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
{/* Tag Filter */}
{tags.length > 0 && (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<button
onClick={() => setSelectedTag(null)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
!selectedTag
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
All
</button>
{tags.map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
selectedTag === tag
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
<Tag size={12} />
{tag}
</button>
))}
</div>
)}
</div>
{/* Address List */}
{filteredAddresses.length === 0 ? (
<div className="text-center py-12 bg-gray-900 rounded-xl border border-gray-800">
<Eye className="mx-auto text-gray-600 mb-4\" size={48} />
<p className="text-gray-400 mb-2">No watch-only addresses yet</p>
<p className="text-gray-500 text-sm mb-4">
Add addresses to monitor their balances without exposing private keys
</p>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
>
<Plus size={16} />
Add Your First Address
</button>
</div>
) : (
<div className="space-y-3">
{filteredAddresses.map((addr) => (
<WatchOnlyCard
key={addr.address}
address={addr}
onCopy={() => handleCopy(addr.address)}
onEdit={() => setEditingAddress(addr)}
onDelete={() => handleDelete(addr.address)}
onRefresh={() => refreshBalance(addr.address)}
copied={copiedAddress === addr.address}
isRefreshing={isLoading}
/>
))}
</div>
)}
{/* Add Modal */}
{showAddModal && (
<AddWatchOnlyModal
onClose={() => setShowAddModal(false)}
onAdd={addAddress}
existingTags={tags}
/>
)}
{/* Edit Modal */}
{editingAddress && (
<EditWatchOnlyModal
address={editingAddress}
onClose={() => setEditingAddress(null)}
onSave={updateAddress}
existingTags={tags}
/>
)}
</div>
);
}
// Watch-only address card component
function WatchOnlyCard({
address,
onCopy,
onEdit,
onDelete,
onRefresh,
copied,
isRefreshing,
}: {
address: WatchOnlyAddress;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onRefresh: () => void;
copied: boolean;
isRefreshing: boolean;
}) {
const truncateAddress = (addr: string) => {
return `${addr.slice(0, 12)}...${addr.slice(-8)}`;
};
const formatTime = (timestamp: number | null) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
return (
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Label and network */}
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-white truncate">{address.label}</h3>
<span
className={`px-2 py-0.5 rounded text-xs ${
address.network === 'testnet'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-green-500/20 text-green-400'
}`}
>
{address.network}
</span>
</div>
{/* Address */}
<div className="flex items-center gap-2 mb-2">
<code className="text-gray-400 text-sm font-mono">
{truncateAddress(address.address)}
</code>
<button
onClick={onCopy}
className="text-gray-500 hover:text-white transition-colors"
>
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
</button>
<a
href={`https://explorer.synor.io/address/${address.address}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-synor-400 transition-colors"
>
<ExternalLink size={14} />
</a>
</div>
{/* Tags */}
{address.tags.length > 0 && (
<div className="flex items-center gap-1.5 mb-2">
{address.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-gray-800 text-gray-400 rounded text-xs"
>
{tag}
</span>
))}
</div>
)}
{/* Notes */}
{address.notes && (
<p className="text-gray-500 text-sm truncate">{address.notes}</p>
)}
</div>
{/* Balance and actions */}
<div className="flex flex-col items-end gap-2 ml-4">
<div className="text-right">
<p className="text-lg font-bold text-white">
{formatWatchOnlyBalance(address.cachedBalance)}
</p>
<p className="text-xs text-gray-500">
Updated: {formatTime(address.balanceUpdatedAt)}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={onRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors disabled:opacity-50"
title="Refresh balance"
>
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
</button>
<button
onClick={onEdit}
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors"
title="Edit"
>
<Edit2 size={14} />
</button>
<button
onClick={onDelete}
className="p-1.5 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
</div>
);
}
// Add watch-only address modal
function AddWatchOnlyModal({
onClose,
onAdd,
existingTags,
}: {
onClose: () => void;
onAdd: (address: string, label: string, notes?: string, tags?: string[]) => Promise<WatchOnlyAddress>;
existingTags: string[];
}) {
const [address, setAddress] = useState('');
const [label, setLabel] = useState('');
const [notes, setNotes] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [newTag, setNewTag] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!address.trim()) {
setError('Address is required');
return;
}
if (!label.trim()) {
setError('Label is required');
return;
}
setIsSubmitting(true);
setError('');
try {
await onAdd(
address.trim(),
label.trim(),
notes.trim() || undefined,
selectedTags.length > 0 ? selectedTags : undefined
);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add address');
} finally {
setIsSubmitting(false);
}
};
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const addNewTag = () => {
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
setSelectedTags((prev) => [...prev, newTag.trim()]);
setNewTag('');
}
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Add Watch-Only Address</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="synor1... or tsynor1..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Cold Storage, Exchange"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any additional notes..."
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-2 mb-2">
{existingTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
selectedTags.includes(tag)
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add new tag..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
/>
<button
onClick={addNewTag}
disabled={!newTag.trim()}
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
>
Add
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle size={14} />
{error}
</div>
)}
</div>
<div className="p-4 border-t border-gray-800 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Adding...' : 'Add Address'}
</button>
</div>
</div>
</div>
);
}
// Edit watch-only address modal
function EditWatchOnlyModal({
address,
onClose,
onSave,
existingTags,
}: {
address: WatchOnlyAddress;
onClose: () => void;
onSave: (
address: string,
label?: string,
notes?: string,
tags?: string[]
) => Promise<WatchOnlyAddress>;
existingTags: string[];
}) {
const [label, setLabel] = useState(address.label);
const [notes, setNotes] = useState(address.notes || '');
const [selectedTags, setSelectedTags] = useState<string[]>(address.tags);
const [newTag, setNewTag] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!label.trim()) {
setError('Label is required');
return;
}
setIsSubmitting(true);
setError('');
try {
await onSave(
address.address,
label.trim(),
notes.trim(),
selectedTags
);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update address');
} finally {
setIsSubmitting(false);
}
};
const toggleTag = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const addNewTag = () => {
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
setSelectedTags((prev) => [...prev, newTag.trim()]);
setNewTag('');
}
};
const allTags = Array.from(new Set([...existingTags, ...address.tags]));
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Edit Watch-Only Address</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
<code className="block w-full bg-gray-800/50 rounded-lg px-4 py-2.5 text-gray-400 font-mono text-sm break-all">
{address.address}
</code>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500 resize-none"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-2 mb-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
selectedTags.includes(tag)
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add new tag..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
/>
<button
onClick={addNewTag}
disabled={!newTag.trim()}
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
>
Add
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle size={14} />
{error}
</div>
)}
</div>
<div className="p-4 border-t border-gray-800 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { TrendingUp, Info, AlertCircle, RefreshCw, Loader2, Plus, Percent, X } from 'lucide-react';
import { useYieldStore, formatAmount } from '../../store/yield';
export default function YieldDashboard() {
const {
opportunities,
positions,
isLoading,
error,
loadOpportunities,
listPositions,
deposit,
withdraw,
clearError,
} = useYieldStore();
const [selectedOpportunity, setSelectedOpportunity] = useState<string | null>(null);
const [depositAmount, setDepositAmount] = useState('');
const [autoCompound, setAutoCompound] = useState(true);
const [showDepositModal, setShowDepositModal] = useState(false);
useEffect(() => {
loadOpportunities();
listPositions();
}, [loadOpportunities, listPositions]);
const handleDeposit = async () => {
if (!selectedOpportunity || !depositAmount) return;
try {
await deposit(selectedOpportunity, parseFloat(depositAmount) * 100_000_000, autoCompound);
setShowDepositModal(false);
setDepositAmount('');
setSelectedOpportunity(null);
} catch {
// Error handled by store
}
};
const totalDeposited = positions.reduce((sum, p) => sum + p.depositedAmount, 0);
const totalEarned = positions.reduce((sum, p) => sum + p.rewardsEarned, 0);
const bestApy = opportunities.length > 0
? Math.max(...opportunities.map(o => o.apy))
: 0;
// Helper to get opportunity details for a position
const getOpportunity = (oppId: string) => opportunities.find(o => o.id === oppId);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3">
<TrendingUp className="text-synor-400" />
Yield Aggregator
</h1>
<p className="text-gray-400 mt-1">Auto-compound and find the best APY</p>
</div>
<button
onClick={() => { loadOpportunities(); listPositions(); }}
className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700"
disabled={isLoading}
>
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
</div>
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="text-red-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-red-200">Error</p>
<p className="text-sm text-red-200/70">{error}</p>
</div>
<button onClick={clearError} className="text-red-400 hover:text-red-300">×</button>
</div>
)}
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
<p className="text-sm text-gray-400 mb-1">Total Deposited</p>
<p className="text-2xl font-bold">{formatAmount(totalDeposited)}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
<p className="text-sm text-gray-400 mb-1">Total Earned</p>
<p className="text-2xl font-bold text-green-400">{formatAmount(totalEarned)}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
<p className="text-sm text-gray-400 mb-1">Best APY</p>
<p className="text-2xl font-bold text-synor-400">{bestApy.toFixed(2)}%</p>
</div>
</div>
{/* Active Positions */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Your Positions ({positions.length})</h3>
{positions.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No active positions</p>
) : (
<div className="space-y-3">
{positions.map((position) => {
const opp = getOpportunity(position.opportunityId);
return (
<div key={position.id} className="p-4 bg-gray-800 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<p className="font-medium">{opp?.protocol || 'Unknown Protocol'}</p>
<p className="text-xs text-gray-500">{opp?.asset || 'Unknown Asset'}</p>
</div>
<div className="flex items-center gap-2">
{position.autoCompound && (
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">Auto</span>
)}
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs flex items-center gap-1">
<Percent size={12} />
{opp?.apy.toFixed(2) || '--'}% APY
</span>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500">Deposited</p>
<p className="font-mono">{formatAmount(position.depositedAmount)}</p>
</div>
<div>
<p className="text-gray-500">Current Value</p>
<p className="font-mono">{formatAmount(position.currentValue)}</p>
</div>
<div>
<p className="text-gray-500">Earned</p>
<p className="font-mono text-green-400">+{formatAmount(position.rewardsEarned)}</p>
</div>
</div>
<button
onClick={() => withdraw(position.id)}
className="mt-3 w-full py-2 bg-gray-700 rounded text-sm hover:bg-gray-600"
>
Withdraw All
</button>
</div>
);
})}
</div>
)}
</div>
{/* Available Strategies */}
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 className="font-medium mb-4">Available Strategies ({opportunities.length})</h3>
{opportunities.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No yield strategies available yet</p>
) : (
<div className="space-y-3">
{opportunities.map((opp) => (
<div key={opp.id} className="p-4 bg-gray-800 rounded-lg flex justify-between items-center">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{opp.protocol}</p>
<span className={`px-1.5 py-0.5 rounded text-xs ${
opp.riskLevel === 'low' ? 'bg-green-500/20 text-green-400' :
opp.riskLevel === 'medium' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
}`}>
{opp.riskLevel} risk
</span>
</div>
<p className="text-sm text-gray-500">{opp.asset}</p>
<div className="text-xs text-gray-600 mt-1">
<span>TVL: ${(opp.tvl / 1_000_000).toFixed(2)}M</span>
{opp.lockupPeriodDays > 0 && (
<span className="ml-2"> {opp.lockupPeriodDays}d lockup</span>
)}
<span className="ml-2"> Min: {formatAmount(opp.minDeposit)}</span>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-green-400">{opp.apy.toFixed(2)}%</p>
<p className="text-xs text-gray-500">APY</p>
<button
onClick={() => { setSelectedOpportunity(opp.id); setShowDepositModal(true); }}
className="mt-2 px-4 py-1 bg-synor-600 rounded text-sm hover:bg-synor-700 flex items-center gap-1"
>
<Plus size={14} />
Deposit
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Deposit Modal */}
{showDepositModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-xl p-6 w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Deposit to Yield Strategy</h3>
<button onClick={() => setShowDepositModal(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Amount (SYN)</label>
<input
type="number"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
placeholder="0.00"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoCompound"
checked={autoCompound}
onChange={(e) => setAutoCompound(e.target.checked)}
className="w-4 h-4 rounded"
/>
<label htmlFor="autoCompound" className="text-sm text-gray-400">
Enable auto-compounding
</label>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowDepositModal(false)}
className="flex-1 py-2 bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
onClick={handleDeposit}
disabled={isLoading || !depositAmount}
className="flex-1 py-2 bg-synor-600 rounded-lg disabled:opacity-50"
>
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Deposit'}
</button>
</div>
</div>
</div>
</div>
)}
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
<Info className="text-gray-500 mt-0.5" size={18} />
<p className="text-sm text-gray-400">
Yield aggregation optimizes gas costs and maximizes returns by automatically
moving funds to the highest-yielding opportunities.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import {
Layers,
RefreshCw,
AlertCircle,
ArrowDownToLine,
ArrowUpFromLine,
Send,
Activity,
Clock,
Zap,
Box,
} from 'lucide-react';
import { useZkStore, formatTps } from '../../store/zk';
export default function ZKDashboard() {
const {
stats,
account,
isLoading,
isTransacting,
error,
clearError,
fetchStats,
fetchAccount,
deposit,
withdraw,
transfer,
} = useZkStore();
const [activeTab, setActiveTab] = useState<'deposit' | 'withdraw' | 'transfer'>('deposit');
const [amount, setAmount] = useState('');
const [recipient, setRecipient] = useState('');
useEffect(() => {
fetchStats();
fetchAccount();
// Poll for stats updates
const interval = setInterval(() => {
fetchStats();
}, 10000);
return () => clearInterval(interval);
}, [fetchStats, fetchAccount]);
const handleDeposit = async () => {
if (!amount) return;
try {
await deposit(amount);
setAmount('');
fetchAccount();
} catch {
// Error handled by store
}
};
const handleWithdraw = async () => {
if (!amount) return;
try {
await withdraw(amount);
setAmount('');
fetchAccount();
} catch {
// Error handled by store
}
};
const handleTransfer = async () => {
if (!amount || !recipient) return;
try {
await transfer(recipient, amount);
setAmount('');
setRecipient('');
fetchAccount();
} catch {
// Error handled by store
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">ZK-Rollup (L2)</h1>
<p className="text-gray-400 mt-1">Fast, low-cost transactions with zero-knowledge proofs</p>
</div>
<button
onClick={() => {
fetchStats();
fetchAccount();
}}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Alert */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<AlertCircle className="text-red-400" size={20} />
<p className="text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-2">
<Box size={16} className="text-synor-400" />
<span className="text-sm text-gray-400">Batch</span>
</div>
<p className="text-2xl font-bold text-white">#{stats?.batchNumber || 0}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-2">
<Activity size={16} className="text-green-400" />
<span className="text-sm text-gray-400">Throughput</span>
</div>
<p className="text-2xl font-bold text-white">{stats ? formatTps(stats.averageTps) : '0 TPS'}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-2">
<Layers size={16} className="text-blue-400" />
<span className="text-sm text-gray-400">Total TXs</span>
</div>
<p className="text-2xl font-bold text-white">{stats?.totalTransactions.toLocaleString() || 0}</p>
</div>
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
<div className="flex items-center gap-2 mb-2">
<Clock size={16} className="text-yellow-400" />
<span className="text-sm text-gray-400">Pending</span>
</div>
<p className="text-2xl font-bold text-white">{stats?.pendingTransactions || 0}</p>
</div>
</div>
{/* Account & Operations */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* L2 Account */}
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Layers size={20} className="text-synor-400" />
<h2 className="text-lg font-semibold text-white">L2 Account</h2>
</div>
{account ? (
<div className="space-y-4">
<div className="p-4 bg-gray-800 rounded-lg">
<p className="text-sm text-gray-500 mb-1">L2 Balance</p>
<p className="text-3xl font-bold text-white">{account.balance} SYN</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-800 rounded-lg">
<p className="text-xs text-gray-500">Nonce</p>
<p className="text-white font-medium">{account.nonce}</p>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="text-xs text-gray-500">Status</p>
<p className={`font-medium ${account.isActivated ? 'text-green-400' : 'text-yellow-400'}`}>
{account.isActivated ? 'Active' : 'Not Activated'}
</p>
</div>
</div>
<div className="p-3 bg-gray-800 rounded-lg">
<p className="text-xs text-gray-500 mb-1">L2 Address</p>
<code className="text-sm text-gray-300 break-all">{account.address}</code>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Layers size={32} className="mx-auto mb-2 opacity-50" />
<p>No L2 account</p>
<p className="text-sm">Deposit funds to activate</p>
</div>
)}
</div>
{/* Operations */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-800">
<button
onClick={() => setActiveTab('deposit')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'deposit'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<ArrowDownToLine size={16} className="inline mr-2" />
Deposit
</button>
<button
onClick={() => setActiveTab('withdraw')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'withdraw'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<ArrowUpFromLine size={16} className="inline mr-2" />
Withdraw
</button>
<button
onClick={() => setActiveTab('transfer')}
className={`flex-1 px-4 py-3 font-medium transition-colors ${
activeTab === 'transfer'
? 'text-synor-400 border-b-2 border-synor-400'
: 'text-gray-400 hover:text-white'
}`}
>
<Send size={16} className="inline mr-2" />
Transfer
</button>
</div>
<div className="p-6">
{activeTab === 'deposit' && (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Deposit SYN from L1 to L2 for fast, low-cost transactions. Deposits are confirmed
after the next batch proof.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleDeposit}
disabled={!amount || isTransacting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<ArrowDownToLine size={18} />
{isTransacting ? 'Depositing...' : 'Deposit to L2'}
</button>
</div>
)}
{activeTab === 'withdraw' && (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Withdraw SYN from L2 back to L1. Withdrawals require proof finalization and may
take some time to complete.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div className="p-3 bg-yellow-900/20 border border-yellow-800/50 rounded-lg">
<p className="text-sm text-yellow-400">
<Zap size={14} className="inline mr-1" />
Withdrawals are batched and processed after proof verification
</p>
</div>
<button
onClick={handleWithdraw}
disabled={!amount || isTransacting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<ArrowUpFromLine size={18} />
{isTransacting ? 'Withdrawing...' : 'Withdraw to L1'}
</button>
</div>
)}
{activeTab === 'transfer' && (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Transfer SYN to another L2 address. L2 transfers are instant and have minimal fees.
</p>
<div>
<label className="block text-sm text-gray-400 mb-1">Recipient Address</label>
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="synor1..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
</div>
<button
onClick={handleTransfer}
disabled={!amount || !recipient || isTransacting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
<Send size={18} />
{isTransacting ? 'Sending...' : 'Transfer on L2'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Rollup Info */}
{stats && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">Rollup Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-gray-800 rounded-lg">
<p className="text-sm text-gray-500 mb-1">State Root</p>
<code className="text-sm text-gray-300 break-all">{stats.stateRoot}</code>
</div>
<div className="p-4 bg-gray-800 rounded-lg">
<p className="text-sm text-gray-500 mb-1">Last Proof</p>
<p className="text-white">{formatTime(stats.lastProofAt)}</p>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,127 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '../lib/tauri';
function logError(context: string, error: unknown): void {
if (import.meta.env.PROD) {
console.error(`[AddressBook] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
} else {
console.error(`[AddressBook] ${context}:`, error);
}
}
export interface AddressBookEntry {
id: string;
name: string;
address: string;
notes?: string;
tags: string[];
createdAt: number;
}
interface AddressBookState {
entries: AddressBookEntry[];
isLoading: boolean;
error: string | null;
clearError: () => void;
fetchAll: () => Promise<void>;
addEntry: (name: string, address: string, notes?: string, tags?: string[]) => Promise<AddressBookEntry>;
updateEntry: (id: string, name: string, address: string, notes?: string, tags?: string[]) => Promise<void>;
deleteEntry: (id: string) => Promise<void>;
findByAddress: (address: string) => AddressBookEntry | undefined;
findByTag: (tag: string) => AddressBookEntry[];
}
export const useAddressBookStore = create<AddressBookState>()(
persist(
(set, get) => ({
entries: [],
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchAll: async () => {
set({ isLoading: true });
try {
const entries = await invoke<AddressBookEntry[]>('addressbook_get_all');
set({ entries, isLoading: false });
} catch (error) {
logError('fetchAll', error);
set({ isLoading: false });
}
},
addEntry: async (name, address, notes, tags = []) => {
try {
const entry = await invoke<AddressBookEntry>('addressbook_add', {
name,
address,
notes,
tags,
});
set((state) => ({ entries: [...state.entries, entry] }));
return entry;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Add failed';
logError('addEntry', error);
set({ error: msg });
throw error;
}
},
updateEntry: async (id, name, address, notes, tags = []) => {
try {
const entry = await invoke<AddressBookEntry>('addressbook_update', {
id,
name,
address,
notes,
tags,
});
set((state) => ({
entries: state.entries.map((e) => (e.id === id ? entry : e)),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Update failed';
logError('updateEntry', error);
set({ error: msg });
throw error;
}
},
deleteEntry: async (id) => {
try {
await invoke('addressbook_delete', { id });
set((state) => ({
entries: state.entries.filter((e) => e.id !== id),
}));
} catch (error) {
logError('deleteEntry', error);
throw error;
}
},
findByAddress: (address) => {
return get().entries.find(
(e) => e.address.toLowerCase() === address.toLowerCase()
);
},
findByTag: (tag) => {
return get().entries.filter((e) =>
e.tags.some((t) => t.toLowerCase() === tag.toLowerCase())
);
},
}),
{
name: 'synor-addressbook-storage',
partialize: (state) => ({ entries: state.entries }),
}
)
);
export function useAddressBookEntries(): AddressBookEntry[] {
return useAddressBookStore((state) => state.entries);
}

View file

@ -0,0 +1,117 @@
import { create } from 'zustand';
import { invoke } from '../lib/tauri';
export interface PriceAlert {
id: string;
asset: string;
condition: 'above' | 'below';
targetPrice: number;
currentPrice: number;
isTriggered: boolean;
isEnabled: boolean;
createdAt: number;
triggeredAt?: number;
notificationMethod: 'push' | 'email' | 'both';
}
interface AlertsState {
alerts: PriceAlert[];
isLoading: boolean;
error: string | null;
}
interface AlertsActions {
createAlert: (
asset: string,
condition: 'above' | 'below',
targetPrice: number,
notificationMethod: 'push' | 'email' | 'both'
) => Promise<PriceAlert>;
listAlerts: () => Promise<void>;
deleteAlert: (alertId: string) => Promise<void>;
toggleAlert: (alertId: string, enabled: boolean) => Promise<PriceAlert>;
clearError: () => void;
}
// Transform snake_case to camelCase
const transformAlert = (data: Record<string, unknown>): PriceAlert => ({
id: data.id as string,
asset: data.asset as string,
condition: data.condition as PriceAlert['condition'],
targetPrice: data.target_price as number,
currentPrice: data.current_price as number,
isTriggered: data.is_triggered as boolean,
isEnabled: data.is_enabled as boolean,
createdAt: data.created_at as number,
triggeredAt: data.triggered_at as number | undefined,
notificationMethod: data.notification_method as PriceAlert['notificationMethod'],
});
export const useAlertsStore = create<AlertsState & AlertsActions>((set) => ({
alerts: [],
isLoading: false,
error: null,
createAlert: async (asset, condition, targetPrice, notificationMethod) => {
try {
set({ isLoading: true, error: null });
const data = await invoke<Record<string, unknown>>('alert_create', {
asset,
condition,
targetPrice,
notificationMethod,
});
const alert = transformAlert(data);
set((state) => ({
alerts: [alert, ...state.alerts],
isLoading: false,
}));
return alert;
} catch (error) {
set({ error: String(error), isLoading: false });
throw error;
}
},
listAlerts: async () => {
try {
set({ isLoading: true, error: null });
const data = await invoke<Record<string, unknown>[]>('alert_list');
const alerts = data.map(transformAlert);
set({ alerts, isLoading: false });
} catch (error) {
set({ error: String(error), isLoading: false });
}
},
deleteAlert: async (alertId: string) => {
try {
await invoke('alert_delete', { alertId });
set((state) => ({
alerts: state.alerts.filter((a) => a.id !== alertId),
}));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
toggleAlert: async (alertId: string, enabled: boolean) => {
try {
const data = await invoke<Record<string, unknown>>('alert_toggle', {
alertId,
enabled,
});
const alert = transformAlert(data);
set((state) => ({
alerts: state.alerts.map((a) => (a.id === alertId ? alert : a)),
}));
return alert;
} catch (error) {
set({ error: String(error) });
throw error;
}
},
clearError: () => set({ error: null }),
}));

View file

@ -0,0 +1,100 @@
import { create } from 'zustand';
import { invoke } from '../lib/tauri';
function logError(context: string, error: unknown): void {
if (import.meta.env.PROD) {
console.error(`[Backup] ${context}: ${error instanceof Error ? error.name : 'Unknown'}`);
} else {
console.error(`[Backup] ${context}:`, error);
}
}
export interface ExportedWallet {
path: string;
createdAt: number;
}
export interface ExportedHistory {
path: string;
transactionCount: number;
createdAt: number;
}
interface BackupState {
isExporting: boolean;
isImporting: boolean;
lastExport: ExportedWallet | null;
lastHistoryExport: ExportedHistory | null;
error: string | null;
clearError: () => void;
exportWallet: (password: string, path: string) => Promise<ExportedWallet>;
importWallet: (path: string, password: string) => Promise<boolean>;
exportHistory: (path: string, format: 'json' | 'csv') => Promise<ExportedHistory>;
}
export const useBackupStore = create<BackupState>()((set) => ({
isExporting: false,
isImporting: false,
lastExport: null,
lastHistoryExport: null,
error: null,
clearError: () => set({ error: null }),
exportWallet: async (password, path) => {
set({ isExporting: true, error: null });
try {
const result = await invoke<ExportedWallet>('backup_export_wallet', {
password,
path,
});
set({ lastExport: result, isExporting: false });
return result;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Export failed';
logError('exportWallet', error);
set({ error: msg, isExporting: false });
throw error;
}
},
importWallet: async (path, password) => {
set({ isImporting: true, error: null });
try {
const success = await invoke<boolean>('backup_import_wallet', {
path,
password,
});
set({ isImporting: false });
return success;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Import failed';
logError('importWallet', error);
set({ error: msg, isImporting: false });
throw error;
}
},
exportHistory: async (path, format) => {
set({ isExporting: true, error: null });
try {
const result = await invoke<ExportedHistory>('backup_export_history', {
path,
format,
});
set({ lastHistoryExport: result, isExporting: false });
return result;
} catch (error) {
const msg = error instanceof Error ? error.message : 'History export failed';
logError('exportHistory', error);
set({ error: msg, isExporting: false });
throw error;
}
},
}));
// Selector hooks
export function useIsBackupInProgress(): boolean {
return useBackupStore((state) => state.isExporting || state.isImporting);
}

View file

@ -0,0 +1,325 @@
import { create } from 'zustand';
import { invoke } from '../lib/tauri';
/**
* A recipient in a batch transaction
*/
export interface BatchRecipient {
id: string;
address: string;
amount: number; // in SYN (human readable)
amountSompi: number; // in sompi (internal)
label?: string;
isValid: boolean;
error?: string;
}
/**
* Batch transaction summary
*/
export interface BatchSummary {
totalAmount: number; // in sompi
totalAmountHuman: string;
recipientCount: number;
estimatedFee: number; // in sompi
estimatedFeeHuman: string;
totalWithFee: number; // in sompi
totalWithFeeHuman: string;
}
/**
* Created batch transaction
*/
interface BatchTransactionResponse {
tx_hex: string;
tx_id: string;
total_sent: number;
fee: number;
recipient_count: number;
}
interface BatchSendState {
// State
recipients: BatchRecipient[];
summary: BatchSummary | null;
isLoading: boolean;
error: string | null;
lastTxId: string | null;
// Actions
addRecipient: () => void;
removeRecipient: (id: string) => void;
updateRecipient: (id: string, updates: Partial<BatchRecipient>) => void;
clearRecipients: () => void;
importFromCsv: (csv: string) => void;
calculateSummary: () => void;
// Async actions
createBatchTransaction: (fee?: number) => Promise<string>;
signAndBroadcast: (txHex: string) => Promise<string>;
}
// Generate unique ID
let idCounter = 0;
const generateId = () => `recipient-${++idCounter}-${Date.now()}`;
// Convert SYN to sompi
const synToSompi = (syn: number): number => Math.floor(syn * 100_000_000);
// Convert sompi to SYN
const sompiToSyn = (sompi: number): string => (sompi / 100_000_000).toFixed(8);
// Validate address format
const validateAddress = (address: string): boolean => {
return address.startsWith('synor1') || address.startsWith('tsynor1');
};
export const useBatchSendStore = create<BatchSendState>()((set, get) => ({
// Initial state
recipients: [
{
id: generateId(),
address: '',
amount: 0,
amountSompi: 0,
isValid: false,
},
],
summary: null,
isLoading: false,
error: null,
lastTxId: null,
// Add a new recipient row
addRecipient: () => {
set((state) => ({
recipients: [
...state.recipients,
{
id: generateId(),
address: '',
amount: 0,
amountSompi: 0,
isValid: false,
},
],
}));
},
// Remove a recipient
removeRecipient: (id) => {
set((state) => {
const newRecipients = state.recipients.filter((r) => r.id !== id);
// Keep at least one recipient row
if (newRecipients.length === 0) {
return {
recipients: [
{
id: generateId(),
address: '',
amount: 0,
amountSompi: 0,
isValid: false,
},
],
summary: null,
};
}
return { recipients: newRecipients };
});
get().calculateSummary();
},
// Update a recipient
updateRecipient: (id, updates) => {
set((state) => ({
recipients: state.recipients.map((r) => {
if (r.id !== id) return r;
const newRecipient = { ...r, ...updates };
// Validate and update
if ('amount' in updates) {
newRecipient.amountSompi = synToSompi(updates.amount || 0);
}
// Validate address
const addressValid = validateAddress(newRecipient.address);
const amountValid = newRecipient.amountSompi > 0;
newRecipient.isValid = addressValid && amountValid;
if (!addressValid && newRecipient.address) {
newRecipient.error = 'Invalid address format';
} else if (!amountValid && newRecipient.amount > 0) {
newRecipient.error = 'Amount must be greater than 0';
} else {
newRecipient.error = undefined;
}
return newRecipient;
}),
}));
get().calculateSummary();
},
// Clear all recipients
clearRecipients: () => {
set({
recipients: [
{
id: generateId(),
address: '',
amount: 0,
amountSompi: 0,
isValid: false,
},
],
summary: null,
error: null,
lastTxId: null,
});
},
// Import recipients from CSV (address,amount format)
importFromCsv: (csv) => {
const lines = csv.trim().split('\n');
const recipients: BatchRecipient[] = [];
for (const line of lines) {
const [address, amountStr, label] = line.split(',').map((s) => s.trim());
const amount = parseFloat(amountStr) || 0;
if (address) {
const isValidAddress = validateAddress(address);
const isValidAmount = amount > 0;
recipients.push({
id: generateId(),
address,
amount,
amountSompi: synToSompi(amount),
label: label || undefined,
isValid: isValidAddress && isValidAmount,
error: !isValidAddress
? 'Invalid address'
: !isValidAmount
? 'Invalid amount'
: undefined,
});
}
}
if (recipients.length > 0) {
set({ recipients });
get().calculateSummary();
}
},
// Calculate transaction summary
calculateSummary: () => {
const { recipients } = get();
const validRecipients = recipients.filter((r) => r.isValid);
if (validRecipients.length === 0) {
set({ summary: null });
return;
}
const totalAmount = validRecipients.reduce((sum, r) => sum + r.amountSompi, 0);
// Estimate fee (roughly 1000 sompi per recipient as base)
// This is a simplification - actual fee depends on tx size
const estimatedFee = Math.max(1000, validRecipients.length * 500 + 500);
set({
summary: {
totalAmount,
totalAmountHuman: `${sompiToSyn(totalAmount)} SYN`,
recipientCount: validRecipients.length,
estimatedFee,
estimatedFeeHuman: `${sompiToSyn(estimatedFee)} SYN`,
totalWithFee: totalAmount + estimatedFee,
totalWithFeeHuman: `${sompiToSyn(totalAmount + estimatedFee)} SYN`,
},
});
},
// Create a batch transaction (unsigned)
createBatchTransaction: async (fee) => {
const { recipients, summary } = get();
const validRecipients = recipients.filter((r) => r.isValid);
if (validRecipients.length === 0) {
throw new Error('No valid recipients');
}
set({ isLoading: true, error: null });
try {
// Convert to backend format
const outputs = validRecipients.map((r) => ({
address: r.address,
amount: r.amountSompi,
}));
const response = await invoke<BatchTransactionResponse>('create_batch_transaction', {
outputs,
fee: fee || summary?.estimatedFee || 1000,
});
set({
lastTxId: response.tx_id,
isLoading: false,
});
return response.tx_hex;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create batch transaction';
set({ error: message, isLoading: false });
throw error;
}
},
// Sign and broadcast the transaction
signAndBroadcast: async (txHex) => {
set({ isLoading: true, error: null });
try {
// Sign the transaction
const signedHex = await invoke<string>('sign_transaction', { txHex });
// Broadcast it
const txId = await invoke<string>('broadcast_transaction', { txHex: signedHex });
set({
lastTxId: txId,
isLoading: false,
});
return txId;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to broadcast transaction';
set({ error: message, isLoading: false });
throw error;
}
},
}));
/**
* Get valid recipient count
*/
export function useValidRecipientCount(): number {
return useBatchSendStore((state) =>
state.recipients.filter((r) => r.isValid).length
);
}
/**
* Check if batch is ready to send
*/
export function useIsBatchReady(): boolean {
const { recipients, summary } = useBatchSendStore();
const validCount = recipients.filter((r) => r.isValid).length;
return validCount > 0 && summary !== null;
}

Some files were not shown because too many files have changed in this diff Show more