Compare commits
No commits in common. "main" and "wallet-v0.1.0" have entirely different histories.
main
...
wallet-v0.
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +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 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') }}
|
||||
59
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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:
|
||||
- "*"
|
||||
|
|
@ -13,8 +13,12 @@ env:
|
|||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: self-hosted
|
||||
name: Check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -52,8 +56,12 @@ jobs:
|
|||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: self-hosted
|
||||
name: Test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -62,7 +70,8 @@ jobs:
|
|||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libclang-dev llvm-dev
|
||||
|
|
@ -91,9 +100,18 @@ jobs:
|
|||
run: cargo test --workspace --all-features
|
||||
|
||||
build:
|
||||
name: Build (Linux x86_64)
|
||||
runs-on: self-hosted
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
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
|
||||
|
|
@ -102,7 +120,8 @@ jobs:
|
|||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libclang-dev llvm-dev
|
||||
|
|
@ -141,14 +160,14 @@ jobs:
|
|||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: synor-linux-x86_64
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
bench:
|
||||
name: Benchmarks
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
needs: [check, test]
|
||||
|
||||
|
|
@ -195,9 +214,10 @@ jobs:
|
|||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Summary job for branch protection
|
||||
ci-success:
|
||||
name: CI Success
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check, test, build]
|
||||
if: always()
|
||||
steps:
|
||||
195
.github/workflows/release-wallet.yml
vendored
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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') }}
|
||||
|
|
@ -15,17 +15,32 @@ permissions:
|
|||
jobs:
|
||||
build-release:
|
||||
name: Build Release (${{ matrix.target }})
|
||||
runs-on: self-hosted
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact-name: synor-linux-x86_64
|
||||
archive-ext: tar.gz
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
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
|
||||
|
|
@ -38,10 +53,17 @@ jobs:
|
|||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install cross-compilation tools
|
||||
if: matrix.cross
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libclang-dev llvm-dev gcc-aarch64-linux-gnu
|
||||
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
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
|
|
@ -63,37 +85,89 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.target }}-cargo-target-release-
|
||||
|
||||
- name: Build release binaries
|
||||
- name: Build release binaries (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: cargo build --release --workspace --target "$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"
|
||||
|
||||
- name: Prepare release archive
|
||||
- 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'
|
||||
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: Upload release artifact
|
||||
- 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'
|
||||
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 Forgejo Release
|
||||
runs-on: self-hosted
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-release
|
||||
|
||||
steps:
|
||||
|
|
@ -112,9 +186,10 @@ 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
|
||||
# Validate tag format (only allow v followed by semver-like pattern)
|
||||
if [[ ! "$CURRENT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "Invalid tag format: $CURRENT_TAG"
|
||||
exit 1
|
||||
|
|
@ -122,6 +197,7 @@ 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
|
||||
|
|
@ -130,6 +206,8 @@ 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
|
||||
|
|
@ -152,10 +230,10 @@ jobs:
|
|||
echo "" >> CHANGELOG_BODY.md
|
||||
echo '```' >> CHANGELOG_BODY.md
|
||||
cd artifacts
|
||||
find . -name "*.tar.gz" -exec sha256sum {} \; | sed 's|./[^/]*/||' >> ../CHANGELOG_BODY.md
|
||||
find . \( -name "*.tar.gz" -o -name "*.zip" \) -exec sha256sum {} \; | sed 's|./[^/]*/||' >> ../CHANGELOG_BODY.md
|
||||
echo '```' >> CHANGELOG_BODY.md
|
||||
|
||||
- name: Create Release
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Synor ${{ steps.changelog.outputs.current_tag }}
|
||||
|
|
@ -164,12 +242,14 @@ 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: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
if: ${{ !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') && !contains(github.ref, 'rc') }}
|
||||
|
||||
|
|
@ -189,12 +269,16 @@ 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..."
|
||||
# Uncomment when ready to publish:
|
||||
# cargo publish -p synor-types
|
||||
# cargo publish -p synor-crypto
|
||||
# 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
|
||||
echo "Crate publishing configured but commented out - uncomment when ready"
|
||||
|
|
@ -21,12 +21,12 @@ jobs:
|
|||
# ============================================================================
|
||||
cargo-audit:
|
||||
name: Vulnerability Scan
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
# ============================================================================
|
||||
cargo-deny:
|
||||
name: License & Security Policy
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -53,12 +53,12 @@ jobs:
|
|||
# ============================================================================
|
||||
clippy:
|
||||
name: Static Analysis (Clippy)
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ jobs:
|
|||
# ============================================================================
|
||||
secrets-scan:
|
||||
name: Secret Detection
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -91,12 +91,12 @@ jobs:
|
|||
# ============================================================================
|
||||
outdated:
|
||||
name: Check Outdated Dependencies
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Install cargo-outdated
|
||||
run: cargo install cargo-outdated --locked
|
||||
|
|
@ -110,12 +110,12 @@ jobs:
|
|||
# ============================================================================
|
||||
geiger:
|
||||
name: Unsafe Code Audit
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Install cargo-geiger
|
||||
run: cargo install cargo-geiger --locked
|
||||
|
|
@ -129,14 +129,14 @@ jobs:
|
|||
# ============================================================================
|
||||
property-tests:
|
||||
name: Property-Based Testing
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PROPTEST_CASES: "500"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@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: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-action@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
4
.gitignore
vendored
|
|
@ -55,7 +55,3 @@ temp/
|
|||
# Firebase
|
||||
.firebase/
|
||||
**/.vite/
|
||||
|
||||
# Secrets
|
||||
credentials.json
|
||||
*.p12
|
||||
|
|
|
|||
505
.misar/MISAR.md
|
|
@ -1,505 +0,0 @@
|
|||
# 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 -->
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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://git.misar.io/misaradmin/synor"
|
||||
repository = "https://github.com/synorcc/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://git.misar.io/misaradmin/synor"
|
||||
repository = "https://github.com/synorcc/synor"
|
||||
homepage = "https://synor.cc"
|
||||
description = "Quantum-secure decentralized cloud computing platform"
|
||||
rust-version = "1.75"
|
||||
|
|
|
|||
20
MISAR.md
|
|
@ -1,20 +0,0 @@
|
|||
# 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 -->
|
||||
14
README.md
|
|
@ -11,22 +11,20 @@ Quantum-secure decentralized cloud computing platform built on DAG-based consens
|
|||
|
||||
## Installation
|
||||
|
||||
### Desktop Wallet (v0.1.1)
|
||||
### Desktop Wallet
|
||||
|
||||
Download the latest release for your platform:
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| **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 && ./` |
|
||||
| **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 && ./` |
|
||||
|
||||
**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:
|
||||
|
|
|
|||
|
|
@ -256,11 +256,7 @@ 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 {
|
||||
|
|
@ -272,11 +268,7 @@ 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 {
|
||||
|
|
@ -322,11 +314,7 @@ 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);
|
||||
|
||||
|
|
@ -356,11 +344,7 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -196,7 +196,8 @@ 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!(
|
||||
|
|
@ -269,10 +270,7 @@ 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"
|
||||
));
|
||||
|
|
@ -283,8 +281,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));
|
||||
|
|
@ -399,7 +397,11 @@ 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
|
||||
|
|
@ -443,7 +445,11 @@ async fn upload_files(base_dir: &Path, files: &[DeployFile], gateway_url: &str)
|
|||
}
|
||||
|
||||
/// 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)]
|
||||
|
|
@ -656,11 +662,7 @@ 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 {
|
||||
|
|
@ -705,13 +707,22 @@ 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"
|
||||
|
|
|
|||
|
|
@ -246,13 +246,7 @@ 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);
|
||||
|
|
@ -263,12 +257,7 @@ 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);
|
||||
|
|
@ -286,10 +275,7 @@ 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(())
|
||||
}
|
||||
|
|
@ -331,12 +317,7 @@ 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);
|
||||
|
|
@ -346,11 +327,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -169,7 +169,11 @@ 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()));
|
||||
|
|
@ -207,16 +211,8 @@ pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFor
|
|||
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...");
|
||||
|
|
@ -230,11 +226,7 @@ pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFor
|
|||
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...");
|
||||
|
|
@ -248,11 +240,7 @@ pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFor
|
|||
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...");
|
||||
|
|
@ -268,11 +256,7 @@ pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFor
|
|||
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...");
|
||||
|
|
@ -281,15 +265,8 @@ pub async fn handle(_client: &RpcClient, command: ZkCommands, _format: OutputFor
|
|||
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...");
|
||||
|
|
|
|||
|
|
@ -469,11 +469,7 @@ 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
|
||||
|
|
@ -499,11 +495,7 @@ 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,
|
||||
},
|
||||
|
||||
|
|
@ -513,11 +505,7 @@ 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,
|
||||
},
|
||||
|
||||
|
|
@ -527,11 +515,7 @@ 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,
|
||||
},
|
||||
}
|
||||
|
|
@ -607,11 +591,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
# 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"]
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke tests for Synor Desktop Wallet
|
||||
*
|
||||
* These tests verify the development server is running and serving content.
|
||||
* Note: Full E2E testing of Tauri features requires running the complete
|
||||
* Tauri application with the Rust backend.
|
||||
*
|
||||
* For comprehensive E2E testing, use:
|
||||
* pnpm tauri:dev (to run full app)
|
||||
*
|
||||
* These smoke tests verify:
|
||||
* - Dev server responds
|
||||
* - HTML content is served
|
||||
* - React app bundles load
|
||||
*/
|
||||
|
||||
test.describe('Smoke Tests', () => {
|
||||
test('dev server should respond', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('should serve HTML content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const html = await page.content();
|
||||
// Should have basic HTML structure
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('<html');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
test('should have root element for React', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const html = await page.content();
|
||||
// Should have React root element
|
||||
expect(html).toContain('id="root"');
|
||||
});
|
||||
|
||||
test('should load JavaScript bundles', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const html = await page.content();
|
||||
// Should include script tags
|
||||
expect(html).toContain('<script');
|
||||
expect(html).toContain('type="module"');
|
||||
});
|
||||
|
||||
test('should load CSS', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const html = await page.content();
|
||||
// Should include styles (either link stylesheet or style tag)
|
||||
const hasStylesheet = html.includes('stylesheet') || html.includes('<style');
|
||||
expect(hasStylesheet).toBe(true);
|
||||
});
|
||||
|
||||
test('should have correct title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const title = await page.title();
|
||||
// Should have a title set
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('all routes should return 200', async ({ page }) => {
|
||||
const routes = [
|
||||
'/',
|
||||
'/setup',
|
||||
'/dashboard',
|
||||
'/send',
|
||||
'/receive',
|
||||
'/history',
|
||||
'/node',
|
||||
'/mining',
|
||||
'/staking',
|
||||
'/swap',
|
||||
'/market',
|
||||
'/contracts',
|
||||
'/tokens',
|
||||
'/nfts',
|
||||
'/settings',
|
||||
'/storage',
|
||||
'/hosting',
|
||||
'/compute',
|
||||
'/database',
|
||||
'/privacy',
|
||||
'/bridge',
|
||||
'/governance',
|
||||
'/zk',
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
const response = await page.goto(route);
|
||||
expect(response?.status(), `Route ${route} should return 200`).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Build Verification', () => {
|
||||
test('should load without unexpected JavaScript errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
// Ignore expected errors when running outside Tauri context:
|
||||
// - Tauri API errors (__TAURI__, invoke, etc.)
|
||||
// - React error boundary messages (expected when components fail without Tauri)
|
||||
// - Window property checks
|
||||
const isExpectedError =
|
||||
text.includes('__TAURI__') ||
|
||||
text.includes('tauri') ||
|
||||
text.includes('invoke') ||
|
||||
text.includes('window.') ||
|
||||
text.includes('error boundary') ||
|
||||
text.includes('Error occurred in') ||
|
||||
text.includes('TitleBar') ||
|
||||
text.includes('getCurrentWindow');
|
||||
|
||||
if (!isExpectedError) {
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(2000); // Wait for async operations
|
||||
|
||||
// Log any errors found for debugging
|
||||
if (errors.length > 0) {
|
||||
console.log('Unexpected console errors found:', errors);
|
||||
}
|
||||
|
||||
// Should have no unexpected errors
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should not have network failures for static assets', async ({ page }) => {
|
||||
const failedRequests: string[] = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
failedRequests.push(`${request.url()} - ${request.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should have no failed requests
|
||||
expect(failedRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@synor/desktop-wallet",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -10,32 +10,27 @@
|
|||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"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"
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^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-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",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zustand": "^4.4.7"
|
||||
"zustand": "^4.4.7",
|
||||
"lucide-react": "^0.303.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "synor-wallet"
|
||||
version = "0.1.1"
|
||||
version = "0.1.0"
|
||||
description = "Secure Synor blockchain wallet with post-quantum cryptography"
|
||||
authors = ["Synor Team"]
|
||||
edition = "2021"
|
||||
|
|
@ -42,37 +42,14 @@ bech32 = "0.11"
|
|||
# OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
keyring = "3"
|
||||
|
||||
# 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 }
|
||||
# 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 }
|
||||
|
||||
[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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 54 KiB |
|
|
@ -44,33 +44,6 @@ 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ 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},
|
||||
|
|
@ -116,39 +112,10 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.setup(|app| {
|
||||
// Initialize wallet state (legacy, for backwards compatibility)
|
||||
// Initialize wallet state
|
||||
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()
|
||||
|
|
@ -181,32 +148,13 @@ pub fn run() {
|
|||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Wallet management (legacy single-wallet)
|
||||
// Wallet management
|
||||
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,
|
||||
|
|
@ -217,246 +165,10 @@ pub fn run() {
|
|||
commands::sign_transaction,
|
||||
commands::broadcast_transaction,
|
||||
commands::get_transaction_history,
|
||||
// Batch transactions
|
||||
commands::create_batch_transaction,
|
||||
// Fee market analytics
|
||||
commands::fee_get_mempool_stats,
|
||||
commands::fee_get_recommendations,
|
||||
commands::fee_get_analytics,
|
||||
commands::fee_get_history,
|
||||
commands::fee_calculate,
|
||||
// Time-locked vaults
|
||||
commands::vault_list,
|
||||
commands::vault_get_summary,
|
||||
commands::vault_create,
|
||||
commands::vault_get,
|
||||
commands::vault_withdraw,
|
||||
commands::vault_delete,
|
||||
commands::vault_time_remaining,
|
||||
// Social recovery
|
||||
commands::recovery_get_config,
|
||||
commands::recovery_setup,
|
||||
commands::recovery_add_guardian,
|
||||
commands::recovery_remove_guardian,
|
||||
commands::recovery_list_guardians,
|
||||
commands::recovery_update_threshold,
|
||||
commands::recovery_initiate,
|
||||
commands::recovery_approve,
|
||||
commands::recovery_get_request,
|
||||
commands::recovery_list_requests,
|
||||
commands::recovery_cancel,
|
||||
commands::recovery_disable,
|
||||
// Decoy wallets
|
||||
commands::decoy_is_enabled,
|
||||
commands::decoy_setup,
|
||||
commands::decoy_create,
|
||||
commands::decoy_list,
|
||||
commands::decoy_update_balance,
|
||||
commands::decoy_delete,
|
||||
commands::decoy_check_duress,
|
||||
commands::decoy_disable,
|
||||
// Network (legacy)
|
||||
// Network
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,480 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,563 +0,0 @@
|
|||
//! 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
//! 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)
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
//! Watch-only address management
|
||||
//!
|
||||
//! Allows monitoring addresses without holding private keys.
|
||||
//! Useful for:
|
||||
//! - Monitoring cold storage addresses
|
||||
//! - Tracking other wallets
|
||||
//! - Observing addresses before import
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Watch-only addresses file name
|
||||
const WATCH_ONLY_FILE: &str = "watch_only.json";
|
||||
|
||||
/// A watch-only address entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchOnlyAddress {
|
||||
/// Bech32 encoded address
|
||||
pub address: String,
|
||||
/// User-defined label
|
||||
pub label: String,
|
||||
/// Network (mainnet/testnet)
|
||||
pub network: String,
|
||||
/// When this address was added
|
||||
pub added_at: i64,
|
||||
/// Optional notes
|
||||
pub notes: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Last known balance (cached)
|
||||
#[serde(default)]
|
||||
pub cached_balance: Option<u64>,
|
||||
/// When balance was last updated
|
||||
#[serde(default)]
|
||||
pub balance_updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Persisted watch-only addresses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WatchOnlyData {
|
||||
/// Map of address -> entry
|
||||
pub addresses: HashMap<String, WatchOnlyAddress>,
|
||||
}
|
||||
|
||||
/// Watch-only address manager
|
||||
pub struct WatchOnlyManager {
|
||||
/// Data directory
|
||||
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||
/// Watch-only addresses
|
||||
pub data: Arc<RwLock<WatchOnlyData>>,
|
||||
}
|
||||
|
||||
impl WatchOnlyManager {
|
||||
/// Create a new manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data_dir: Arc::new(RwLock::new(None)),
|
||||
data: Arc::new(RwLock::new(WatchOnlyData::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the data directory
|
||||
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
|
||||
tokio::fs::create_dir_all(&path).await
|
||||
.map_err(|e| Error::Io(e))?;
|
||||
|
||||
let mut data_dir = self.data_dir.write().await;
|
||||
*data_dir = Some(path);
|
||||
|
||||
// Load existing data
|
||||
drop(data_dir);
|
||||
self.load().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the file path
|
||||
async fn file_path(&self) -> Result<PathBuf> {
|
||||
let data_dir = self.data_dir.read().await;
|
||||
data_dir
|
||||
.as_ref()
|
||||
.map(|p| p.join(WATCH_ONLY_FILE))
|
||||
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
|
||||
}
|
||||
|
||||
/// Load watch-only addresses from disk
|
||||
pub async fn load(&self) -> Result<()> {
|
||||
let path = self.file_path().await?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let json = tokio::fs::read_to_string(&path).await
|
||||
.map_err(|e| Error::Io(e))?;
|
||||
|
||||
let loaded: WatchOnlyData = serde_json::from_str(&json)
|
||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||
|
||||
let mut data = self.data.write().await;
|
||||
*data = loaded;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save watch-only addresses to disk
|
||||
pub async fn save(&self) -> Result<()> {
|
||||
let path = self.file_path().await?;
|
||||
let data = self.data.read().await;
|
||||
|
||||
let json = serde_json::to_string_pretty(&*data)
|
||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||
|
||||
tokio::fs::write(&path, json).await
|
||||
.map_err(|e| Error::Io(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a watch-only address
|
||||
pub async fn add_address(
|
||||
&self,
|
||||
address: String,
|
||||
label: String,
|
||||
network: String,
|
||||
notes: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<WatchOnlyAddress> {
|
||||
// Validate address format (basic check)
|
||||
if !address.starts_with("synor1") && !address.starts_with("tsynor1") {
|
||||
return Err(Error::Validation("Invalid address format".to_string()));
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
{
|
||||
let data = self.data.read().await;
|
||||
if data.addresses.contains_key(&address) {
|
||||
return Err(Error::Validation("Address already exists".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let entry = WatchOnlyAddress {
|
||||
address: address.clone(),
|
||||
label,
|
||||
network,
|
||||
added_at: current_timestamp(),
|
||||
notes,
|
||||
tags,
|
||||
cached_balance: None,
|
||||
balance_updated_at: None,
|
||||
};
|
||||
|
||||
{
|
||||
let mut data = self.data.write().await;
|
||||
data.addresses.insert(address.clone(), entry.clone());
|
||||
}
|
||||
|
||||
self.save().await?;
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Update a watch-only address
|
||||
pub async fn update_address(
|
||||
&self,
|
||||
address: &str,
|
||||
label: Option<String>,
|
||||
notes: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<WatchOnlyAddress> {
|
||||
let mut data = self.data.write().await;
|
||||
|
||||
let entry = data.addresses.get_mut(address)
|
||||
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||
|
||||
if let Some(l) = label {
|
||||
entry.label = l;
|
||||
}
|
||||
if let Some(n) = notes {
|
||||
entry.notes = Some(n);
|
||||
}
|
||||
if let Some(t) = tags {
|
||||
entry.tags = t;
|
||||
}
|
||||
|
||||
let updated = entry.clone();
|
||||
drop(data);
|
||||
|
||||
self.save().await?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Remove a watch-only address
|
||||
pub async fn remove_address(&self, address: &str) -> Result<()> {
|
||||
let mut data = self.data.write().await;
|
||||
|
||||
if data.addresses.remove(address).is_none() {
|
||||
return Err(Error::NotFound("Watch-only address not found".to_string()));
|
||||
}
|
||||
|
||||
drop(data);
|
||||
self.save().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all watch-only addresses
|
||||
pub async fn list_addresses(&self) -> Vec<WatchOnlyAddress> {
|
||||
let data = self.data.read().await;
|
||||
let mut addresses: Vec<WatchOnlyAddress> = data.addresses.values().cloned().collect();
|
||||
addresses.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
addresses
|
||||
}
|
||||
|
||||
/// Get a specific watch-only address
|
||||
pub async fn get_address(&self, address: &str) -> Option<WatchOnlyAddress> {
|
||||
let data = self.data.read().await;
|
||||
data.addresses.get(address).cloned()
|
||||
}
|
||||
|
||||
/// Update cached balance for an address
|
||||
pub async fn update_balance(&self, address: &str, balance: u64) -> Result<()> {
|
||||
let mut data = self.data.write().await;
|
||||
|
||||
let entry = data.addresses.get_mut(address)
|
||||
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||
|
||||
entry.cached_balance = Some(balance);
|
||||
entry.balance_updated_at = Some(current_timestamp());
|
||||
|
||||
drop(data);
|
||||
self.save().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get addresses by tag
|
||||
pub async fn get_addresses_by_tag(&self, tag: &str) -> Vec<WatchOnlyAddress> {
|
||||
let data = self.data.read().await;
|
||||
data.addresses.values()
|
||||
.filter(|a| a.tags.contains(&tag.to_string()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all unique tags
|
||||
pub async fn get_all_tags(&self) -> Vec<String> {
|
||||
let data = self.data.read().await;
|
||||
let mut tags: Vec<String> = data.addresses.values()
|
||||
.flat_map(|a| a.tags.clone())
|
||||
.collect();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
pub async fn count(&self) -> usize {
|
||||
let data = self.data.read().await;
|
||||
data.addresses.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WatchOnlyManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current timestamp
|
||||
fn current_timestamp() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Synor Wallet",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"identifier": "io.synor.wallet",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
|
@ -34,9 +34,17 @@
|
|||
}
|
||||
},
|
||||
"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}}"
|
||||
|
|
@ -45,7 +53,8 @@
|
|||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification": {}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
|
|
|||
|
|
@ -8,88 +8,24 @@ import UpdateBanner from './components/UpdateBanner';
|
|||
|
||||
// Hooks
|
||||
import { useTrayEvents } from './hooks/useTrayEvents';
|
||||
import { useNodeEvents } from './hooks/useNodeEvents';
|
||||
import { useMiningEvents } from './hooks/useMiningEvents';
|
||||
|
||||
// Onboarding Pages
|
||||
// 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 */}
|
||||
|
|
@ -120,355 +56,34 @@ function App() {
|
|||
|
||||
{/* Protected routes (require unlocked wallet) */}
|
||||
<Route element={<Layout />}>
|
||||
{/* Core Wallet */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
isUnlocked ? <Dashboard /> : <Navigate to="/unlock" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/send"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Send />
|
||||
</ProtectedRoute>
|
||||
isUnlocked ? <Send /> : <Navigate to="/unlock" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/receive"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Receive />
|
||||
</ProtectedRoute>
|
||||
isUnlocked ? <Receive /> : <Navigate to="/unlock" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<History />
|
||||
</ProtectedRoute>
|
||||
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<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>
|
||||
isUnlocked ? <Settings /> : <Navigate to="/unlock" replace />
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { X, Eye, EyeOff, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import { useWalletManagerStore } from '../store/walletManager';
|
||||
|
||||
interface ImportWalletModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportWalletModal({ onClose }: ImportWalletModalProps) {
|
||||
const [step, setStep] = useState<'form' | 'success'>('form');
|
||||
const [label, setLabel] = useState('');
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTestnet, setIsTestnet] = useState(true);
|
||||
const [importedAddress, setImportedAddress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { importWallet, isLoading } = useWalletManagerStore();
|
||||
|
||||
// Validate mnemonic word count
|
||||
const mnemonicWords = mnemonic.trim().split(/\s+/).filter(Boolean);
|
||||
const isValidWordCount = mnemonicWords.length === 12 || mnemonicWords.length === 24;
|
||||
|
||||
const handleImport = async () => {
|
||||
// Validation
|
||||
if (!label.trim()) {
|
||||
setError('Please enter a wallet label');
|
||||
return;
|
||||
}
|
||||
if (!isValidWordCount) {
|
||||
setError('Recovery phrase must be 12 or 24 words');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const address = await importWallet(
|
||||
label.trim(),
|
||||
mnemonic.trim().toLowerCase(),
|
||||
password,
|
||||
isTestnet
|
||||
);
|
||||
setImportedAddress(address);
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import wallet');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{step === 'form' ? 'Import Wallet' : 'Wallet Imported'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{step === 'form' && (
|
||||
<div className="space-y-4">
|
||||
{/* Wallet Label */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">
|
||||
Wallet Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g., Imported Wallet, Cold Storage"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recovery Phrase */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">
|
||||
Recovery Phrase
|
||||
</label>
|
||||
<textarea
|
||||
value={mnemonic}
|
||||
onChange={(e) => setMnemonic(e.target.value)}
|
||||
placeholder="Enter your 12 or 24 word recovery phrase..."
|
||||
rows={4}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Words: {mnemonicWords.length}
|
||||
{mnemonicWords.length > 0 && !isValidWordCount && (
|
||||
<span className="text-yellow-500 ml-2">
|
||||
(needs 12 or 24 words)
|
||||
</span>
|
||||
)}
|
||||
{isValidWordCount && (
|
||||
<span className="text-green-500 ml-2">
|
||||
<CheckCircle size={12} className="inline" />
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password for this wallet"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Network Selection */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">
|
||||
Network
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTestnet(true)}
|
||||
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||
isTestnet
|
||||
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Testnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsTestnet(false)}
|
||||
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||
!isTestnet
|
||||
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Mainnet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Warning */}
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="text-yellow-500 mt-0.5" size={14} />
|
||||
<p className="text-yellow-300/80 text-xs">
|
||||
Make sure you're entering your recovery phrase on a secure device.
|
||||
Never share your phrase with anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
|
||||
<AlertTriangle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle size={32} className="text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
Wallet Imported!
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Your wallet has been successfully imported and is ready to use.
|
||||
</p>
|
||||
<div className="bg-gray-800 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 mb-1">Wallet Address</p>
|
||||
<p className="text-synor-400 font-mono text-sm break-all">
|
||||
{importedAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||
{step === 'form' && (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isLoading || !isValidWordCount}
|
||||
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Importing...' : 'Import Wallet'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === 'success' && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,244 +8,88 @@ 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, 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 { lockWallet, networkStatus, balance } = useWalletStore();
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
{/* Balance display */}
|
||||
<div className="p-6 border-b border-gray-800">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
Balance
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{balance?.balanceHuman || '0 SYN'}
|
||||
</p>
|
||||
{balance?.pending ? (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
+ {(balance.pending / 100_000_000).toFixed(8)} SYN pending
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
|
||||
{/* 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 justify-between px-4 py-2.5 rounded-lg transition-colors ${
|
||||
`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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={18} />
|
||||
<Icon size={20} />
|
||||
{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-56 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
{/* Wallet Selector */}
|
||||
<div className="p-3 border-b border-gray-800">
|
||||
<WalletSelector
|
||||
onCreateWallet={() => setShowCreateModal(true)}
|
||||
onImportWallet={() => setShowImportModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Balance display */}
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{balance?.balanceHuman || '0 SYN'}
|
||||
</p>
|
||||
{balance?.pending ? (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
+ {(balance.pending / 100_000_000).toFixed(8)} pending
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
||||
{renderNavSection(navItems)}
|
||||
{renderNavSection(defiNavItems, 'DeFi')}
|
||||
{renderNavSection(advancedNavItems, 'Advanced')}
|
||||
{renderNavSection(infrastructureNavItems, 'Infrastructure')}
|
||||
{renderNavSection(privacyBridgeNavItems, 'Privacy & Bridge')}
|
||||
{renderNavSection(governanceNavItems, 'Governance')}
|
||||
{renderNavSection(toolsNavItems, 'Tools')}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<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="p-4 border-t border-gray-800 space-y-2">
|
||||
{/* Network status */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
{nodeStatus.isConnected ? (
|
||||
{networkStatus.connected ? (
|
||||
<>
|
||||
<Wifi size={14} className="text-green-400" />
|
||||
<span className="text-gray-400 text-xs">
|
||||
{nodeStatus.network || 'Connected'}
|
||||
{nodeStatus.isSyncing && ' (Syncing)'}
|
||||
<Wifi size={16} className="text-green-400" />
|
||||
<span className="text-gray-400">
|
||||
{networkStatus.network || 'Connected'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={14} className="text-red-400" />
|
||||
<span className="text-gray-400 text-xs">Disconnected</span>
|
||||
<WifiOff size={16} className="text-red-400" />
|
||||
<span className="text-gray-400">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 text-sm"
|
||||
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"
|
||||
>
|
||||
<Lock size={14} />
|
||||
<Lock size={16} />
|
||||
Lock Wallet
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -255,14 +99,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Settings,
|
||||
X,
|
||||
Send,
|
||||
Hammer,
|
||||
Coins,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useNotificationsStore,
|
||||
NotificationType,
|
||||
requestNotificationPermission,
|
||||
} from '../store/notifications';
|
||||
|
||||
interface NotificationsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<NotificationType, React.ReactNode> = {
|
||||
transaction: <Send size={16} className="text-blue-400" />,
|
||||
mining: <Hammer size={16} className="text-yellow-400" />,
|
||||
staking: <Coins size={16} className="text-purple-400" />,
|
||||
system: <Info size={16} className="text-gray-400" />,
|
||||
price: <AlertCircle size={16} className="text-green-400" />,
|
||||
};
|
||||
|
||||
export default function NotificationsPanel({ isOpen, onClose }: NotificationsPanelProps) {
|
||||
const {
|
||||
notifications,
|
||||
preferences,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll,
|
||||
updatePreferences,
|
||||
} = useNotificationsStore();
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
updatePreferences({ enabled: true });
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 60000) return 'Just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" onClick={onClose}>
|
||||
<div
|
||||
className="absolute right-4 top-16 w-96 max-h-[70vh] bg-gray-900 rounded-xl border border-gray-800 shadow-xl overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={20} className="text-synor-400" />
|
||||
<h2 className="font-semibold text-white">Notifications</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-synor-600 text-white text-xs rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Settings size={18} className="text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={18} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div className="p-4 border-b border-gray-800 bg-gray-800/50">
|
||||
<h3 className="text-sm font-medium text-white mb-3">Notification Settings</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Enable Notifications</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.enabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleEnableNotifications();
|
||||
} else {
|
||||
updatePreferences({ enabled: false });
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Transaction Alerts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.transactionAlerts}
|
||||
onChange={(e) =>
|
||||
updatePreferences({ transactionAlerts: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Mining Alerts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.miningAlerts}
|
||||
onChange={(e) =>
|
||||
updatePreferences({ miningAlerts: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Staking Alerts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.stakingAlerts}
|
||||
onChange={(e) =>
|
||||
updatePreferences({ stakingAlerts: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Price Alerts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.priceAlerts}
|
||||
onChange={(e) =>
|
||||
updatePreferences({ priceAlerts: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Sound</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.soundEnabled}
|
||||
onChange={(e) =>
|
||||
updatePreferences({ soundEnabled: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-synor-600 focus:ring-synor-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-sm text-synor-400 hover:text-synor-300"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{notifications.length > 0 ? (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 hover:bg-gray-800/50 transition-colors ${
|
||||
!notification.read ? 'bg-synor-600/5' : ''
|
||||
}`}
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-gray-800 rounded-lg">
|
||||
{TYPE_ICONS[notification.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4
|
||||
className={`font-medium ${
|
||||
notification.read ? 'text-gray-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</h4>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeNotification(notification.id);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<span className="text-xs text-gray-600 mt-1 block">
|
||||
{formatTime(notification.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{!notification.read && (
|
||||
<div className="w-2 h-2 bg-synor-400 rounded-full mt-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<BellOff size={32} className="mb-3 opacity-50" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bell button component to be used in the header/titlebar
|
||||
export function NotificationsBell() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { unreadCount } = useNotificationsStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="relative p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell size={20} className="text-gray-400" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-synor-600 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<NotificationsPanel isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +1,12 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { getCurrentWindow, mockWindow, isTauri } from '../lib/tauri';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
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, 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>
|
||||
);
|
||||
}
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,291 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// UI Components
|
||||
export { default as Layout } from './Layout';
|
||||
export { default as TitleBar } from './TitleBar';
|
||||
export { UpdateBanner } from './UpdateBanner';
|
||||
export { NotificationsBell } from './NotificationsPanel';
|
||||
|
||||
// Error Handling
|
||||
export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
// Loading States
|
||||
export {
|
||||
LoadingSpinner,
|
||||
LoadingOverlay,
|
||||
LoadingInline,
|
||||
LoadingButton,
|
||||
Skeleton,
|
||||
CardSkeleton,
|
||||
TableSkeleton,
|
||||
StatsSkeleton,
|
||||
} from './LoadingStates';
|
||||
|
||||
// Animations
|
||||
export {
|
||||
FadeIn,
|
||||
SlideIn,
|
||||
ScaleIn,
|
||||
StaggerChildren,
|
||||
Pulse,
|
||||
Bounce,
|
||||
CountUp,
|
||||
TypeWriter,
|
||||
} from './Animations';
|
||||
|
||||
// Multi-Wallet Components
|
||||
export { WalletSelector } from './WalletSelector';
|
||||
export { CreateWalletModal } from './CreateWalletModal';
|
||||
export { ImportWalletModal } from './ImportWalletModal';
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { listen, invoke } from '../lib/tauri';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { listen } from '../lib/tauri';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,503 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,377 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Send,
|
||||
X,
|
||||
Check,
|
||||
Copy,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useBatchSendStore,
|
||||
useValidRecipientCount,
|
||||
useIsBatchReady,
|
||||
BatchRecipient,
|
||||
} from '../../store/batchSend';
|
||||
|
||||
export default function BatchSendDashboard() {
|
||||
const {
|
||||
recipients,
|
||||
summary,
|
||||
isLoading,
|
||||
error,
|
||||
lastTxId,
|
||||
addRecipient,
|
||||
removeRecipient,
|
||||
updateRecipient,
|
||||
clearRecipients,
|
||||
importFromCsv,
|
||||
createBatchTransaction,
|
||||
signAndBroadcast,
|
||||
} = useBatchSendStore();
|
||||
|
||||
const validCount = useValidRecipientCount();
|
||||
const isReady = useIsBatchReady();
|
||||
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [txHex, setTxHex] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCreateTransaction = async () => {
|
||||
try {
|
||||
const hex = await createBatchTransaction();
|
||||
setTxHex(hex);
|
||||
setShowConfirmModal(true);
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSend = async () => {
|
||||
if (!txHex) return;
|
||||
try {
|
||||
await signAndBroadcast(txHex);
|
||||
setShowConfirmModal(false);
|
||||
setShowSuccessModal(true);
|
||||
setTxHex(null);
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTxId = async () => {
|
||||
if (!lastTxId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastTxId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API failure
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTemplate = () => {
|
||||
const template = 'address,amount,label\nsynor1...,10.5,Payment 1\nsynor1...,25.0,Payment 2';
|
||||
const blob = new Blob([template], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'batch_template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Layers className="text-synor-400" />
|
||||
Batch Send
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Send to multiple addresses in a single transaction
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExportTemplate}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||
>
|
||||
<Download size={14} />
|
||||
Template
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={clearRecipients}
|
||||
disabled={recipients.length === 1 && !recipients[0].address}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertTriangle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => useBatchSendStore.setState({ error: null })}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipients List */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-800 text-sm text-gray-500">
|
||||
<div className="col-span-1">#</div>
|
||||
<div className="col-span-5">Recipient Address</div>
|
||||
<div className="col-span-2">Amount (SYN)</div>
|
||||
<div className="col-span-3">Label (optional)</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<div className="divide-y divide-gray-800/50">
|
||||
{recipients.map((recipient, index) => (
|
||||
<RecipientRow
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
index={index}
|
||||
onUpdate={(updates) => updateRecipient(recipient.id, updates)}
|
||||
onRemove={() => removeRecipient(recipient.id)}
|
||||
canRemove={recipients.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<div className="p-3 border-t border-gray-800">
|
||||
<button
|
||||
onClick={addRecipient}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors w-full justify-center"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Recipient
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Transaction Summary</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Recipients</p>
|
||||
<p className="text-xl font-bold text-white">{summary.recipientCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Total Amount</p>
|
||||
<p className="text-xl font-bold text-synor-400">{summary.totalAmountHuman}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Estimated Fee</p>
|
||||
<p className="text-xl font-bold text-yellow-400">{summary.estimatedFeeHuman}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Total (incl. fee)</p>
|
||||
<p className="text-xl font-bold text-white">{summary.totalWithFeeHuman}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleCreateTransaction}
|
||||
disabled={!isReady || isLoading}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={18} />
|
||||
{isLoading ? 'Creating Transaction...' : `Send to ${validCount} Recipients`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||
<h4 className="text-blue-300 font-medium mb-2">About Batch Transactions</h4>
|
||||
<ul className="text-blue-300/70 text-sm space-y-1">
|
||||
<li>• Batch transactions combine multiple sends into a single transaction</li>
|
||||
<li>• This saves on fees compared to sending individually</li>
|
||||
<li>• All recipients receive their funds when the transaction confirms</li>
|
||||
<li>• You can import recipients from a CSV file</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImportModal && (
|
||||
<ImportCsvModal
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={(csv) => {
|
||||
importFromCsv(csv);
|
||||
setShowImportModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Modal */}
|
||||
{showConfirmModal && summary && (
|
||||
<ConfirmModal
|
||||
summary={summary}
|
||||
recipients={recipients.filter((r) => r.isValid)}
|
||||
isLoading={isLoading}
|
||||
onConfirm={handleConfirmSend}
|
||||
onCancel={() => {
|
||||
setShowConfirmModal(false);
|
||||
setTxHex(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Success Modal */}
|
||||
{showSuccessModal && lastTxId && (
|
||||
<SuccessModal
|
||||
txId={lastTxId}
|
||||
onClose={() => {
|
||||
setShowSuccessModal(false);
|
||||
clearRecipients();
|
||||
}}
|
||||
onCopy={handleCopyTxId}
|
||||
copied={copied}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient row component
|
||||
function RecipientRow({
|
||||
recipient,
|
||||
index,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: {
|
||||
recipient: BatchRecipient;
|
||||
index: number;
|
||||
onUpdate: (updates: Partial<BatchRecipient>) => void;
|
||||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`grid grid-cols-12 gap-4 p-4 ${recipient.error ? 'bg-red-500/5' : ''}`}>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<span className="text-gray-500">{index + 1}</span>
|
||||
{recipient.isValid && <Check size={14} className="text-green-400 ml-2" />}
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<input
|
||||
type="text"
|
||||
value={recipient.address}
|
||||
onChange={(e) => onUpdate({ address: e.target.value })}
|
||||
placeholder="synor1... or tsynor1..."
|
||||
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-white placeholder-gray-500 font-mono text-sm focus:outline-none ${
|
||||
recipient.error && recipient.address
|
||||
? 'border-red-500/50'
|
||||
: 'border-gray-700 focus:border-synor-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
value={recipient.amount || ''}
|
||||
onChange={(e) => onUpdate({ amount: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.00000001"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<input
|
||||
type="text"
|
||||
value={recipient.label || ''}
|
||||
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||
placeholder="Label"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end">
|
||||
<button
|
||||
onClick={onRemove}
|
||||
disabled={!canRemove}
|
||||
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{recipient.error && (
|
||||
<div className="col-span-12 -mt-2">
|
||||
<p className="text-red-400 text-xs">{recipient.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Import CSV modal
|
||||
function ImportCsvModal({
|
||||
onClose,
|
||||
onImport,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onImport: (csv: string) => void;
|
||||
}) {
|
||||
const [csvContent, setCsvContent] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setCsvContent(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-lg shadow-2xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FileSpreadsheet size={20} />
|
||||
Import from CSV
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm mb-3">
|
||||
Upload a CSV file or paste content below. Format: address,amount,label
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full py-8 border-2 border-dashed border-gray-700 rounded-lg text-gray-400 hover:border-synor-500 hover:text-synor-400 transition-colors"
|
||||
>
|
||||
<Upload className="mx-auto mb-2" size={24} />
|
||||
Click to upload CSV file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500 text-sm">or paste content</div>
|
||||
|
||||
<textarea
|
||||
value={csvContent}
|
||||
onChange={(e) => setCsvContent(e.target.value)}
|
||||
placeholder="address,amount,label synor1abc...,10.5,Payment 1 synor1def...,25.0,Payment 2"
|
||||
rows={6}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onImport(csvContent)}
|
||||
disabled={!csvContent.trim()}
|
||||
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm transaction modal
|
||||
function ConfirmModal({
|
||||
summary,
|
||||
recipients,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
summary: { totalAmountHuman: string; estimatedFeeHuman: string; totalWithFeeHuman: string };
|
||||
recipients: BatchRecipient[];
|
||||
isLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Confirm Batch Transaction</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-800/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Total Amount</span>
|
||||
<span className="text-white font-medium">{summary.totalAmountHuman}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Network Fee</span>
|
||||
<span className="text-yellow-400">{summary.estimatedFeeHuman}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-700 pt-2 flex justify-between">
|
||||
<span className="text-gray-300 font-medium">Total</span>
|
||||
<span className="text-synor-400 font-bold">{summary.totalWithFeeHuman}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients list */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Sending to {recipients.length} recipients:</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{recipients.slice(0, 5).map((r, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className="text-gray-400 font-mono truncate max-w-[200px]">
|
||||
{r.address.slice(0, 12)}...{r.address.slice(-6)}
|
||||
</span>
|
||||
<span className="text-white">{r.amount} SYN</span>
|
||||
</div>
|
||||
))}
|
||||
{recipients.length > 5 && (
|
||||
<p className="text-gray-500 text-sm">
|
||||
... and {recipients.length - 5} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||
<p className="text-yellow-300 text-sm">
|
||||
Please verify all recipients and amounts. This transaction cannot be reversed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Confirm & Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success modal
|
||||
function SuccessModal({
|
||||
txId,
|
||||
onClose,
|
||||
onCopy,
|
||||
copied,
|
||||
}: {
|
||||
txId: string;
|
||||
onClose: () => void;
|
||||
onCopy: () => void;
|
||||
copied: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl text-center">
|
||||
<div className="p-8">
|
||||
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Check size={32} className="text-green-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Transaction Sent!</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Your batch transaction has been broadcast to the network.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-gray-500 mb-1">Transaction ID</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-synor-400 text-sm font-mono break-all">
|
||||
{txId.slice(0, 16)}...{txId.slice(-16)}
|
||||
</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal, Info, Send, Loader2 } from 'lucide-react';
|
||||
import { useCliStore } from '../../store/cli';
|
||||
|
||||
export default function CliDashboard() {
|
||||
const {
|
||||
history,
|
||||
isExecuting,
|
||||
execute,
|
||||
loadHistory,
|
||||
clearOutput,
|
||||
navigateHistory,
|
||||
} = useCliStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
// Auto-scroll to bottom when history changes
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
const handleCommand = async () => {
|
||||
if (!input.trim() || isExecuting) return;
|
||||
const cmd = input.trim();
|
||||
setInput('');
|
||||
await execute(cmd);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCommand();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = navigateHistory('up');
|
||||
if (prev !== null) setInput(prev);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = navigateHistory('down');
|
||||
if (next !== null) setInput(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Terminal className="text-synor-400" />
|
||||
CLI Mode
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Terminal interface for power users</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearOutput}
|
||||
className="px-3 py-1 bg-gray-800 rounded text-sm hover:bg-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="p-4 font-mono text-sm bg-black/50 h-96 overflow-y-auto"
|
||||
>
|
||||
{/* Welcome message if no history */}
|
||||
{history.length === 0 && (
|
||||
<div className="text-gray-500">
|
||||
<p className="text-synor-400">{'>'} Welcome to Synor CLI Mode</p>
|
||||
<p className="text-synor-400">{'>'} Type "help" for available commands</p>
|
||||
<p className="text-synor-400">{'>'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command history */}
|
||||
{history.map((result, i) => (
|
||||
<div key={i} className="mb-2">
|
||||
<div className="text-synor-400">
|
||||
{'>'} {result.command}
|
||||
</div>
|
||||
<div className={`whitespace-pre-wrap ${result.isError ? 'text-red-400' : 'text-gray-300'}`}>
|
||||
{result.output}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isExecuting && (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 p-3 flex gap-2">
|
||||
<span className="text-synor-400 font-mono">{'>'}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter command..."
|
||||
className="flex-1 bg-transparent text-white font-mono outline-none"
|
||||
autoFocus
|
||||
disabled={isExecuting}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCommand}
|
||||
disabled={isExecuting || !input.trim()}
|
||||
className="p-2 bg-synor-600 rounded-lg hover:bg-synor-700 disabled:opacity-50"
|
||||
>
|
||||
{isExecuting ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 className="font-medium mb-3">Quick Commands</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['help', 'balance', 'address', 'status', 'utxos', 'peers'].map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
onClick={() => { setInput(cmd); }}
|
||||
className="px-3 py-1 bg-gray-800 rounded font-mono text-sm hover:bg-gray-700"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Reference */}
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 className="font-medium mb-3">Command Reference</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="font-mono text-synor-400">help</div>
|
||||
<div className="text-gray-400">Show all available commands</div>
|
||||
|
||||
<div className="font-mono text-synor-400">balance</div>
|
||||
<div className="text-gray-400">Show wallet balance</div>
|
||||
|
||||
<div className="font-mono text-synor-400">address</div>
|
||||
<div className="text-gray-400">Show wallet addresses</div>
|
||||
|
||||
<div className="font-mono text-synor-400">send <addr> <amount></div>
|
||||
<div className="text-gray-400">Send SYN to address</div>
|
||||
|
||||
<div className="font-mono text-synor-400">utxos</div>
|
||||
<div className="text-gray-400">List unspent outputs</div>
|
||||
|
||||
<div className="font-mono text-synor-400">history</div>
|
||||
<div className="text-gray-400">Transaction history</div>
|
||||
|
||||
<div className="font-mono text-synor-400">status</div>
|
||||
<div className="text-gray-400">Network status</div>
|
||||
|
||||
<div className="font-mono text-synor-400">peers</div>
|
||||
<div className="text-gray-400">Connected peers</div>
|
||||
|
||||
<div className="font-mono text-synor-400">clear</div>
|
||||
<div className="text-gray-400">Clear output</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||
<p className="text-sm text-gray-400">
|
||||
CLI mode provides a terminal interface for advanced users who prefer keyboard-driven
|
||||
interaction. Use ↑/↓ arrows to navigate command history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,563 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,439 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,501 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Usb,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Check,
|
||||
Copy,
|
||||
Shield,
|
||||
Cpu,
|
||||
Key,
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useHardwareStore,
|
||||
HardwareDevice,
|
||||
HardwareAddress,
|
||||
} from '../../store/hardware';
|
||||
|
||||
export default function HardwareWalletPage() {
|
||||
const {
|
||||
devices,
|
||||
selectedDevice,
|
||||
isScanning,
|
||||
error,
|
||||
clearError,
|
||||
detectDevices,
|
||||
selectDevice,
|
||||
getAddress,
|
||||
} = useHardwareStore();
|
||||
|
||||
const [addresses, setAddresses] = useState<HardwareAddress[]>([]);
|
||||
const [loadingAddress, setLoadingAddress] = useState(false);
|
||||
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||
const [accountIndex, setAccountIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
detectDevices();
|
||||
}, [detectDevices]);
|
||||
|
||||
const handleSelectDevice = (device: HardwareDevice) => {
|
||||
selectDevice(device);
|
||||
setAddresses([]);
|
||||
};
|
||||
|
||||
const handleGetAddress = async () => {
|
||||
if (!selectedDevice) return;
|
||||
|
||||
setLoadingAddress(true);
|
||||
try {
|
||||
const address = await getAddress(selectedDevice.id, accountIndex);
|
||||
setAddresses((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((a) => a.path === address.path)) return prev;
|
||||
return [...prev, address];
|
||||
});
|
||||
setAccountIndex((prev) => prev + 1);
|
||||
} catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
setLoadingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyAddress = (address: string) => {
|
||||
navigator.clipboard.writeText(address);
|
||||
setCopiedAddress(address);
|
||||
setTimeout(() => setCopiedAddress(null), 2000);
|
||||
};
|
||||
|
||||
const getDeviceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ledger':
|
||||
return <Cpu className="text-blue-400" size={24} />;
|
||||
case 'trezor':
|
||||
return <Shield className="text-green-400" size={24} />;
|
||||
default:
|
||||
return <Usb className="text-gray-400" size={24} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Hardware Wallet</h1>
|
||||
<p className="text-gray-400 mt-1">Connect Ledger or Trezor devices</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => detectDevices()}
|
||||
disabled={isScanning}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} className={isScanning ? 'animate-spin' : ''} />
|
||||
Scan Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="text-red-400" size={20} />
|
||||
<p className="text-red-400 flex-1">{error}</p>
|
||||
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Fingerprint className="text-blue-400 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-400">Secure Hardware Signing</h3>
|
||||
<p className="text-sm text-blue-300/70 mt-1">
|
||||
Your private keys never leave the hardware device. All transactions are
|
||||
signed directly on the device for maximum security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Device List */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Available Devices</h2>
|
||||
|
||||
{isScanning ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="animate-spin text-synor-400" size={32} />
|
||||
</div>
|
||||
) : devices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{devices.map((device) => (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => handleSelectDevice(device)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-lg border transition-colors ${
|
||||
selectedDevice?.id === device.id
|
||||
? 'bg-synor-600/20 border-synor-500'
|
||||
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="p-2 bg-gray-900 rounded-lg">
|
||||
{getDeviceIcon(device.deviceType)}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<h3 className="font-medium text-white">{device.name}</h3>
|
||||
<p className="text-sm text-gray-400 capitalize">
|
||||
{device.deviceType}
|
||||
{device.firmwareVersion && ` • v${device.firmwareVersion}`}
|
||||
</p>
|
||||
</div>
|
||||
{device.connected && (
|
||||
<span className="px-2 py-1 bg-green-600/20 text-green-400 text-xs rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{selectedDevice?.id === device.id && (
|
||||
<Check className="text-synor-400" size={20} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Usb className="mx-auto mb-4 text-gray-600" size={48} />
|
||||
<p className="text-gray-500">No devices found</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Connect your hardware wallet and click "Scan Devices"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device Actions */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
{selectedDevice ? selectedDevice.name : 'Select a Device'}
|
||||
</h2>
|
||||
|
||||
{selectedDevice ? (
|
||||
<div className="space-y-6">
|
||||
{/* Get Address */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||
Derive Addresses
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleGetAddress}
|
||||
disabled={loadingAddress}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingAddress ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
Deriving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key size={16} />
|
||||
Get Next Address
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Confirm on your device to reveal the address
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Address List */}
|
||||
{addresses.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||
Derived Addresses
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{addresses.map((addr, index) => (
|
||||
<div
|
||||
key={addr.path}
|
||||
className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
|
||||
>
|
||||
<span className="text-sm text-gray-500 w-8">#{index}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-white truncate block">
|
||||
{addr.address}
|
||||
</code>
|
||||
<span className="text-xs text-gray-500">{addr.path}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyAddress(addr.address)}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
{copiedAddress === addr.address ? (
|
||||
<Check size={16} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={16} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
<div className="pt-4 border-t border-gray-800">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">
|
||||
Device Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Type</p>
|
||||
<p className="text-white capitalize">{selectedDevice.deviceType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Status</p>
|
||||
<p className={selectedDevice.connected ? 'text-green-400' : 'text-red-400'}>
|
||||
{selectedDevice.connected ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
</div>
|
||||
{selectedDevice.firmwareVersion && (
|
||||
<div>
|
||||
<p className="text-gray-500">Firmware</p>
|
||||
<p className="text-white">v{selectedDevice.firmwareVersion}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">
|
||||
Select a device from the list to manage addresses and sign transactions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Setup Instructions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Connect Device</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Plug in your Ledger or Trezor via USB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Unlock Device</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Enter your PIN on the hardware wallet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-synor-600/20 rounded-full flex items-center justify-center text-synor-400 font-bold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Open App</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Open the Synor app on your device
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
|
|
|
|||
|
|
@ -1,264 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,541 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,921 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
QrCode,
|
||||
Camera,
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '../../lib/tauri';
|
||||
|
||||
interface PaymentRequest {
|
||||
address: string;
|
||||
amount?: number;
|
||||
label?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function QRScannerPage() {
|
||||
const [activeTab, setActiveTab] = useState<'generate' | 'scan'>('generate');
|
||||
const [address, setAddress] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [copiedUri, setCopiedUri] = useState(false);
|
||||
const [scannedData, setScannedData] = useState<PaymentRequest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generatePaymentUri = () => {
|
||||
let uri = `synor:${address}`;
|
||||
const params: string[] = [];
|
||||
|
||||
if (amount) params.push(`amount=${amount}`);
|
||||
if (label) params.push(`label=${encodeURIComponent(label)}`);
|
||||
if (message) params.push(`message=${encodeURIComponent(message)}`);
|
||||
|
||||
if (params.length > 0) {
|
||||
uri += '?' + params.join('&');
|
||||
}
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
const handleGenerateQR = async () => {
|
||||
if (!address) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const uri = generatePaymentUri();
|
||||
const qrData = await invoke<string>('qr_generate', {
|
||||
data: uri,
|
||||
size: 256,
|
||||
});
|
||||
setQrCodeData(qrData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate QR code');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteUri = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
parsePaymentUri(text);
|
||||
} catch {
|
||||
setError('Failed to read clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const parsePaymentUri = async (uri: string) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const parsed = await invoke<PaymentRequest>('qr_parse_payment', { uri });
|
||||
setScannedData(parsed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid payment URI');
|
||||
}
|
||||
};
|
||||
|
||||
const copyUri = () => {
|
||||
const uri = generatePaymentUri();
|
||||
navigator.clipboard.writeText(uri);
|
||||
setCopiedUri(true);
|
||||
setTimeout(() => setCopiedUri(false), 2000);
|
||||
};
|
||||
|
||||
const downloadQR = () => {
|
||||
if (!qrCodeData) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `synor-payment-${Date.now()}.png`;
|
||||
link.href = qrCodeData;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">QR Code</h1>
|
||||
<p className="text-gray-400 mt-1">Generate and scan payment QR codes</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="text-red-400" size={20} />
|
||||
<p className="text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-gray-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('generate')}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === 'generate'
|
||||
? 'text-synor-400 border-b-2 border-synor-400'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Generate QR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scan')}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === 'scan'
|
||||
? 'text-synor-400 border-b-2 border-synor-400'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Parse Payment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generate Tab */}
|
||||
{activeTab === 'generate' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Form */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Payment Details</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Receiving Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="synor1..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Amount (SYN) - Optional
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Label - Optional
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g., Coffee Shop"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Message - Optional
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="e.g., Payment for order #123"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateQR}
|
||||
disabled={!address || isGenerating}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<RefreshCw size={18} className="animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QrCode size={18} />
|
||||
Generate QR Code
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Display */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">QR Code</h2>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{qrCodeData ? (
|
||||
<>
|
||||
<div className="p-4 bg-white rounded-xl">
|
||||
<img
|
||||
src={qrCodeData}
|
||||
alt="Payment QR Code"
|
||||
className="w-64 h-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full">
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Payment URI
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={generatePaymentUri()}
|
||||
readOnly
|
||||
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={copyUri}
|
||||
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{copiedUri ? (
|
||||
<Check size={18} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={18} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={downloadQR}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<Download size={18} />
|
||||
Download QR Code
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-64 h-64 bg-gray-800 rounded-xl flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<QrCode size={48} className="mx-auto mb-2 opacity-50" />
|
||||
<p>Enter details to generate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan Tab */}
|
||||
{activeTab === 'scan' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Input */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Parse Payment URI</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Paste a Synor payment URI to decode its contents. The URI format is:
|
||||
</p>
|
||||
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-gray-300 break-all">
|
||||
synor:{'<address>'}?amount={'<amount>'}&label={'<label>'}
|
||||
</code>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Payment URI
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="Paste synor:... URI here"
|
||||
rows={3}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
parsePaymentUri(e.target.value);
|
||||
} else {
|
||||
setScannedData(null);
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePasteUri}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
<Upload size={18} />
|
||||
Paste from Clipboard
|
||||
</button>
|
||||
|
||||
<div className="pt-4 border-t border-gray-800">
|
||||
<p className="text-sm text-gray-500">
|
||||
Note: Camera scanning is not available in desktop apps. You can use
|
||||
your phone's camera to scan QR codes and copy the resulting URI here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parsed Result */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Payment Request</h2>
|
||||
|
||||
{scannedData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-900/20 border border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||||
<Check size={18} />
|
||||
<span className="font-medium">Valid Payment Request</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 mb-1">Address</label>
|
||||
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-white break-all">
|
||||
{scannedData.address}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{scannedData.amount !== undefined && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 mb-1">Amount</label>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{scannedData.amount} SYN
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scannedData.label && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 mb-1">Label</label>
|
||||
<p className="text-white">{scannedData.label}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scannedData.message && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-500 mb-1">Message</label>
|
||||
<p className="text-white">{scannedData.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// Navigate to send page with pre-filled data
|
||||
window.location.href = `/send?to=${scannedData.address}${
|
||||
scannedData.amount ? `&amount=${scannedData.amount}` : ''
|
||||
}`;
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Send Payment
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Camera size={48} className="mx-auto mb-2 opacity-50" />
|
||||
<p>Paste a payment URI to decode</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { Copy, Check, Plus, QrCode } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
|
||||
export default function Receive() {
|
||||
|
|
@ -52,16 +51,12 @@ export default function Receive() {
|
|||
<span className="text-sm text-gray-400">Primary Address</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"
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Address with copy */}
|
||||
|
|
|
|||
|
|
@ -1,546 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Server, Plus, Trash2, CheckCircle, RefreshCw, Info, Wifi } from 'lucide-react';
|
||||
|
||||
interface RpcProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
export default function RpcProfilesDashboard() {
|
||||
const [profiles] = useState<RpcProfile[]>([
|
||||
{ id: '1', name: 'Default Mainnet', url: 'https://rpc.synor.io', isActive: true, latency: 45 },
|
||||
{ id: '2', name: 'Testnet', url: 'https://testnet.synor.io', isActive: false, latency: 52 },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Server className="text-synor-400" />
|
||||
Custom RPC Profiles
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Multiple endpoints with automatic failover</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
|
||||
<Plus size={18} />
|
||||
Add Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`bg-gray-900 rounded-xl p-4 border ${
|
||||
profile.isActive ? 'border-synor-500' : 'border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{profile.isActive ? (
|
||||
<CheckCircle className="text-green-400" size={20} />
|
||||
) : (
|
||||
<Wifi className="text-gray-500" size={20} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{profile.name}</p>
|
||||
<p className="text-sm text-gray-500 font-mono">{profile.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{profile.latency && (
|
||||
<span className={`text-sm ${profile.latency < 100 ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
{profile.latency}ms
|
||||
</span>
|
||||
)}
|
||||
<button className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 className="font-medium mb-3">Failover Settings</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Auto-failover on disconnect</span>
|
||||
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Retry failed requests</span>
|
||||
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Latency-based routing</span>
|
||||
<input type="checkbox" className="rounded bg-gray-800" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||
<p className="text-sm text-gray-400">
|
||||
Multiple RPC profiles ensure reliability. If one endpoint fails, the wallet
|
||||
automatically switches to the next available endpoint.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Send as SendIcon, AlertTriangle, Check } from 'lucide-react';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Server,
|
||||
Shield,
|
||||
|
|
|
|||
|
|
@ -1,348 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,828 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Wrench, Info, AlertTriangle, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function TxBuilderDashboard() {
|
||||
const [outputs, setOutputs] = useState([{ address: '', amount: '' }]);
|
||||
|
||||
const addOutput = () => {
|
||||
setOutputs([...outputs, { address: '', amount: '' }]);
|
||||
};
|
||||
|
||||
const removeOutput = (index: number) => {
|
||||
if (outputs.length > 1) {
|
||||
setOutputs(outputs.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||
<Wrench className="text-synor-400" />
|
||||
Transaction Builder
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Advanced custom transaction crafting</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-200">Advanced Feature</p>
|
||||
<p className="text-sm text-yellow-200/70">
|
||||
Transaction builder is for advanced users. Incorrect transactions may result in
|
||||
lost funds. Use with caution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 className="font-medium mb-4">Outputs</h3>
|
||||
<div className="space-y-3">
|
||||
{outputs.map((output, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Address"
|
||||
value={output.address}
|
||||
onChange={(e) => {
|
||||
const newOutputs = [...outputs];
|
||||
newOutputs[index].address = e.target.value;
|
||||
setOutputs(newOutputs);
|
||||
}}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Amount (SYN)"
|
||||
value={output.amount}
|
||||
onChange={(e) => {
|
||||
const newOutputs = [...outputs];
|
||||
newOutputs[index].amount = e.target.value;
|
||||
setOutputs(newOutputs);
|
||||
}}
|
||||
className="w-40 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeOutput(index)}
|
||||
className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400"
|
||||
disabled={outputs.length === 1}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addOutput}
|
||||
className="mt-3 px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<h3 className="font-medium mb-4">Advanced Options</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Fee Rate (sompi/byte)</label>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue="1"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Locktime</label>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue="0"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button className="flex-1 px-4 py-3 bg-gray-800 rounded-lg">
|
||||
Preview Transaction
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-3 bg-synor-600 rounded-lg">
|
||||
Create & Sign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||
<p className="text-sm text-gray-400">
|
||||
The transaction builder allows you to craft custom transactions with multiple
|
||||
outputs, specific fee rates, and advanced options like timelocks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Lock, KeyRound, Download, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Lock } from 'lucide-react';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
|
||||
export default function Unlock() {
|
||||
const navigate = useNavigate();
|
||||
const { unlockWallet, setInitialized } = useWalletStore();
|
||||
const { unlockWallet } = 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();
|
||||
|
|
@ -32,16 +31,6 @@ 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">
|
||||
|
|
@ -87,73 +76,8 @@ 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,517 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Lock,
|
||||
Unlock,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Timer,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Using Timer as VaultIcon since lucide-react doesn't export Vault
|
||||
const VaultIcon = Timer;
|
||||
import {
|
||||
useVaultsStore,
|
||||
Vault,
|
||||
formatTimeRemaining,
|
||||
getVaultStatusColor,
|
||||
LOCK_DURATION_PRESETS,
|
||||
} from '../../store/vaults';
|
||||
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||
|
||||
/**
|
||||
* Create Vault Modal
|
||||
*/
|
||||
function CreateVaultModal({ onClose }: { onClose: () => void }) {
|
||||
const { createVault, isLoading } = useVaultsStore();
|
||||
const [name, setName] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [duration, setDuration] = useState(86400); // 24 hours default
|
||||
const [customDuration, setCustomDuration] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const amountNum = parseFloat(amount);
|
||||
if (!name.trim()) {
|
||||
setError('Vault name is required');
|
||||
return;
|
||||
}
|
||||
if (isNaN(amountNum) || amountNum <= 0) {
|
||||
setError('Invalid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const lockDuration = useCustom
|
||||
? parseInt(customDuration) * 3600 // Custom is in hours
|
||||
: duration;
|
||||
|
||||
if (lockDuration < 60) {
|
||||
setError('Lock duration must be at least 1 minute');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createVault({
|
||||
name: name.trim(),
|
||||
amount: Math.floor(amountNum * 100_000_000), // Convert SYN to sompi
|
||||
lockDurationSecs: lockDuration,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create vault');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<VaultIcon className="text-synor-400" size={24} />
|
||||
Create Time-Locked Vault
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Vault Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Savings Goal, Emergency Fund"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.00000001"
|
||||
min="0"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00000000"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Lock Duration</label>
|
||||
<div className="grid grid-cols-4 gap-2 mb-2">
|
||||
{LOCK_DURATION_PRESETS.slice(0, 4).map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDuration(preset.value);
|
||||
setUseCustom(false);
|
||||
}}
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
!useCustom && duration === preset.value
|
||||
? 'bg-synor-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{LOCK_DURATION_PRESETS.slice(4).map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDuration(preset.value);
|
||||
setUseCustom(false);
|
||||
}}
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
!useCustom && duration === preset.value
|
||||
? 'bg-synor-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustom(true)}
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
useCustom
|
||||
? 'bg-synor-600 text-white'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{useCustom && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={customDuration}
|
||||
onChange={(e) => setCustomDuration(e.target.value)}
|
||||
placeholder="Duration in hours"
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||
/>
|
||||
<span className="text-gray-400">hours</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Description (Optional)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What is this vault for?"
|
||||
rows={2}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-200">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-start gap-2 text-sm text-yellow-200">
|
||||
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
||||
<p>
|
||||
<strong>Warning:</strong> Funds locked in a vault cannot be accessed until the lock
|
||||
period expires. Make sure you don't need these funds during the lock period.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size={18} />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock size={18} />
|
||||
Lock Funds
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual Vault Card
|
||||
*/
|
||||
function VaultCard({ vault }: { vault: Vault }) {
|
||||
const { withdrawVault, deleteVault, isLoading } = useVaultsStore();
|
||||
const [timeRemaining, setTimeRemaining] = useState(vault.remainingSecs);
|
||||
const [showConfirm, setShowConfirm] = useState<'withdraw' | 'delete' | null>(null);
|
||||
|
||||
// Update countdown timer
|
||||
useEffect(() => {
|
||||
if (vault.status !== 'locked') return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeRemaining((prev) => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [vault.status]);
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
try {
|
||||
await withdrawVault(vault.id);
|
||||
setShowConfirm(null);
|
||||
} catch (err) {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteVault(vault.id);
|
||||
setShowConfirm(null);
|
||||
} catch (err) {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIcon = vault.status === 'locked' ? Lock : vault.status === 'unlocked' ? Unlock : CheckCircle;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{vault.name}</h3>
|
||||
{vault.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{vault.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${getVaultStatusColor(vault.status)}`}>
|
||||
<StatusIcon size={14} />
|
||||
<span className="capitalize">{vault.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-bold text-white mb-2">
|
||||
{vault.amountHuman}
|
||||
</div>
|
||||
|
||||
{vault.status === 'locked' && (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-synor-500 transition-all duration-1000"
|
||||
style={{ width: `${vault.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<Timer size={14} />
|
||||
Unlocks in:
|
||||
</span>
|
||||
<span className="font-mono text-synor-400">
|
||||
{formatTimeRemaining(timeRemaining)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{vault.status === 'unlocked' && !showConfirm && (
|
||||
<button
|
||||
onClick={() => setShowConfirm('withdraw')}
|
||||
disabled={isLoading}
|
||||
className="w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download size={18} />
|
||||
Withdraw Funds
|
||||
</button>
|
||||
)}
|
||||
|
||||
{vault.status === 'withdrawn' && !showConfirm && (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
Withdrawn {vault.txId && `(${vault.txId.slice(0, 12)}...)`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowConfirm('delete')}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirm && (
|
||||
<div className="mt-3 bg-gray-800 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
{showConfirm === 'withdraw'
|
||||
? 'Are you sure you want to withdraw funds from this vault?'
|
||||
: 'Are you sure you want to delete this vault record?'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowConfirm(null)}
|
||||
className="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={showConfirm === 'withdraw' ? handleWithdraw : handleDelete}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 px-3 py-1.5 rounded text-sm ${
|
||||
showConfirm === 'withdraw'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-red-600 hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-800 flex justify-between text-xs text-gray-500">
|
||||
<span>Created: {new Date(vault.createdAt * 1000).toLocaleDateString()}</span>
|
||||
<span>Unlock: {new Date(vault.unlockAt * 1000).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Vaults Dashboard
|
||||
*/
|
||||
export default function VaultsDashboard() {
|
||||
const { vaults, summary, isLoading, error, fetchVaults, fetchSummary } = useVaultsStore();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Fetch vaults on mount
|
||||
useEffect(() => {
|
||||
fetchVaults();
|
||||
fetchSummary();
|
||||
|
||||
// Refresh every minute
|
||||
const interval = setInterval(() => {
|
||||
fetchVaults();
|
||||
fetchSummary();
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchVaults, fetchSummary]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<VaultIcon className="text-synor-400" />
|
||||
Time-Locked Vaults
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Lock funds for a period to enforce saving goals or vesting schedules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchVaults();
|
||||
fetchSummary();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create Vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||
<AlertCircle className="text-red-400" />
|
||||
<span className="text-red-200">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<p className="text-sm text-gray-400 mb-1">Total Locked</p>
|
||||
<p className="text-xl font-bold text-synor-400">{summary.totalLockedHuman}</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<p className="text-sm text-gray-400 mb-1">Active Vaults</p>
|
||||
<p className="text-xl font-bold text-white">{summary.lockedVaults}</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<p className="text-sm text-gray-400 mb-1">Ready to Withdraw</p>
|
||||
<p className="text-xl font-bold text-green-400">{summary.unlockedVaults}</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||
<p className="text-sm text-gray-400 mb-1">Next Unlock</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{summary.nextUnlock
|
||||
? new Date(summary.nextUnlock * 1000).toLocaleDateString()
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vaults List */}
|
||||
{isLoading && vaults.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<LoadingSpinner size={32} />
|
||||
</div>
|
||||
) : vaults.length === 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||
<VaultIcon size={48} className="mx-auto text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-400 mb-2">No Vaults Yet</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Create a time-locked vault to start saving with enforced holding periods.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create Your First Vault
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{vaults.map((vault) => (
|
||||
<VaultCard key={vault.id} vault={vault} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||
<div className="text-sm text-gray-400">
|
||||
<p className="font-medium text-gray-300 mb-1">About Time-Locked Vaults</p>
|
||||
<p>
|
||||
Vaults use time-locked transactions to enforce a holding period. Once funds are
|
||||
deposited, they cannot be withdrawn until the lock period expires. This is useful
|
||||
for savings goals, vesting schedules, or preventing impulsive spending. The lock
|
||||
is enforced at the protocol level, so even you cannot access the funds early.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && <CreateVaultModal onClose={() => setShowCreateModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,714 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
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 }),
|
||||
}));
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
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;
|
||||
}
|
||||