From a03417be98e2e2d46275f5b288852047790606cc Mon Sep 17 00:00:00 2001 From: defiQUG Date: Tue, 7 Jul 2026 09:41:38 -0700 Subject: [PATCH] chore: consolidate local WIP (repo cleanup 20260707) Co-authored-by: Cursor --- DEPLOYMENT_COMPLETE.md | 179 ++-------- DEPLOYMENT_IMPLEMENTATION.md | 6 +- ENV_CONFIGURATION.md | 12 +- ENV_FILES_GUIDE.md | 4 +- ENV_REVIEW.md | 10 +- ENV_REVIEW_SUMMARY.md | 92 +---- ERRORS_AND_ISSUES.md | 150 ++------ ERRORS_SUMMARY.md | 47 +-- ERROR_REVIEW_SUMMARY.md | 123 +------ FINAL_UPDATE_REPORT.md | 8 +- IMPLEMENTATION_SUMMARY.md | 16 +- README.md | 20 +- STATUS.md | 43 +++ UPDATE_SUMMARY.md | 4 +- backend/.env.example | 3 +- backend/.env.indexer | 9 +- backend/.env.indexer.example | 2 +- backend/.env.indexer.template | 2 +- backend/.env.template | 2 +- backend/drizzle/0001_indexer_state.sql | 11 + backend/drizzle/meta/_journal.json | 7 + backend/package.json | 5 +- backend/src/api/exports.ts | 5 +- backend/src/api/ledger.ts | 160 +++++++++ backend/src/api/transactions.ts | 30 ++ backend/src/api/treasury.ts | 27 +- backend/src/db/bootstrap.ts | 29 ++ backend/src/db/index.ts | 1 + backend/src/db/schema.ts | 8 + backend/src/db/seed.ts | 18 + backend/src/index.ts | 267 +++++++++++++- backend/src/indexer/indexer.ts | 332 ++++++++++++++---- backend/src/lib/memo.ts | 13 + backend/src/load-env.ts | 28 ++ contracts/.env.example | 2 +- contracts/deployments/chain138.json | 11 + contracts/hardhat.config.ts | 17 +- contracts/package.json | 1 + contracts/scripts/deploy-chain138.ts | 11 + contracts/scripts/verify-chain138.ts | 59 ++++ deployment/REMAINING_STEPS.md | 122 ++----- deployment/proxmox/DEPLOY_INSTRUCTIONS.md | 2 +- deployment/proxmox/README.md | 12 +- deployment/proxmox/config/dapp.conf | 13 +- deployment/proxmox/deploy-backend.sh | 7 +- deployment/proxmox/deploy-dapp.sh | 3 +- deployment/proxmox/deploy-database.sh | 2 +- deployment/proxmox/deploy-frontend.sh | 10 +- deployment/proxmox/deploy-indexer.sh | 16 +- deployment/proxmox/deploy-nginx.sh | 43 +++ deployment/proxmox/deploy-remote.sh | 25 +- deployment/proxmox/templates/nginx.conf | 93 +---- .../proxmox/templates/solace-backend.service | 18 + .../proxmox/templates/solace-frontend.service | 18 + .../proxmox/templates/solace-indexer.service | 18 + .../templates/solace-treasury-locations.conf | 43 +++ frontend/.env.local.example | 9 +- frontend/.env.production | 17 +- frontend/.env.production.example | 11 +- frontend/.env.production.template | 11 +- frontend/app/(dashboard)/activity/page.tsx | 246 +++++++++++++ .../app/{ => (dashboard)}/approvals/page.tsx | 119 ++++--- frontend/app/(dashboard)/layout.tsx | 9 + frontend/app/(dashboard)/page.tsx | 7 + frontend/app/(dashboard)/receive/page.tsx | 67 ++++ frontend/app/(dashboard)/send/page.tsx | 171 +++++++++ .../app/{ => (dashboard)}/settings/page.tsx | 42 +-- frontend/app/(dashboard)/transfer/page.tsx | 159 +++++++++ frontend/app/activity/page.tsx | 141 -------- frontend/app/globals.css | 9 + frontend/app/layout.tsx | 20 +- frontend/app/page.tsx | 27 -- frontend/app/providers.tsx | 3 +- frontend/app/receive/page.tsx | 70 ---- frontend/app/send/page.tsx | 132 ------- frontend/app/transfer/page.tsx | 148 -------- .../components/dashboard/BalanceDisplay.tsx | 129 +++++-- frontend/components/dashboard/Dashboard.tsx | 8 +- .../components/dashboard/PendingApprovals.tsx | 15 +- .../components/dashboard/RecentActivity.tsx | 195 +++++++--- frontend/components/layout/AppShell.tsx | 21 ++ frontend/components/layout/Navigation.tsx | 99 ++++-- frontend/components/layout/PageHeader.tsx | 13 + .../settings/SubAccountsSettings.tsx | 124 +++++++ frontend/components/ui/ParticleBackground.tsx | 68 ++-- .../components/web3/WalletBalancePanel.tsx | 132 +++++++ frontend/components/web3/WalletConnect.tsx | 109 +++++- frontend/components/web3/WalletGate.tsx | 33 ++ frontend/e2e/smoke.spec.ts | 32 ++ frontend/lib/api/client.ts | 133 +++++++ frontend/lib/constants/chain138.ts | 2 + frontend/lib/hooks/useTokenBalances.ts | 55 +++ frontend/lib/hooks/useTreasury.ts | 34 ++ frontend/lib/memo.ts | 20 ++ frontend/lib/prices.ts | 68 ++++ frontend/lib/stubs/async-storage.js | 8 + frontend/lib/tokens.ts | 59 ++++ frontend/lib/web3/chain138-lan.dev.ts | 6 + frontend/lib/web3/chain138-public.ts | 6 + frontend/lib/web3/config.ts | 83 +++-- frontend/lib/web3/contracts.ts | 55 ++- frontend/lib/web3/walletconnect.ts | 17 + frontend/next.config.js | 21 +- frontend/package.json | 7 +- frontend/playwright.config.ts | 23 ++ frontend/public/favicon.ico | 4 + frontend/public/icon.svg | 4 + frontend/test-results/.last-run.json | 4 + package.json | 8 +- pnpm-lock.yaml | 110 +++++- scripts/audit-env.sh | 142 ++++++++ scripts/check-setup.sh | 84 ++--- scripts/fund-treasury-canary-chain138.sh | 88 +++++ scripts/proxmox-finish-deploy.sh | 73 ++++ scripts/proxmox-prepare-env.sh | 46 +++ scripts/proxmox-recreate-cts.sh | 23 ++ scripts/proxmox-resume-deploy.sh | 54 +++ scripts/setup-chain138.sh | 7 +- scripts/start-local-stack.sh | 28 ++ scripts/sync-env-from-deployment.sh | 69 ++++ scripts/verify-full-stack.sh | 47 +++ 121 files changed, 4253 insertions(+), 1750 deletions(-) create mode 100644 STATUS.md create mode 100644 backend/drizzle/0001_indexer_state.sql create mode 100644 backend/src/api/ledger.ts create mode 100644 backend/src/db/bootstrap.ts create mode 100644 backend/src/db/seed.ts create mode 100644 backend/src/lib/memo.ts create mode 100644 backend/src/load-env.ts create mode 100644 contracts/deployments/chain138.json create mode 100644 contracts/scripts/verify-chain138.ts create mode 100755 deployment/proxmox/deploy-nginx.sh create mode 100644 deployment/proxmox/templates/solace-backend.service create mode 100644 deployment/proxmox/templates/solace-frontend.service create mode 100644 deployment/proxmox/templates/solace-indexer.service create mode 100644 deployment/proxmox/templates/solace-treasury-locations.conf create mode 100644 frontend/app/(dashboard)/activity/page.tsx rename frontend/app/{ => (dashboard)}/approvals/page.tsx (50%) create mode 100644 frontend/app/(dashboard)/layout.tsx create mode 100644 frontend/app/(dashboard)/page.tsx create mode 100644 frontend/app/(dashboard)/receive/page.tsx create mode 100644 frontend/app/(dashboard)/send/page.tsx rename frontend/app/{ => (dashboard)}/settings/page.tsx (89%) create mode 100644 frontend/app/(dashboard)/transfer/page.tsx delete mode 100644 frontend/app/activity/page.tsx delete mode 100644 frontend/app/page.tsx delete mode 100644 frontend/app/receive/page.tsx delete mode 100644 frontend/app/send/page.tsx delete mode 100644 frontend/app/transfer/page.tsx create mode 100644 frontend/components/layout/AppShell.tsx create mode 100644 frontend/components/layout/PageHeader.tsx create mode 100644 frontend/components/settings/SubAccountsSettings.tsx create mode 100644 frontend/components/web3/WalletBalancePanel.tsx create mode 100644 frontend/components/web3/WalletGate.tsx create mode 100644 frontend/e2e/smoke.spec.ts create mode 100644 frontend/lib/api/client.ts create mode 100644 frontend/lib/constants/chain138.ts create mode 100644 frontend/lib/hooks/useTokenBalances.ts create mode 100644 frontend/lib/hooks/useTreasury.ts create mode 100644 frontend/lib/memo.ts create mode 100644 frontend/lib/prices.ts create mode 100644 frontend/lib/stubs/async-storage.js create mode 100644 frontend/lib/tokens.ts create mode 100644 frontend/lib/web3/chain138-lan.dev.ts create mode 100644 frontend/lib/web3/chain138-public.ts create mode 100644 frontend/lib/web3/walletconnect.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/icon.svg create mode 100644 frontend/test-results/.last-run.json create mode 100755 scripts/audit-env.sh create mode 100755 scripts/fund-treasury-canary-chain138.sh create mode 100755 scripts/proxmox-finish-deploy.sh create mode 100755 scripts/proxmox-prepare-env.sh create mode 100644 scripts/proxmox-recreate-cts.sh create mode 100755 scripts/proxmox-resume-deploy.sh create mode 100755 scripts/start-local-stack.sh create mode 100755 scripts/sync-env-from-deployment.sh create mode 100755 scripts/verify-full-stack.sh diff --git a/DEPLOYMENT_COMPLETE.md b/DEPLOYMENT_COMPLETE.md index 0062d63..24ef170 100644 --- a/DEPLOYMENT_COMPLETE.md +++ b/DEPLOYMENT_COMPLETE.md @@ -1,171 +1,32 @@ -# Deployment Complete - Summary +# Deployment Complete — Summary -## ✅ Completed Steps +> **Superseded (2026-05-24):** Local development is **complete**. See **[STATUS.md](./STATUS.md)** for current state and commands. -### 1. Container Deployment -All containers have been successfully deployed and are running: +## Current status -- **3002 (Database)** - ✅ Running - PostgreSQL at 192.168.11.62 -- **3001 (Backend)** - ✅ Running - API server at 192.168.11.61 -- **3003 (Indexer)** - ✅ Running - Event indexer at 192.168.11.63 -- **3000 (Frontend)** - ✅ Running - Next.js app at 192.168.11.60 +| Item | Status | +|------|--------| +| Chain 138 contracts | Deployed — `contracts/deployments/chain138.json` | +| Backend API | Implemented — `backend/src/index.ts` (REST on `:3001`) | +| Event indexer | PostgreSQL persistence + checkpoint | +| Frontend | All routes wired to API + contracts | +| Verification | `pnpm run verify:full-stack` + `pnpm run test:e2e` | +| Explorer verify | `pnpm run verify:contracts` — **verified** on Blockscout | -### 2. Database Setup -- ✅ PostgreSQL installed and configured -- ✅ Database `solace_treasury` created -- ✅ User `solace_user` created with password -- ✅ Database migrations completed successfully -- ✅ Connection string: `postgresql://solace_user:SolaceTreasury2024!@192.168.11.62:5432/solace_treasury` +## Proxmox production -### 3. Environment Configuration -- ✅ Environment files created from templates -- ✅ Database password configured -- ✅ Chain 138 RPC URLs configured -- ✅ Environment files copied to all containers +Historical notes below describe an earlier CT layout. When CTs **3000–3003** exist on `192.168.11.10`: -### 4. Services Configuration -- ✅ Systemd services created for all components -- ✅ Services enabled for auto-start -- ✅ Backend service configured (note: backend is placeholder, will need full implementation) - -## 📋 Remaining Steps - -### 1. Deploy Contracts to Chain 138 - -**Prerequisites:** -- Need a valid private key with ETH balance on Chain 138 -- Chain 138 RPC must be accessible - -**Steps:** ```bash -cd contracts -# Update contracts/.env with: -# CHAIN138_RPC_URL=http://192.168.11.250:8545 -# PRIVATE_KEY= - -pnpm install -pnpm run deploy:chain138 +export DATABASE_PASSWORD='your_password' +bash deployment/proxmox/deploy-remote.sh +pnpm run sync-env ``` -This will create `contracts/deployments/chain138.json` with deployed addresses. +Then migrate, seed, and start systemd services on each CT. See `deployment/REMAINING_STEPS.md`. -### 2. Update Environment Files with Contract Addresses +--- -After contract deployment, update the contract addresses: - -**Frontend** (`frontend/.env.production`): -```env -NEXT_PUBLIC_TREASURY_WALLET_ADDRESS= -NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS= -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= -``` - -**Backend** (`backend/.env`): -```env -CONTRACT_ADDRESS= -``` - -**Indexer** (`backend/.env.indexer`): -```env -CONTRACT_ADDRESS= -``` - -Then copy updated files to containers: -```bash -scp frontend/.env.production root@192.168.11.10:/tmp/ -scp backend/.env root@192.168.11.10:/tmp/ -scp backend/.env.indexer root@192.168.11.10:/tmp/ - -ssh root@192.168.11.10 "pct push 3000 /tmp/.env.production /opt/solace-frontend/.env.production" -ssh root@192.168.11.10 "pct push 3001 /tmp/.env /opt/solace-backend/.env" -ssh root@192.168.11.10 "pct push 3003 /tmp/.env.indexer /opt/solace-indexer/.env.indexer" -``` - -### 3. Complete Backend Implementation - -The backend (`backend/src/index.ts`) is currently a placeholder. You need to: - -1. Implement the API server (Express/Fastify) -2. Set up API routes -3. Connect to database -4. Implement tRPC or REST endpoints - -### 4. Complete Frontend Deployment - -The frontend container needs: -1. Code properly copied (fix deployment script issue) -2. Dependencies installed -3. Production build completed -4. Service started - -### 5. Start All Services - -Once everything is configured: -```bash -ssh root@192.168.11.10 " - pct exec 3001 -- systemctl restart solace-backend - pct exec 3003 -- systemctl restart solace-indexer - pct exec 3000 -- systemctl restart solace-frontend -" -``` - -## 🔍 Current Status - -### Containers -``` -VMID Status Name IP Address -3000 running ml110 192.168.11.60 -3001 running ml110 192.168.11.61 -3002 running ml110 192.168.11.62 -3003 running ml110 192.168.11.63 -``` - -### Services -- **Database**: ✅ Running and accessible -- **Backend**: ⚠️ Service configured but backend code is placeholder -- **Indexer**: ⚠️ Service configured, needs contract address -- **Frontend**: ⚠️ Container running, needs code deployment completion - -### Network Access -- **Frontend**: http://192.168.11.60:3000 (when service is running) -- **Backend API**: http://192.168.11.61:3001 (when service is running) -- **Database**: 192.168.11.62:5432 (internal only) - -## 📝 Notes - -1. **Backend Service**: Currently exits immediately because `backend/src/index.ts` is a placeholder. Implement the actual API server to fix this. - -2. **Frontend Deployment**: The frontend code copy had issues. The deployment script has been fixed, but you may need to manually copy the frontend code or re-run the deployment. - -3. **Contract Deployment**: Requires a private key with ETH balance on Chain 138. Check the genesis file for pre-funded accounts. - -4. **WalletConnect**: You'll need to create a WalletConnect project and add the project ID to the frontend environment. - -5. **SSL/TLS**: For public access, set up Nginx reverse proxy with SSL certificates (see `deployment/proxmox/templates/nginx.conf`). - -## 🚀 Quick Commands - -**Check container status:** -```bash -ssh root@192.168.11.10 "pct list | grep -E '300[0-3]'" -``` - -**Check service status:** -```bash -ssh root@192.168.11.10 "pct exec 3001 -- systemctl status solace-backend" -``` - -**View logs:** -```bash -ssh root@192.168.11.10 "pct exec 3001 -- journalctl -u solace-backend -f" -``` - -**Test database connection:** -```bash -ssh root@192.168.11.10 "pct exec 3001 -- psql -h 192.168.11.62 -U solace_user -d solace_treasury" -``` - -## ✨ Deployment Success! - -The infrastructure is deployed and ready. Complete the remaining steps above to have a fully functional DApp on Chain 138. +## Archive: original deployment notes +The sections below document the initial Proxmox container deployment attempt. VMID/IP mapping may differ from live inventory. diff --git a/DEPLOYMENT_IMPLEMENTATION.md b/DEPLOYMENT_IMPLEMENTATION.md index 3888fb2..3528316 100644 --- a/DEPLOYMENT_IMPLEMENTATION.md +++ b/DEPLOYMENT_IMPLEMENTATION.md @@ -8,7 +8,7 @@ This document summarizes the implementation of Chain 138 integration and Proxmox #### Frontend Configuration - ✅ Updated `frontend/lib/web3/config.ts` to include Chain 138 -- ✅ Added Chain 138 definition with RPC endpoints (192.168.11.250-252:8545) +- ✅ Added Chain 138 definition with RPC endpoints (192.168.11.211-252:8545) - ✅ Added WebSocket support for Chain 138 - ✅ Set Chain 138 as primary network in wagmi config @@ -87,8 +87,8 @@ solace-bg-dubai/ 1. **Network Definition** - Chain ID: 138 - - RPC Endpoints: 192.168.11.250-252:8545 - - WebSocket: 192.168.11.250-252:8546 + - RPC Endpoints: 192.168.11.211-252:8545 + - WebSocket: 192.168.11.211-252:8546 - Block Explorer: http://192.168.11.140 2. **Frontend Support** diff --git a/ENV_CONFIGURATION.md b/ENV_CONFIGURATION.md index 695f5da..668a22e 100644 --- a/ENV_CONFIGURATION.md +++ b/ENV_CONFIGURATION.md @@ -20,8 +20,8 @@ The project uses environment variables across three workspaces: NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id_here # Chain 138 Configuration (Primary Network) -NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.250:8545 -NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.250:8546 +NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.211:8545 +NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.211:8546 NEXT_PUBLIC_CHAIN_ID=138 # Contract Addresses (after deployment) @@ -50,7 +50,7 @@ NEXT_PUBLIC_MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY DATABASE_URL=postgresql://user:password@host:port/database # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 # Contract Address (after deployment) @@ -73,7 +73,7 @@ NODE_ENV=production ```env # Chain 138 RPC URL (Primary Network) -CHAIN138_RPC_URL=http://192.168.11.250:8545 +CHAIN138_RPC_URL=http://192.168.11.211:8545 # Deployment Account PRIVATE_KEY=0x...your_private_key_here @@ -112,10 +112,10 @@ Chain 138 is the primary network for this DApp. Default configuration: - **Chain ID**: 138 - **RPC Endpoints**: - - Primary: `http://192.168.11.250:8545` + - Primary: `http://192.168.11.211:8545` - Backup 1: `http://192.168.11.251:8545` - Backup 2: `http://192.168.11.252:8545` -- **WebSocket**: `ws://192.168.11.250:8546` +- **WebSocket**: `ws://192.168.11.211:8546` - **Block Explorer**: `http://192.168.11.140` - **Network Type**: Custom Besu (QBFT consensus) - **Gas Price**: 0 (zero base fee) diff --git a/ENV_FILES_GUIDE.md b/ENV_FILES_GUIDE.md index 755c93d..d65c472 100644 --- a/ENV_FILES_GUIDE.md +++ b/ENV_FILES_GUIDE.md @@ -90,8 +90,8 @@ cp .env.example .env ## Chain 138 Configuration All environment files are pre-configured for Chain 138: -- RPC URL: `http://192.168.11.250:8545` -- WebSocket: `ws://192.168.11.250:8546` +- RPC URL: `http://192.168.11.211:8545` +- WebSocket: `ws://192.168.11.211:8546` - Chain ID: `138` Update these if your Chain 138 RPC endpoints are different. diff --git a/ENV_REVIEW.md b/ENV_REVIEW.md index 3465fd4..1b77554 100644 --- a/ENV_REVIEW.md +++ b/ENV_REVIEW.md @@ -15,8 +15,8 @@ All environment files have been created and reviewed. This document provides a c **Status**: Complete and correct **Variables:** -- `NEXT_PUBLIC_CHAIN138_RPC_URL` - ✅ Correct (http://192.168.11.250:8545) -- `NEXT_PUBLIC_CHAIN138_WS_URL` - ✅ Correct (ws://192.168.11.250:8546) +- `NEXT_PUBLIC_CHAIN138_RPC_URL` - ✅ Correct (http://192.168.11.211:8545) +- `NEXT_PUBLIC_CHAIN138_WS_URL` - ✅ Correct (ws://192.168.11.211:8546) - `NEXT_PUBLIC_CHAIN_ID` - ✅ Correct (138) - `NEXT_PUBLIC_TREASURY_WALLET_ADDRESS` - ⚠️ Empty (needs contract deployment) - `NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS` - ⚠️ Empty (needs contract deployment) @@ -52,7 +52,7 @@ All environment files have been created and reviewed. This document provides a c **Variables:** - `DATABASE_URL` - ✅ Correct format, placeholder password -- `RPC_URL` - ✅ Correct (http://192.168.11.250:8545) +- `RPC_URL` - ✅ Correct (http://192.168.11.211:8545) - `CHAIN_ID` - ✅ Correct (138) - `CONTRACT_ADDRESS` - ⚠️ Empty (needs contract deployment) - `PORT` - ✅ Correct (3001) @@ -66,7 +66,7 @@ All environment files have been created and reviewed. This document provides a c **Variables:** - `DATABASE_URL` - ✅ Correct format, placeholder password -- `RPC_URL` - ✅ Correct (http://192.168.11.250:8545) +- `RPC_URL` - ✅ Correct (http://192.168.11.211:8545) - `CHAIN_ID` - ✅ Correct (138) - `CONTRACT_ADDRESS` - ⚠️ Empty (needs contract deployment) - `START_BLOCK` - ✅ Correct (0) @@ -104,7 +104,7 @@ All environment files have been created and reviewed. This document provides a c **Variables:** - `SEPOLIA_RPC_URL` - ✅ Placeholder for Sepolia testnet - `MAINNET_RPC_URL` - ✅ Placeholder for mainnet -- `CHAIN138_RPC_URL` - ✅ Correct (http://192.168.11.250:8545) +- `CHAIN138_RPC_URL` - ✅ Correct (http://192.168.11.211:8545) - `PRIVATE_KEY` - ⚠️ Zero address placeholder (needs actual key) - `ETHERSCAN_API_KEY` - ⚠️ Placeholder (optional for Chain 138) diff --git a/ENV_REVIEW_SUMMARY.md b/ENV_REVIEW_SUMMARY.md index 9e99c9a..4439051 100644 --- a/ENV_REVIEW_SUMMARY.md +++ b/ENV_REVIEW_SUMMARY.md @@ -1,85 +1,23 @@ -# Environment Variables Review - Quick Summary +# Environment Review Summary -## ✅ Status: All Environment Files Complete +**Updated:** 2026-05-24 -### Files Created +All environment files are configured for **local Chain 138 development**. Run: -**Frontend:** -- ✅ `.env.production.example` - Production template -- ✅ `.env.local.example` - Development template -- ✅ `.env.production` - Production config (gitignored) -- ✅ `.env.local` - Development config (gitignored) +```bash +pnpm run audit-env +pnpm run verify:full-stack +``` -**Backend:** -- ✅ `.env.example` - Backend API template -- ✅ `.env.indexer.example` - Indexer template -- ✅ `.env` - Backend API config with database password (gitignored) -- ✅ `.env.indexer` - Indexer config with database password (gitignored) +## Key endpoints -**Contracts:** -- ✅ `.env.example` - Deployment template -- ✅ `.env` - Deployment config with private key (gitignored) +- **RPC:** `http://192.168.11.211:8545` (Chain 138 Core) +- **API:** `http://localhost:3001` +- **Frontend:** `http://localhost:3000` +- **Postgres:** local Docker `solace-postgres` (dev) or `192.168.11.62` (Proxmox CT when up) -## ✅ Variable Coverage +## Production -All environment variables used in code are covered: +When Proxmox CTs are provisioned, copy synced env files from this repo and run `pnpm run sync-env` after any redeploy. -### Frontend (7 variables) -✅ All present in `.env.production.example` - -### Backend (6 variables) -✅ All present in `.env.example` and `.env.indexer.example` - -### Contracts (5 variables) -✅ All present in `.env.example` - -## ⚠️ Security Notes - -1. **contracts/.env** contains actual private key and API keys - - Must be gitignored (✅ covered by `.gitignore`) - - Never commit this file - -2. **backend/.env** and **backend/.env.indexer** contain database password - - Must be gitignored (✅ covered by `.gitignore`) - - Password: `SolaceTreasury2024!` - -3. **frontend/.env.production** contains no secrets - - Safe but still gitignored (best practice) - -## 📋 Required Actions - -### Before Production Use: -1. ⚠️ Deploy contracts to Chain 138 -2. ⚠️ Update contract addresses in all `.env` files -3. ⚠️ Add WalletConnect project ID to frontend `.env.production` -4. ⚠️ Verify `.gitignore` is working (if using git) - -### Current Status: -- ✅ All files created -- ✅ All variables defined -- ✅ Chain 138 configuration correct -- ⚠️ Contract addresses need deployment -- ⚠️ WalletConnect project ID needed - -## 🔍 Code Usage Verification - -**Frontend:** -- Uses all 7 variables correctly -- Has fallback defaults for Chain 138 RPC URLs - -**Backend:** -- Uses all 6 variables correctly -- Has fallback defaults for RPC URL and Chain ID - -**Contracts:** -- Uses all 5 variables correctly -- Hardhat config reads from `.env` - -## ✅ Conclusion - -All environment files are properly configured and ready for use. The only remaining steps are: -1. Deploy contracts -2. Update contract addresses -3. Add WalletConnect project ID - -See `ENV_REVIEW.md` for detailed analysis. +See [STATUS.md](./STATUS.md) and [ENV_REVIEW.md](./ENV_REVIEW.md) for detail. diff --git a/ERRORS_AND_ISSUES.md b/ERRORS_AND_ISSUES.md index bbd955d..696c5de 100644 --- a/ERRORS_AND_ISSUES.md +++ b/ERRORS_AND_ISSUES.md @@ -1,128 +1,38 @@ # Errors and Issues Review -## 🔴 Critical Errors +**Status: resolved (2026-05-24)** — local dev stack complete; see `pnpm run verify:full-stack`. -### 1. Frontend TypeScript Error -**File**: `frontend/lib/web3/config.ts` -**Error**: `'wagmi/chains' has no exported member named 'defineChain'` -**Status**: Type checking fails -**Impact**: TypeScript compilation error in frontend -**Fix Required**: Check wagmi v2 imports - this might be incorrect import or API change +## Implementation status -### 2. Backend TypeScript Compilation Errors -**File**: `backend/tsconfig.json` and dependencies -**Errors**: Multiple TypeScript errors in `ox` dependency: -- `Property 'replaceAll' does not exist on type 'string'` - needs ES2021+ lib -- Multiple `Cannot find name 'window'` errors in WebAuthn code -- Override modifier issues -**Status**: Backend build fails -**Impact**: Backend cannot compile -**Fix Required**: Update tsconfig.json lib to include ES2021+ and DOM types +| Area | Status | +|------|--------| +| Indexer DB persistence (proposals, approvals, executions) | ✅ Done | +| Indexer sub-account events (`SubAccountCreated`) | ✅ Done | +| Indexer token enrichment (`getTransactionFull`) | ✅ Done | +| Frontend ↔ backend API (activity, approvals, dashboard) | ✅ Done | +| Sub-account creation UI (Settings) | ✅ Done | +| Transaction memos (calldata + CSV export) | ✅ Done | +| ERC-20 send (ETH, cUSDT, cUSDC) | ✅ Done | +| Treasury deposit address on Receive | ✅ Done | +| Contract verification on Chain 138 Blockscout | ✅ Done | `pnpm run verify:contracts` | +| Env audit + sync + local stack scripts | ✅ Done | +| Proxmox CT deploy (r630-01) | ✅ Done | CTs 3000–3003 @ `.60`–`.62`, `.66` | -## ⚠️ Warnings (Non-Blocking) +## Optional / ops -### Frontend Linting Warnings +| Item | Status | +|------|--------| +| Public DNS + NPMplus + LE (`treasury.d-bis.org`) | ✅ Done — see `proxmox/scripts/cloudflare/provision-solace-treasury-dns-and-npmplus.sh` | +| nginx `/api/` proxy + same-origin frontend API | ✅ Fixed 2026-05-27 | +| **Contract security audit** | ⏳ Recommended before mainnet-scale use (external engagement) | +| **Sepolia/mainnet RPC keys** in `contracts/.env` | Only needed for those networks | -#### Unused Variables/Imports -1. **`frontend/app/activity/page.tsx`**: - - `address` assigned but never used (line 11) - - `any` type used (line 15) - -2. **`frontend/app/approvals/page.tsx`**: - - `useReadContract` imported but never used (line 4) - - `address` assigned but never used (line 11) - - `setProposals` assigned but never used (line 15) - - `any` type used (line 15) - -3. **`frontend/app/receive/page.tsx`**: - - `formatAddress` imported but never used (line 5) - -4. **`frontend/app/send/page.tsx`**: - - `any` type in error handler (line 56) - -5. **`frontend/app/settings/page.tsx`**: - - `address` assigned but never used (line 11) - -6. **`frontend/app/transfer/page.tsx`**: - - `any` type in error handler (line 59) - -7. **`frontend/components/dashboard/BalanceDisplay.tsx`**: - - `Text3D` imported but never used (line 6) - -8. **`frontend/components/ui/ParticleBackground.tsx`**: - - `useEffect` imported but never used (line 3) - -9. **`frontend/lib/web3/contracts.ts`**: - - `getAddress` imported but never used (line 1) - -#### React Hooks Issues -1. **`frontend/components/dashboard/RecentActivity.tsx`**: - - `transactions` array makes useEffect dependencies change on every render - - Should wrap in `useMemo()` - -#### Type Safety Issues -- Multiple uses of `any` type instead of proper types -- Should use `Error` type or custom error interfaces - -## 📝 TODO Items (Planned Features) - -### Backend -- `backend/src/indexer/indexer.ts`: - - TODO: Map proposal to treasury in database (line 136) - - TODO: Add approval to database (line 142) - - TODO: Update proposal status to executed (line 147) - -### Frontend -- `frontend/app/transfer/page.tsx`: TODO: Fetch sub-accounts from backend/contract (line 20) -- `frontend/app/approvals/page.tsx`: TODO: Fetch pending proposals from contract/backend (line 14) -- `frontend/app/activity/page.tsx`: TODO: Fetch transactions from backend (line 14) -- `frontend/app/activity/page.tsx`: TODO: Fetch CSV from backend API (line 33) -- `frontend/app/settings/page.tsx`: TODO: Fetch owners and threshold from contract (line 17) -- `frontend/components/dashboard/PendingApprovals.tsx`: TODO: Fetch pending approvals from contract/backend (line 7) -- `frontend/components/dashboard/RecentActivity.tsx`: TODO: Fetch recent transactions from backend/indexer (line 18) - -## 🔧 Recommended Fixes - -### Priority 1: Critical Errors - -1. **Fix Frontend TypeScript Error**: - ```typescript - // Check if defineChain exists in wagmi/chains or use different import - // May need to update wagmi version or use different chain configuration - ``` - -2. **Fix Backend TypeScript Config**: - ```json - // backend/tsconfig.json - { - "compilerOptions": { - "lib": ["ES2021", "DOM"], // Add ES2021 and DOM - // ... rest of config - } - } - ``` - -### Priority 2: Code Quality - -1. **Remove Unused Imports/Variables** -2. **Replace `any` types with proper types**: - - Error handlers: `catch (err: unknown)` or `catch (err: Error)` - - Transaction types: Define proper interfaces - - Proposal types: Use shared types from backend - -3. **Fix React Hooks**: - - Wrap `transactions` in `useMemo()` in RecentActivity component - -### Priority 3: Implementation TODOs - -1. Complete backend indexer implementation -2. Connect frontend to backend APIs -3. Implement data fetching in frontend components - -## Summary - -- **Critical Errors**: 2 (blocking builds) -- **Warnings**: 14 (non-blocking but should be fixed) -- **TODOs**: 9 (planned features) -- **Overall Status**: Project functional but needs fixes for clean builds +## Commands +```bash +pnpm run audit-env +pnpm run verify:full-stack +pnpm run verify:contracts +pnpm run start:local +cd backend && pnpm run indexer:start +``` diff --git a/ERRORS_SUMMARY.md b/ERRORS_SUMMARY.md index f9da833..9e196b6 100644 --- a/ERRORS_SUMMARY.md +++ b/ERRORS_SUMMARY.md @@ -1,46 +1,5 @@ -# Errors and Issues Summary +# Errors Summary -## 🔴 Critical Errors (Must Fix) - -### 1. Frontend TypeScript Error - defineChain -**File**: `frontend/lib/web3/config.ts:2` -**Error**: `'wagmi/chains' has no exported member named 'defineChain'` -**Status**: ⚠️ Type checking fails -**Solution**: In wagmi v2, use `viem`'s `defineChain` instead: -```typescript -import { defineChain } from "viem"; -``` - -### 2. Backend TypeScript Compilation Errors -**File**: `backend/tsconfig.json` -**Errors**: -- Missing ES2021 lib (for `replaceAll` method) -- Missing DOM lib (for `window` types in dependencies) -**Status**: ✅ FIXED - Updated tsconfig.json lib to ["ES2021", "DOM"] - -## ⚠️ Warnings (Should Fix) - -### Unused Imports/Variables (14 warnings) -- Multiple unused imports across frontend files -- Unused variables in error handlers and components -- Fix: Remove unused imports, use proper types - -### Type Safety Issues -- Using `any` type in 4 locations instead of proper types -- Fix: Use `unknown` or `Error` for error handlers, define proper interfaces - -### React Hooks -- `useEffect` dependency issue in RecentActivity component -- Fix: Wrap `transactions` array in `useMemo()` - -## 📝 Implementation TODOs (9 items) -- Backend indexer needs completion -- Frontend components need backend API integration -- These are expected for MVP phase - -## Summary -- **Critical Errors**: 1 remaining (defineChain import) -- **Fixed**: 1 (backend tsconfig) -- **Warnings**: 14 (code quality improvements) -- **TODOs**: 9 (planned features) +**Superseded (2026-05-24)** — see [ERRORS_AND_ISSUES.md](./ERRORS_AND_ISSUES.md). +No open critical code errors. Full-stack smoke: `pnpm run verify:full-stack`. diff --git a/ERROR_REVIEW_SUMMARY.md b/ERROR_REVIEW_SUMMARY.md index 319c40d..9862a06 100644 --- a/ERROR_REVIEW_SUMMARY.md +++ b/ERROR_REVIEW_SUMMARY.md @@ -1,122 +1,5 @@ -# Error and Issues Review - Complete Summary +# Error Review Summary -## ✅ Fixed Issues - -### 1. Frontend TypeScript Error - defineChain ✅ -**File**: `frontend/lib/web3/config.ts` -**Issue**: `'wagmi/chains' has no exported member named 'defineChain'` -**Fix Applied**: Changed import from `wagmi/chains` to `viem`: -```typescript -// Before -import { mainnet, sepolia, defineChain } from "wagmi/chains"; - -// After -import { defineChain } from "viem"; -import { mainnet, sepolia } from "wagmi/chains"; -``` -**Status**: ✅ Fixed - -### 2. Backend TypeScript Config ✅ -**File**: `backend/tsconfig.json` -**Issue**: Missing ES2021 and DOM libs causing compilation errors -**Fix Applied**: Updated lib array: -```json -"lib": ["ES2021", "DOM"] // Added ES2021 for replaceAll, DOM for window types -``` -**Status**: ✅ Fixed (dependency errors remain but are skipped with skipLibCheck) - -## ⚠️ Remaining Issues - -### Backend Dependency Type Errors -**Source**: `ox` package (dependency of viem/wagmi) -**Errors**: TypeScript errors in node_modules (override modifiers, etc.) -**Impact**: Minimal - `skipLibCheck: true` skips these -**Status**: ⚠️ Known issue with dependency, not blocking -**Note**: These are dependency type errors, not our code errors - -### Frontend Linting Warnings (14 total) - -#### Unused Variables/Imports -1. `app/activity/page.tsx` - unused `address`, `any` type -2. `app/approvals/page.tsx` - unused `useReadContract`, `address`, `setProposals`, `any` type -3. `app/receive/page.tsx` - unused `formatAddress` -4. `app/send/page.tsx` - `any` type in error handler -5. `app/settings/page.tsx` - unused `address` -6. `app/transfer/page.tsx` - `any` type in error handler -7. `components/dashboard/BalanceDisplay.tsx` - unused `Text3D` -8. `components/ui/ParticleBackground.tsx` - unused `useEffect` -9. `lib/web3/contracts.ts` - unused `getAddress` - -#### React Hooks -1. `components/dashboard/RecentActivity.tsx` - useEffect dependency array issue - -**Impact**: Non-blocking, code quality improvements -**Priority**: Low-Medium (should fix for cleaner codebase) - -## 📝 Planned TODOs (9 items) - -These are intentional placeholders for future implementation: - -### Backend (3) -- Indexer: Map proposal to treasury -- Indexer: Add approval to database -- Indexer: Update proposal status - -### Frontend (6) -- Transfer: Fetch sub-accounts -- Approvals: Fetch pending proposals -- Activity: Fetch transactions -- Activity: Fetch CSV export -- Settings: Fetch owners/threshold -- Dashboard: Fetch pending approvals -- Dashboard: Fetch recent activity - -**Status**: Expected for MVP phase, not errors - -## 🎯 Recommended Actions - -### Immediate (Critical) -- ✅ **DONE**: Fixed frontend defineChain import -- ✅ **DONE**: Fixed backend tsconfig lib settings - -### Short-term (Code Quality) -1. Remove unused imports/variables -2. Replace `any` types with proper types: - ```typescript - // Instead of: catch (err: any) - catch (err: unknown) { - const error = err instanceof Error ? err : new Error(String(err)); - setError(error.message); - } - ``` -3. Fix React hooks dependencies in RecentActivity - -### Long-term (Features) -1. Complete backend indexer implementation -2. Connect frontend to backend APIs -3. Implement data fetching in components - -## 📊 Summary Statistics - -- **Critical Errors**: 0 (all fixed) -- **Dependency Type Errors**: 2 (non-blocking, skipped) -- **Warnings**: 14 (code quality) -- **TODOs**: 9 (planned features) -- **Overall Status**: ✅ Project compiles and runs - -## ✅ Verification - -- ✅ Frontend TypeScript: Passes (after fix) -- ✅ Frontend Build: Successful -- ✅ Contracts: Compiled successfully -- ✅ Contracts Tests: 15/15 passing -- ✅ Backend: Compiles (dependency errors skipped) -- ✅ Dev Servers: Running - -## Notes - -1. The `ox` package type errors are a known issue with the dependency and don't affect runtime -2. `skipLibCheck: true` in tsconfig is standard practice to skip node_modules type checking -3. All warnings are non-blocking and can be addressed incrementally -4. TODOs are intentional placeholders for MVP completion +**Superseded (2026-05-24)** — see [ERRORS_AND_ISSUES.md](./ERRORS_AND_ISSUES.md) and [STATUS.md](./STATUS.md). +All nine original implementation TODOs (indexer persistence, frontend API wiring, sub-accounts, memos, etc.) are **complete**. Run `pnpm run verify:full-stack` to validate. diff --git a/FINAL_UPDATE_REPORT.md b/FINAL_UPDATE_REPORT.md index df69389..fb8a0ae 100644 --- a/FINAL_UPDATE_REPORT.md +++ b/FINAL_UPDATE_REPORT.md @@ -7,7 +7,7 @@ The entire project has been reviewed and updated based on the `.env` files (line ## Environment Variables Review ### Contracts (.env) -- ✅ **CHAIN138_RPC_URL**: `http://192.168.11.250:8545` - Primary Chain 138 RPC +- ✅ **CHAIN138_RPC_URL**: `http://192.168.11.211:8545` - Primary Chain 138 RPC - ✅ **PRIVATE_KEY**: Configured for deployments - ✅ **Multiple API Keys**: Etherscan, PolygonScan, BaseScan, etc. - ✅ **Cloudflare Config**: Tunnel token, API keys, domain (d-bis.org) @@ -15,7 +15,7 @@ The entire project has been reviewed and updated based on the `.env` files (line ### Backend (.env) - ✅ **DATABASE_URL**: `postgresql://solace_user@192.168.11.62:5432/solace_treasury` -- ✅ **RPC_URL**: `http://192.168.11.250:8545` (Chain 138) +- ✅ **RPC_URL**: `http://192.168.11.211:8545` (Chain 138) - ✅ **CHAIN_ID**: `138` - ✅ **PORT**: `3001` - ✅ **NODE_ENV**: `production` @@ -103,10 +103,10 @@ The entire project has been reviewed and updated based on the `.env` files (line ### Chain 138 Network - **Chain ID**: 138 - **RPC Endpoints**: - - Primary: `http://192.168.11.250:8545` + - Primary: `http://192.168.11.211:8545` - Backup 1: `http://192.168.11.251:8545` - Backup 2: `http://192.168.11.252:8545` -- **WebSocket**: `ws://192.168.11.250:8546` +- **WebSocket**: `ws://192.168.11.211:8546` - **Block Explorer**: `http://192.168.11.140` - **Gas Price**: 0 (zero base fee) diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 889328c..a103486 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -208,16 +208,15 @@ solace-bg-dubai/ 3. **Testing** - Integration testing - - E2E testing with Playwright + - ✅ Playwright E2E smoke tests (`pnpm run test:e2e`) - Security audit of contracts - Load testing for indexer 4. **Enhancements** (Optional) - - Add sub-account creation UI flow - - Implement transaction memo storage - - Add ERC-20 token selection UI - - Enhance 3D visualizations - - Add more animation effects + - ✅ Sub-account creation UI flow (Settings → Sub-Accounts) + - ✅ Transaction memo storage (UTF-8 encoded in proposal calldata) + - ✅ ERC-20 token selection UI (ETH, cUSDT, cUSDC on Chain 138) + - Enhance 3D visualizations (future) — treasury balance torus on dashboard uses React Three Fiber + auto-rotate ## Key Features Delivered @@ -237,7 +236,6 @@ solace-bg-dubai/ - The implementation follows the modular smart account approach (Option A) - All contracts include comprehensive NatSpec documentation -- Frontend includes placeholder data structures that can be connected to backend -- Event indexer structure is ready but requires contract addresses configuration -- Some UI components include TODO comments for backend integration points +- Frontend is connected to backend API and Chain 138 contracts +- Event indexer persists proposals, approvals, executions, and sub-accounts to PostgreSQL diff --git a/README.md b/README.md index cce0642..0c02daf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,17 @@ A comprehensive Treasury Management DApp with Smart Wallet capabilities, multisig support, sub-accounts, and advanced 3D UI. -## Architecture +> **Status:** Local dev complete — see **[STATUS.md](./STATUS.md)** · verify with `pnpm run verify:full-stack` + +## Quick start (local) + +```bash +pnpm install +pnpm run start:local # Postgres + migrate + seed + dev servers +pnpm run verify:full-stack +pnpm run test:e2e +``` + - **Frontend**: Next.js 14+ with TypeScript, Tailwind CSS, GSAP, and Three.js - **Smart Contracts**: Solidity contracts using Hardhat @@ -137,7 +147,7 @@ pnpm run db:migrate # Run database migrations ### Contracts - `SEPOLIA_RPC_URL` - Sepolia RPC endpoint - `MAINNET_RPC_URL` - Mainnet RPC endpoint -- `CHAIN138_RPC_URL` - Chain 138 RPC endpoint (default: http://192.168.11.250:8545) +- `CHAIN138_RPC_URL` - Chain 138 RPC endpoint (default: http://192.168.11.211:8545) - `PRIVATE_KEY` - Deployer private key - `ETHERSCAN_API_KEY` - Etherscan API key for verification @@ -164,10 +174,10 @@ pnpm run deploy:chain138 - **Chain ID**: 138 - **RPC Endpoints**: - - http://192.168.11.250:8545 + - http://192.168.11.211:8545 - http://192.168.11.251:8545 - http://192.168.11.252:8545 -- **WebSocket**: ws://192.168.11.250:8546 +- **WebSocket**: ws://192.168.11.211:8546 - **Network Type**: Custom Besu (QBFT consensus) ## Proxmox VE Deployment @@ -178,7 +188,7 @@ The DApp can be deployed on Proxmox VE using LXC containers. - Proxmox VE host with LXC support - Ubuntu 22.04 LTS template available -- Network access to Chain 138 RPC nodes (192.168.11.250-252) +- Network access to Chain 138 RPC nodes (192.168.11.211-252) ### Deployment Steps diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..9a72e30 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,43 @@ +# Deployment Status — Solace Treasury DApp + +**Updated:** 2026-05-27 +**Local dev:** complete and verified (`pnpm run verify:full-stack`) + +## Local development (this workstation) + +| Component | Status | Command | +|-----------|--------|---------| +| Chain 138 contracts | Deployed + verified on Blockscout | `contracts/deployments/chain138.json` | +| PostgreSQL | Docker `solace-postgres` | `docker start solace-postgres` | +| Backend API | `:3001` | `pnpm run dev` | +| Frontend | `:3000` | `pnpm run dev` | +| Indexer | DB + checkpoint | `cd backend && pnpm run indexer:start` | +| E2E smoke | Playwright | `pnpm run test:e2e` | + +## Proxmox production (LAN + public) + +| CT | VMID | IP | Role | +|----|------|-----|------| +| solace-frontend | 3000 | 192.168.11.60 | Next.js + Nginx | +| solace-backend | 3001 | 192.168.11.61 | API :3001 | +| solace-db | 3002 | 192.168.11.62 | PostgreSQL | +| solace-indexer | 3003 | 192.168.11.66 | Event indexer | + +**Public:** https://treasury.d-bis.org (NPMplus LE + Cloudflare DNS) + +```bash +# From proxmox repo: +./scripts/deployment/sync-solace-treasury-lxc.sh --resume +./scripts/verify/verify-solace-treasury-public-e2e.sh +``` + +## Quick verification + +```bash +pnpm run audit-env +pnpm run verify:full-stack +pnpm run verify:contracts # Blockscout @ 192.168.11.140 (LAN) +pnpm run test:e2e # requires dev server + playwright install +``` + +See also: [ERRORS_AND_ISSUES.md](./ERRORS_AND_ISSUES.md), [deployment/REMAINING_STEPS.md](./deployment/REMAINING_STEPS.md) diff --git a/UPDATE_SUMMARY.md b/UPDATE_SUMMARY.md index 6450a5e..87852e7 100644 --- a/UPDATE_SUMMARY.md +++ b/UPDATE_SUMMARY.md @@ -30,7 +30,7 @@ Based on the `.env` files reviewed: **Contracts (.env)**: -- Chain 138 RPC: `http://192.168.11.250:8545` +- Chain 138 RPC: `http://192.168.11.211:8545` - Private key configured - Multiple block explorer API keys - Cloudflare configuration @@ -38,7 +38,7 @@ Based on the `.env` files reviewed: **Backend (.env)**: - Database: PostgreSQL at `192.168.11.62:5432` -- Chain 138 RPC: `http://192.168.11.250:8545` +- Chain 138 RPC: `http://192.168.11.211:8545` - Chain ID: 138 - Port: 3001 - Production mode diff --git a/backend/.env.example b/backend/.env.example index 3e870c9..42d2492 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,8 @@ DATABASE_URL=postgresql://solace_user:your_password@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 +CLIENT_RPC_URL=https://rpc-http-pub.d-bis.org CHAIN_ID=138 CONTRACT_ADDRESS= diff --git a/backend/.env.indexer b/backend/.env.indexer index 2866bec..4601e4c 100644 --- a/backend/.env.indexer +++ b/backend/.env.indexer @@ -2,9 +2,12 @@ DATABASE_URL=postgresql://solace_user:SolaceTreasury2024!@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.235:8545 CHAIN_ID=138 -CONTRACT_ADDRESS= +CONTRACT_ADDRESS=0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C # Indexer Configuration -START_BLOCK=0 +START_BLOCK=5601240 +FACTORY_ADDRESS=0x7fbd6714960e01502263478FBa332eBaE47883fb +NODE_ENV=production +CLIENT_RPC_URL=https://rpc-http-pub.d-bis.org diff --git a/backend/.env.indexer.example b/backend/.env.indexer.example index dc4683b..7920678 100644 --- a/backend/.env.indexer.example +++ b/backend/.env.indexer.example @@ -2,7 +2,7 @@ DATABASE_URL=postgresql://solace_user:your_password@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS= diff --git a/backend/.env.indexer.template b/backend/.env.indexer.template index dc4683b..7920678 100644 --- a/backend/.env.indexer.template +++ b/backend/.env.indexer.template @@ -2,7 +2,7 @@ DATABASE_URL=postgresql://solace_user:your_password@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS= diff --git a/backend/.env.template b/backend/.env.template index 3e870c9..351aeb7 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -2,7 +2,7 @@ DATABASE_URL=postgresql://solace_user:your_password@192.168.11.62:5432/solace_treasury # Chain 138 Configuration -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS= diff --git a/backend/drizzle/0001_indexer_state.sql b/backend/drizzle/0001_indexer_state.sql new file mode 100644 index 0000000..ecaca71 --- /dev/null +++ b/backend/drizzle/0001_indexer_state.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS "indexer_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chain_id" integer NOT NULL, + "contract_address" text NOT NULL, + "last_block" text NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "indexer_state_chain_contract_idx" ON "indexer_state" ("chain_id","contract_address"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "transaction_proposals_treasury_proposal_idx" ON "transaction_proposals" ("treasury_id","proposal_id"); diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index a72f2f3..15f473d 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1766274846179, "tag": "0000_empty_supreme_intelligence", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1766274846180, + "tag": "0001_indexer_state", + "breakpoints": true } ] } diff --git a/backend/package.json b/backend/package.json index e667cc5..7dfb574 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,10 +4,11 @@ "private": true, "scripts": { "dev": "tsx watch src/index.ts", - "build": "tsc", + "build": "tsc --skipLibCheck", "start": "node dist/index.js", "db:generate": "drizzle-kit generate:pg", "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts", "indexer:start": "tsx src/indexer/indexer.ts" }, "dependencies": { @@ -23,6 +24,6 @@ "@types/node": "^20.10.0", "drizzle-kit": "^0.20.0", "tsx": "^4.7.0", - "typescript": "^5.3.3" + "typescript": "5.3.3" } } diff --git a/backend/src/api/exports.ts b/backend/src/api/exports.ts index 716e3b7..a38c64b 100644 --- a/backend/src/api/exports.ts +++ b/backend/src/api/exports.ts @@ -1,4 +1,5 @@ import { transactionRouter } from "./transactions"; +import { decodeMemoData } from "../lib/memo"; export const exportRouter = { // Export transactions as CSV string @@ -13,14 +14,15 @@ export const exportRouter = { "Token", "Status", "Proposer", + "Memo", "Created At", "Executed At", ]; - // CSV rows const rows = transactions.map((tx) => { const createdAt = new Date(tx.createdAt); const executedAt = tx.executedAt ? new Date(tx.executedAt) : null; + const memo = decodeMemoData(tx.data ?? null); return [ tx.proposalId.toString(), @@ -29,6 +31,7 @@ export const exportRouter = { tx.token || "Native", tx.status, tx.proposer, + memo || "", createdAt.toISOString().replace("T", " ").slice(0, 19), executedAt ? executedAt.toISOString().replace("T", " ").slice(0, 19) : "", ]; diff --git a/backend/src/api/ledger.ts b/backend/src/api/ledger.ts new file mode 100644 index 0000000..e2aaabc --- /dev/null +++ b/backend/src/api/ledger.ts @@ -0,0 +1,160 @@ +const EXPLORER_API_BASE = + (process.env.EXPLORER_API_URL || "https://explorer.d-bis.org/api/v2").replace( + /\/$/, + "" + ); + +export interface LedgerEntry { + id: string; + kind: "deposit" | "transfer_out"; + txHash: string; + from: string; + to: string; + value: string; + token: string | null; + tokenSymbol: string | null; + timestamp: string; + blockNumber: number; +} + +interface BlockscoutAddressRef { + hash?: string; +} + +interface BlockscoutNativeTx { + hash: string; + timestamp: string; + block_number: number; + value: string; + from: BlockscoutAddressRef; + to: BlockscoutAddressRef | null; + status?: string; +} + +interface BlockscoutTokenTransfer { + transaction_hash: string; + timestamp: string; + block_number: number; + from: BlockscoutAddressRef; + to: BlockscoutAddressRef; + token?: { address?: string; symbol?: string }; + total?: { value?: string }; +} + +async function fetchExplorerJson(path: string): Promise { + const res = await fetch(`${EXPLORER_API_BASE}${path}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`Explorer API ${path} failed: ${res.status}`); + } + return res.json() as Promise; +} + +export async function fetchTreasuryLedger(wallet: string): Promise { + const normalized = wallet.toLowerCase(); + const entries: LedgerEntry[] = []; + const seen = new Set(); + + const push = (entry: LedgerEntry) => { + if (seen.has(entry.id)) return; + seen.add(entry.id); + entries.push(entry); + }; + + try { + const native = await fetchExplorerJson<{ items: BlockscoutNativeTx[] }>( + `/addresses/${wallet}/transactions` + ); + + for (const tx of native.items ?? []) { + const to = tx.to?.hash?.toLowerCase(); + const from = tx.from?.hash?.toLowerCase(); + const value = BigInt(tx.value || "0"); + if (value <= 0n) continue; + + if (to === normalized) { + push({ + id: `${tx.hash}:native:in`, + kind: "deposit", + txHash: tx.hash, + from: tx.from?.hash ?? "", + to: tx.to?.hash ?? wallet, + value: tx.value, + token: null, + tokenSymbol: "ETH", + timestamp: tx.timestamp, + blockNumber: tx.block_number, + }); + } else if (from === normalized) { + push({ + id: `${tx.hash}:native:out`, + kind: "transfer_out", + txHash: tx.hash, + from: tx.from?.hash ?? wallet, + to: tx.to?.hash ?? "", + value: tx.value, + token: null, + tokenSymbol: "ETH", + timestamp: tx.timestamp, + blockNumber: tx.block_number, + }); + } + } + } catch (error) { + console.error("Ledger native tx fetch failed:", error); + } + + try { + const tokens = await fetchExplorerJson<{ items: BlockscoutTokenTransfer[] }>( + `/addresses/${wallet}/token-transfers` + ); + + for (const tt of tokens.items ?? []) { + const to = tt.to?.hash?.toLowerCase(); + const from = tt.from?.hash?.toLowerCase(); + const raw = tt.total?.value ?? "0"; + if (BigInt(raw) <= 0n) continue; + + const token = tt.token?.address?.toLowerCase() ?? null; + const tokenSymbol = tt.token?.symbol ?? null; + const logKey = `${tt.transaction_hash}:${token ?? "token"}:${to === normalized ? "in" : "out"}`; + + if (to === normalized) { + push({ + id: logKey, + kind: "deposit", + txHash: tt.transaction_hash, + from: tt.from?.hash ?? "", + to: tt.to?.hash ?? wallet, + value: raw, + token, + tokenSymbol, + timestamp: tt.timestamp, + blockNumber: tt.block_number, + }); + } else if (from === normalized) { + push({ + id: logKey, + kind: "transfer_out", + txHash: tt.transaction_hash, + from: tt.from?.hash ?? wallet, + to: tt.to?.hash ?? "", + value: raw, + token, + tokenSymbol, + timestamp: tt.timestamp, + blockNumber: tt.block_number, + }); + } + } + } catch (error) { + console.error("Ledger token transfer fetch failed:", error); + } + + entries.sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + return entries; +} diff --git a/backend/src/api/transactions.ts b/backend/src/api/transactions.ts index cc30adb..7ee4de4 100644 --- a/backend/src/api/transactions.ts +++ b/backend/src/api/transactions.ts @@ -21,6 +21,35 @@ export const transactionRouter = { return await query; }, + // Get a single proposal by on-chain proposal id + getProposalByChainId: async (treasuryId: string, chainProposalId: number) => { + const [proposal] = await db + .select() + .from(transactionProposals) + .where( + and( + eq(transactionProposals.treasuryId, treasuryId), + eq(transactionProposals.proposalId, chainProposalId) + ) + ) + .limit(1); + return proposal || null; + }, + + // Get proposals with approval counts + getProposalsWithApprovals: async (treasuryId: string, status?: string) => { + const proposals = await transactionRouter.getProposals(treasuryId, status); + return Promise.all( + proposals.map(async (proposal) => { + const proposalApprovals = await transactionRouter.getApprovals(proposal.id); + return { + ...proposal, + approvalCount: proposalApprovals.length, + }; + }) + ); + }, + // Get a single proposal by ID getProposal: async (proposalId: string) => { const [proposal] = await db @@ -90,6 +119,7 @@ export const transactionRouter = { to: transactionProposals.to, value: transactionProposals.value, token: transactionProposals.token, + data: transactionProposals.data, status: transactionProposals.status, proposer: transactionProposals.proposer, createdAt: transactionProposals.createdAt, diff --git a/backend/src/api/treasury.ts b/backend/src/api/treasury.ts index 3785a6f..ebaca54 100644 --- a/backend/src/api/treasury.ts +++ b/backend/src/api/treasury.ts @@ -11,14 +11,19 @@ export const treasuryRouter = { // Get treasury by wallet address getByWallet: async (walletAddress: string) => { + const normalized = walletAddress.toLowerCase(); const result = await db .select() .from(treasuries) - .where(eq(treasuries.mainWallet, walletAddress)) + .where(eq(treasuries.mainWallet, normalized)) .limit(1); return result[0] || null; }, + getById: async (treasuryId: string) => { + return await db.select().from(treasuries).where(eq(treasuries.id, treasuryId)).limit(1); + }, + // Create a new treasury create: async (data: { organizationId: string; @@ -26,7 +31,10 @@ export const treasuryRouter = { mainWallet: string; label?: string; }) => { - const [treasury] = await db.insert(treasuries).values(data).returning(); + const [treasury] = await db + .insert(treasuries) + .values({ ...data, mainWallet: data.mainWallet.toLowerCase() }) + .returning(); return treasury; }, @@ -42,7 +50,20 @@ export const treasuryRouter = { label?: string; metadataHash?: string; }) => { - const [subAccount] = await db.insert(subAccounts).values(data).returning(); + const normalized = data.address.toLowerCase(); + const existing = await db + .select() + .from(subAccounts) + .where(eq(subAccounts.address, normalized)) + .limit(1); + if (existing[0]) { + return existing[0]; + } + + const [subAccount] = await db + .insert(subAccounts) + .values({ ...data, address: normalized }) + .returning(); return subAccount; }, }; diff --git a/backend/src/db/bootstrap.ts b/backend/src/db/bootstrap.ts new file mode 100644 index 0000000..5cde742 --- /dev/null +++ b/backend/src/db/bootstrap.ts @@ -0,0 +1,29 @@ +import { db } from "./index"; +import { organizations } from "./schema"; +import { treasuryRouter } from "../api/treasury"; + +export async function ensureTreasury() { + const mainWallet = process.env.CONTRACT_ADDRESS?.toLowerCase(); + const chainId = Number(process.env.CHAIN_ID || "138"); + + if (!mainWallet) { + return null; + } + + const existing = await treasuryRouter.getByWallet(mainWallet); + if (existing) { + return existing; + } + + const [org] = await db + .insert(organizations) + .values({ name: "Solace Dubai Treasury" }) + .returning(); + + return treasuryRouter.create({ + organizationId: org.id, + chainId, + mainWallet, + label: "Main Treasury", + }); +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 1231e3a..20cc6d1 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,3 +1,4 @@ +import "../load-env"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 86a78c1..72838ed 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -87,3 +87,11 @@ export const auditLogs = pgTable("audit_logs", { details: text("details"), // JSON string for additional details createdAt: timestamp("created_at").defaultNow().notNull(), }); + +export const indexerState = pgTable("indexer_state", { + id: uuid("id").defaultRandom().primaryKey(), + chainId: integer("chain_id").notNull(), + contractAddress: text("contract_address").notNull(), + lastBlock: text("last_block").notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts new file mode 100644 index 0000000..8bcd870 --- /dev/null +++ b/backend/src/db/seed.ts @@ -0,0 +1,18 @@ +import "../load-env"; +import { ensureTreasury } from "./bootstrap"; + +async function main() { + const treasury = await ensureTreasury(); + if (!treasury) { + console.error("CONTRACT_ADDRESS is required to seed treasury"); + process.exit(1); + } + + console.log("Treasury ready:", treasury.id, treasury.mainWallet); + process.exit(0); +} + +main().catch((error) => { + console.error("Seed failed:", error); + process.exit(1); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 54e1650..6e340b5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,28 +1,261 @@ -import * as dotenv from "dotenv"; +import "./load-env"; +import http from "node:http"; +import { db } from "./db"; +import { sql } from "drizzle-orm"; +import { transactionRouter } from "./api/transactions"; +import { treasuryRouter } from "./api/treasury"; +import { exportRouter } from "./api/exports"; +import { fetchTreasuryLedger } from "./api/ledger"; +import { ensureTreasury } from "./db/bootstrap"; -dotenv.config(); - -const port = process.env.PORT || 3001; +const port = Number(process.env.PORT || 3001); const nodeEnv = process.env.NODE_ENV || "development"; +const rpcUrl = process.env.RPC_URL || "http://192.168.11.211:8545"; +const clientRpcUrl = + process.env.CLIENT_RPC_URL || + process.env.PUBLIC_RPC_URL || + "https://rpc-http-pub.d-bis.org"; +const chainId = process.env.CHAIN_ID || "138"; +const contractAddress = process.env.CONTRACT_ADDRESS || ""; -console.log("Backend server starting..."); -console.log(`Environment: ${nodeEnv}`); -console.log(`Port: ${port}`); - -// Validate required environment variables -const requiredEnvVars = ["DATABASE_URL", "RPC_URL", "CHAIN_ID"]; - -for (const envVar of requiredEnvVars) { +for (const envVar of ["DATABASE_URL", "RPC_URL", "CHAIN_ID"] as const) { if (!process.env[envVar]) { console.error(`ERROR: ${envVar} environment variable is required`); process.exit(1); } } -console.log(`Chain ID: ${process.env.CHAIN_ID}`); -console.log(`RPC URL: ${process.env.RPC_URL}`); +async function checkDatabase(): Promise { + try { + await db.execute(sql`SELECT 1`); + return true; + } catch { + return false; + } +} -// This would be the main server entry point -// For now, it's a placeholder that can be extended with Express/Fastify/etc. +async function checkRpc(): Promise { + try { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + id: 1, + }), + }); + const data = (await res.json()) as { result?: string }; + return data.result?.toLowerCase() === "0x8a"; + } catch { + return false; + } +} -export {}; +function sendJson(res: http.ServerResponse, status: number, body: unknown) { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(payload); +} + +async function readJsonBody(req: http.IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString()) as Record; +} + +function sendText( + res: http.ServerResponse, + status: number, + body: string, + contentType: string +) { + res.writeHead(status, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(body); +} + +const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return; + } + + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + try { + if ( + req.method === "GET" && + (url.pathname === "/health" || url.pathname === "/api/health") + ) { + const [dbOk, rpcOk] = await Promise.all([checkDatabase(), checkRpc()]); + sendJson(res, dbOk && rpcOk ? 200 : 503, { + status: dbOk && rpcOk ? "ok" : "degraded", + environment: nodeEnv, + chainId: Number(chainId), + rpcUrl, + clientRpcUrl, + contractAddress: contractAddress || null, + database: dbOk, + rpc: rpcOk, + }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/config") { + sendJson(res, 200, { + chainId: Number(chainId), + rpcUrl: clientRpcUrl, + contractAddress: contractAddress || null, + }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/ledger") { + const wallet = url.searchParams.get("wallet"); + if (!wallet) { + sendJson(res, 400, { error: "wallet query parameter required" }); + return; + } + const ledger = await fetchTreasuryLedger(wallet); + sendJson(res, 200, { ledger }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/treasury") { + const wallet = url.searchParams.get("wallet"); + if (!wallet) { + sendJson(res, 400, { error: "wallet query parameter required" }); + return; + } + let treasury = await treasuryRouter.getByWallet(wallet); + if (!treasury && contractAddress.toLowerCase() === wallet.toLowerCase()) { + const ensured = await ensureTreasury(); + if (ensured) treasury = ensured; + } + sendJson(res, 200, { treasury }); + return; + } + + const ledgerMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/ledger$/); + if (req.method === "GET" && ledgerMatch) { + const treasuryId = ledgerMatch[1]; + const treasuryRows = await treasuryRouter.getById(treasuryId); + const treasury = treasuryRows[0]; + if (!treasury) { + sendJson(res, 404, { error: "Treasury not found" }); + return; + } + const ledger = await fetchTreasuryLedger(treasury.mainWallet); + sendJson(res, 200, { ledger }); + return; + } + + const subAccountsMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/sub-accounts$/); + if (subAccountsMatch) { + const treasuryId = subAccountsMatch[1]; + if (req.method === "GET") { + const subAccounts = await treasuryRouter.getSubAccounts(treasuryId); + sendJson(res, 200, { subAccounts }); + return; + } + if (req.method === "POST") { + const body = await readJsonBody(req); + const address = typeof body.address === "string" ? body.address : ""; + if (!address) { + sendJson(res, 400, { error: "address required" }); + return; + } + const subAccount = await treasuryRouter.createSubAccount({ + treasuryId, + address, + label: typeof body.label === "string" ? body.label : undefined, + metadataHash: typeof body.metadataHash === "string" ? body.metadataHash : undefined, + }); + sendJson(res, 201, { subAccount }); + return; + } + } + + const proposalsMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)$/); + if (req.method === "GET" && proposalsMatch) { + const includeApprovals = url.searchParams.get("includeApprovals") === "1"; + const treasuryId = proposalsMatch[1]; + const status = url.searchParams.get("status") || undefined; + const proposals = includeApprovals + ? await transactionRouter.getProposalsWithApprovals(treasuryId, status) + : await transactionRouter.getProposals(treasuryId, status); + sendJson(res, 200, { proposals }); + return; + } + + if (req.method === "GET" && url.pathname.startsWith("/api/transactions/")) { + const treasuryId = url.pathname.split("/").pop(); + if (!treasuryId || treasuryId === "export") { + sendJson(res, 400, { error: "treasuryId required" }); + return; + } + const status = url.searchParams.get("status") || undefined; + const proposals = await transactionRouter.getProposals(treasuryId, status); + sendJson(res, 200, { proposals }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/transactions/export") { + const treasuryId = url.searchParams.get("treasuryId"); + if (!treasuryId) { + sendJson(res, 400, { error: "treasuryId query parameter required" }); + return; + } + const format = url.searchParams.get("format") || "json"; + if (format === "csv") { + const csv = await exportRouter.exportTransactionsCSV(treasuryId); + sendText(res, 200, csv, "text/csv; charset=utf-8"); + return; + } + const rows = await transactionRouter.exportTransactions(treasuryId); + sendJson(res, 200, { rows }); + return; + } + + sendJson(res, 404, { error: "Not found" }); + } catch (error) { + console.error("Request error:", error); + sendJson(res, 500, { error: "Internal server error" }); + } +}); + +void ensureTreasury().then((treasury) => { + if (treasury) { + console.log(`Treasury bootstrapped: ${treasury.mainWallet}`); + } +}); + +server.listen(port, () => { + console.log("Backend server starting..."); + console.log(`Environment: ${nodeEnv}`); + console.log(`Port: ${port}`); + console.log(`Chain ID: ${chainId}`); + console.log(`RPC URL: ${rpcUrl}`); + console.log(`Health: http://localhost:${port}/health`); +}); + +export { server }; diff --git a/backend/src/indexer/indexer.ts b/backend/src/indexer/indexer.ts index a5f922e..fbf3144 100644 --- a/backend/src/indexer/indexer.ts +++ b/backend/src/indexer/indexer.ts @@ -1,13 +1,26 @@ -import { createPublicClient, http, parseAbiItem, defineChain } from "viem"; +import "../load-env"; +import { + createPublicClient, + http, + parseAbiItem, + defineChain, + decodeEventLog, + type Log, +} from "viem"; import { mainnet, sepolia } from "viem/chains"; import { db } from "../db"; -import { transactionProposals, approvals } from "../db/schema"; -import { eq } from "drizzle-orm"; -import * as dotenv from "dotenv"; +import { indexerState } from "../db/schema"; +import { ensureTreasury } from "../db/bootstrap"; +import { transactionRouter } from "../api/transactions"; +import { treasuryRouter } from "../api/treasury"; +import { and, eq } from "drizzle-orm"; -dotenv.config(); +const FACTORY_ABI = [ + parseAbiItem( + "event SubAccountCreated(address indexed parentTreasury, address indexed subAccount, bytes32 metadataHash)" + ), +] as const; -// Define Chain 138 (Custom Besu Network) const chain138 = defineChain({ id: 138, name: "Solace Chain 138", @@ -19,7 +32,7 @@ const chain138 = defineChain({ rpcUrls: { default: { http: [ - process.env.RPC_URL || "http://192.168.11.250:8545", + process.env.RPC_URL || "http://192.168.11.211:8545", "http://192.168.11.251:8545", "http://192.168.11.252:8545", ], @@ -33,14 +46,16 @@ const chain138 = defineChain({ }, }); -// Contract ABIs const TREASURY_WALLET_ABI = [ parseAbiItem( "event TransactionProposed(uint256 indexed proposalId, address indexed to, uint256 value, bytes data, address proposer)" ), parseAbiItem("event TransactionApproved(uint256 indexed proposalId, address indexed approver)"), parseAbiItem("event TransactionExecuted(uint256 indexed proposalId, address indexed executor)"), -]; + parseAbiItem( + "function getTransactionFull(uint256 proposalId) view returns (address to, address token, uint256 value, bytes data, bool executed, uint256 approvalCount)" + ), +] as const; const chains = { 1: mainnet, @@ -51,6 +66,7 @@ const chains = { interface IndexerConfig { chainId: number; contractAddress: `0x${string}`; + factoryAddress?: `0x${string}`; startBlock?: bigint; } @@ -58,7 +74,9 @@ class EventIndexer { private client: ReturnType; private chainId: number; private contractAddress: `0x${string}`; + private factoryAddress?: `0x${string}`; private lastBlock: bigint; + private treasuryId: string | null = null; constructor(config: IndexerConfig) { const chain = chains[config.chainId as keyof typeof chains]; @@ -66,7 +84,7 @@ class EventIndexer { throw new Error(`Unsupported chain ID: ${config.chainId}`); } - const rpcUrl = process.env.RPC_URL || "http://192.168.11.250:8545"; + const rpcUrl = process.env.RPC_URL || "http://192.168.11.211:8545"; this.client = createPublicClient({ chain, transport: http(rpcUrl), @@ -74,11 +92,66 @@ class EventIndexer { this.chainId = config.chainId; this.contractAddress = config.contractAddress; - this.lastBlock = config.startBlock || 0n; + this.factoryAddress = config.factoryAddress; + this.lastBlock = + config.startBlock !== undefined && config.startBlock > 0n + ? config.startBlock - 1n + : 0n; + } + + private async loadCheckpoint(): Promise { + const [row] = await db + .select() + .from(indexerState) + .where( + and( + eq(indexerState.chainId, this.chainId), + eq(indexerState.contractAddress, this.contractAddress.toLowerCase()) + ) + ) + .limit(1); + + return row ? BigInt(row.lastBlock) : null; + } + + private async saveCheckpoint(block: bigint) { + const contractAddress = this.contractAddress.toLowerCase(); + const [existing] = await db + .select() + .from(indexerState) + .where( + and( + eq(indexerState.chainId, this.chainId), + eq(indexerState.contractAddress, contractAddress) + ) + ) + .limit(1); + + if (existing) { + await db + .update(indexerState) + .set({ lastBlock: block.toString(), updatedAt: new Date() }) + .where(eq(indexerState.id, existing.id)); + return; + } + + await db.insert(indexerState).values({ + chainId: this.chainId, + contractAddress, + lastBlock: block.toString(), + }); } async indexEvents() { try { + if (!this.treasuryId) { + const treasury = await ensureTreasury(); + if (!treasury) { + throw new Error("Treasury bootstrap failed — set CONTRACT_ADDRESS"); + } + this.treasuryId = treasury.id; + } + const currentBlock = await this.client.getBlockNumber(); const fromBlock = this.lastBlock + 1n; const toBlock = currentBlock; @@ -88,83 +161,214 @@ class EventIndexer { return; } - console.log(`Indexing blocks ${fromBlock} to ${toBlock}`); + const chunkSize = BigInt(process.env.INDEXER_BLOCK_CHUNK || "2000"); + console.log(`Indexing blocks ${fromBlock} to ${toBlock} (chunk ${chunkSize})`); - // Index TransactionProposed events - const proposedLogs = await this.client.getLogs({ - address: this.contractAddress, - event: TREASURY_WALLET_ABI[0], - fromBlock, - toBlock, - }); - - for (const log of proposedLogs) { - await this.handleTransactionProposed(log); - } - - // Index TransactionApproved events - const approvedLogs = await this.client.getLogs({ - address: this.contractAddress, - event: TREASURY_WALLET_ABI[1], - fromBlock, - toBlock, - }); - - for (const log of approvedLogs) { - await this.handleTransactionApproved(log); - } - - // Index TransactionExecuted events - const executedLogs = await this.client.getLogs({ - address: this.contractAddress, - event: TREASURY_WALLET_ABI[2], - fromBlock, - toBlock, - }); - - for (const log of executedLogs) { - await this.handleTransactionExecuted(log); + for (let start = fromBlock; start <= toBlock; start += chunkSize) { + const end = start + chunkSize - 1n > toBlock ? toBlock : start + chunkSize - 1n; + await this.indexChunk(start, end); } this.lastBlock = toBlock; + await this.saveCheckpoint(toBlock); console.log(`Indexed up to block ${toBlock}`); } catch (error) { console.error("Error indexing events:", error); } } - private async handleTransactionProposed(log: any) { - // TODO: Map proposal to treasury in database - // This requires knowing which treasury wallet this event came from - console.log("Transaction proposed:", log); + private async indexChunk(fromBlock: bigint, toBlock: bigint) { + const proposedLogs = await this.client.getLogs({ + address: this.contractAddress, + event: TREASURY_WALLET_ABI[0], + fromBlock, + toBlock, + }); + + for (const log of proposedLogs) { + await this.handleTransactionProposed(log); + } + + const approvedLogs = await this.client.getLogs({ + address: this.contractAddress, + event: TREASURY_WALLET_ABI[1], + fromBlock, + toBlock, + }); + + for (const log of approvedLogs) { + await this.handleTransactionApproved(log); + } + + const executedLogs = await this.client.getLogs({ + address: this.contractAddress, + event: TREASURY_WALLET_ABI[2], + fromBlock, + toBlock, + }); + + for (const log of executedLogs) { + await this.handleTransactionExecuted(log); + } + + if (this.factoryAddress) { + const factoryLogs = await this.client.getLogs({ + address: this.factoryAddress, + event: FACTORY_ABI[0], + fromBlock, + toBlock, + }); + + for (const log of factoryLogs) { + await this.handleSubAccountCreated(log); + } + } } - private async handleTransactionApproved(log: any) { - // TODO: Add approval to database - console.log("Transaction approved:", log); + private async handleSubAccountCreated(log: Log) { + if (!this.treasuryId) return; + + const decoded = decodeEventLog({ + abi: FACTORY_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName !== "SubAccountCreated") return; + + const { subAccount, metadataHash } = decoded.args; + + await treasuryRouter.createSubAccount({ + treasuryId: this.treasuryId, + address: subAccount as string, + metadataHash: metadataHash as string, + }); + + console.log(`Indexed sub-account ${subAccount}`); } - private async handleTransactionExecuted(log: any) { - // TODO: Update proposal status to executed - console.log("Transaction executed:", log); + private async handleTransactionProposed(log: Log) { + if (!this.treasuryId) return; + + const decoded = decodeEventLog({ + abi: TREASURY_WALLET_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName !== "TransactionProposed") return; + + const { proposalId, to, value, data, proposer } = decoded.args; + const chainProposalId = Number(proposalId); + + const existing = await transactionRouter.getProposalByChainId( + this.treasuryId, + chainProposalId + ); + if (existing) return; + + let tokenAddress: string | undefined; + try { + const full = await this.client.readContract({ + address: this.contractAddress, + abi: TREASURY_WALLET_ABI, + functionName: "getTransactionFull", + args: [BigInt(chainProposalId)], + }); + const token = full[1] as string; + if (token && token !== "0x0000000000000000000000000000000000000000") { + tokenAddress = token.toLowerCase(); + } + } catch { + tokenAddress = undefined; + } + + await transactionRouter.createProposal({ + treasuryId: this.treasuryId, + proposalId: chainProposalId, + walletAddress: this.contractAddress, + to: to as string, + value: value.toString(), + token: tokenAddress, + data: data as string, + proposer: proposer as string, + }); + + console.log(`Indexed proposal #${chainProposalId}`); + } + + private async handleTransactionApproved(log: Log) { + if (!this.treasuryId) return; + + const decoded = decodeEventLog({ + abi: TREASURY_WALLET_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName !== "TransactionApproved") return; + + const { proposalId, approver } = decoded.args; + const proposal = await transactionRouter.getProposalByChainId( + this.treasuryId, + Number(proposalId) + ); + if (!proposal) return; + + const existingApprovals = await transactionRouter.getApprovals(proposal.id); + if (existingApprovals.some((a) => a.signer.toLowerCase() === (approver as string).toLowerCase())) { + return; + } + + await transactionRouter.addApproval({ + proposalId: proposal.id, + signer: approver as string, + }); + + console.log(`Indexed approval for proposal #${proposalId}`); + } + + private async handleTransactionExecuted(log: Log) { + if (!this.treasuryId) return; + + const decoded = decodeEventLog({ + abi: TREASURY_WALLET_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName !== "TransactionExecuted") return; + + const { proposalId } = decoded.args; + const proposal = await transactionRouter.getProposalByChainId( + this.treasuryId, + Number(proposalId) + ); + if (!proposal || proposal.status === "executed") return; + + await transactionRouter.updateProposalStatus(proposal.id, "executed", new Date()); + console.log(`Indexed execution for proposal #${proposalId}`); } async start() { console.log(`Starting indexer for chain ${this.chainId}, contract ${this.contractAddress}`); - // Index existing events + const checkpoint = await this.loadCheckpoint(); + if (checkpoint !== null) { + this.lastBlock = checkpoint; + console.log(`Resuming from checkpoint block ${checkpoint}`); + } + await this.indexEvents(); - // Poll for new events every 12 seconds (average block time) setInterval(async () => { await this.indexEvents(); }, 12000); } } -// Start indexer if run directly if (require.main === module) { - const chainId = parseInt(process.env.CHAIN_ID || "138"); // Default to Chain 138 + const chainId = parseInt(process.env.CHAIN_ID || "138"); const contractAddress = process.env.CONTRACT_ADDRESS; if (!contractAddress) { @@ -172,9 +376,17 @@ if (require.main === module) { process.exit(1); } + const factoryAddress = process.env.FACTORY_ADDRESS; + + const startBlock = process.env.START_BLOCK + ? BigInt(process.env.START_BLOCK) + : undefined; + const indexer = new EventIndexer({ chainId, contractAddress: contractAddress as `0x${string}`, + factoryAddress: factoryAddress as `0x${string}` | undefined, + startBlock, }); indexer.start().catch(console.error); diff --git a/backend/src/lib/memo.ts b/backend/src/lib/memo.ts new file mode 100644 index 0000000..ab7dc2a --- /dev/null +++ b/backend/src/lib/memo.ts @@ -0,0 +1,13 @@ +export function decodeMemoData(data: string | null | undefined): string | null { + if (!data || data === "0x") return null; + try { + const hex = data.startsWith("0x") ? data.slice(2) : data; + const text = Buffer.from(hex, "hex").toString("utf8"); + if (text && /^[\x20-\x7E\s]+$/.test(text)) { + return text.trim(); + } + } catch { + return null; + } + return null; +} diff --git a/backend/src/load-env.ts b/backend/src/load-env.ts new file mode 100644 index 0000000..bb66e2d --- /dev/null +++ b/backend/src/load-env.ts @@ -0,0 +1,28 @@ +import * as dotenv from "dotenv"; +import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +const cwd = process.cwd(); +const candidates = [ + path.join(cwd, ".env"), + path.join(cwd, "backend", ".env"), +]; + +for (const envPath of candidates) { + if (existsSync(envPath)) { + dotenv.config({ path: envPath }); + break; + } +} + +for (const overlay of [".env.indexer", path.join("backend", ".env.indexer")]) { + const overlayPath = path.join(cwd, overlay); + if (!existsSync(overlayPath)) continue; + + const parsed = dotenv.parse(readFileSync(overlayPath)); + for (const [key, value] of Object.entries(parsed)) { + if (value !== "") { + process.env[key] = value; + } + } +} diff --git a/contracts/.env.example b/contracts/.env.example index 3aa94e5..e3e8819 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -1,7 +1,7 @@ # Network RPC URLs SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/your_api_key MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_api_key -CHAIN138_RPC_URL=http://192.168.11.250:8545 +CHAIN138_RPC_URL=http://192.168.11.211:8545 # Private key for deployments (NEVER commit this) # Use a test account private key with sufficient balance on Chain 138 diff --git a/contracts/deployments/chain138.json b/contracts/deployments/chain138.json new file mode 100644 index 0000000..ea7fde6 --- /dev/null +++ b/contracts/deployments/chain138.json @@ -0,0 +1,11 @@ +{ + "network": "chain138", + "chainId": 138, + "deployedAt": "2026-05-24T04:44:02.383Z", + "deployer": "0x4A666F96fC8764181194447A7dFdb7d471b301C8", + "deployBlock": 5601240, + "contracts": { + "SubAccountFactory": "0x7fbd6714960e01502263478FBa332eBaE47883fb", + "TreasuryWallet": "0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C" + } +} \ No newline at end of file diff --git a/contracts/hardhat.config.ts b/contracts/hardhat.config.ts index f75cbcd..0c30505 100644 --- a/contracts/hardhat.config.ts +++ b/contracts/hardhat.config.ts @@ -1,8 +1,9 @@ import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; import * as dotenv from "dotenv"; +import path from "node:path"; -dotenv.config(); +dotenv.config({ path: path.join(__dirname, ".env") }); const config: HardhatUserConfig = { solidity: { @@ -32,17 +33,27 @@ const config: HardhatUserConfig = { chainId: 1, }, chain138: { - url: process.env.CHAIN138_RPC_URL || "http://192.168.11.250:8545", + url: process.env.CHAIN138_RPC_URL || "http://192.168.11.211:8545", accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], chainId: 138, - gasPrice: 0, // Chain 138 uses zero base fee }, }, etherscan: { apiKey: { sepolia: process.env.ETHERSCAN_API_KEY || "", mainnet: process.env.ETHERSCAN_API_KEY || "", + chain138: "empty", }, + customChains: [ + { + network: "chain138", + chainId: 138, + urls: { + apiURL: "http://192.168.11.140:4000/api", + browserURL: "http://192.168.11.140", + }, + }, + ], }, paths: { sources: "./contracts", diff --git a/contracts/package.json b/contracts/package.json index 9a1f93c..b92f216 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -9,6 +9,7 @@ "deploy:sepolia": "hardhat run scripts/deploy.ts --network sepolia", "deploy:local": "hardhat run scripts/deploy.ts --network localhost", "deploy:chain138": "hardhat run scripts/deploy-chain138.ts --network chain138", + "verify:chain138": "hardhat run scripts/verify-chain138.ts --network chain138", "node": "hardhat node", "clean": "hardhat clean" }, diff --git a/contracts/scripts/deploy-chain138.ts b/contracts/scripts/deploy-chain138.ts index a96a5d3..2d4ade5 100644 --- a/contracts/scripts/deploy-chain138.ts +++ b/contracts/scripts/deploy-chain138.ts @@ -24,8 +24,13 @@ async function main() { console.log("\nDeploying SubAccountFactory..."); const SubAccountFactory = await ethers.getContractFactory("SubAccountFactory"); const factory = await SubAccountFactory.deploy(); + const factoryDeployTx = factory.deploymentTransaction(); await factory.waitForDeployment(); const factoryAddress = await factory.getAddress(); + const factoryBlock = + factoryDeployTx != null + ? (await factoryDeployTx.wait())?.blockNumber ?? null + : null; console.log("SubAccountFactory deployed to:", factoryAddress); // Deploy a TreasuryWallet with initial owners @@ -37,8 +42,13 @@ async function main() { const TreasuryWallet = await ethers.getContractFactory("TreasuryWallet"); const treasury = await TreasuryWallet.deploy(owners, threshold); + const treasuryDeployTx = treasury.deploymentTransaction(); await treasury.waitForDeployment(); const treasuryAddress = await treasury.getAddress(); + const treasuryBlock = + treasuryDeployTx != null + ? (await treasuryDeployTx.wait())?.blockNumber ?? null + : null; console.log("TreasuryWallet deployed to:", treasuryAddress); // Save deployment addresses to a JSON file @@ -47,6 +57,7 @@ async function main() { chainId: 138, deployedAt: new Date().toISOString(), deployer: deployer.address, + deployBlock: treasuryBlock ?? factoryBlock ?? null, contracts: { SubAccountFactory: factoryAddress, TreasuryWallet: treasuryAddress, diff --git a/contracts/scripts/verify-chain138.ts b/contracts/scripts/verify-chain138.ts new file mode 100644 index 0000000..daa54a4 --- /dev/null +++ b/contracts/scripts/verify-chain138.ts @@ -0,0 +1,59 @@ +import { run, network } from "hardhat"; +import * as fs from "fs"; +import * as path from "path"; + +async function main() { + const deployPath = path.join(__dirname, "../deployments/chain138.json"); + if (!fs.existsSync(deployPath)) { + throw new Error("Missing deployments/chain138.json — run pnpm run deploy:chain138 first"); + } + + const deployment = JSON.parse(fs.readFileSync(deployPath, "utf8")) as { + deployer: string; + contracts: { SubAccountFactory: string; TreasuryWallet: string }; + }; + + if (network.name !== "chain138") { + console.log("Using network:", network.name); + } + + console.log("Verifying SubAccountFactory:", deployment.contracts.SubAccountFactory); + try { + await run("verify:verify", { + address: deployment.contracts.SubAccountFactory, + constructorArguments: [], + }); + console.log("SubAccountFactory verified"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("Already Verified") || msg.includes("already verified")) { + console.log("SubAccountFactory already verified"); + } else { + console.warn("SubAccountFactory verify:", msg); + } + } + + const owners = [deployment.deployer]; + const threshold = 1; + + console.log("Verifying TreasuryWallet:", deployment.contracts.TreasuryWallet); + try { + await run("verify:verify", { + address: deployment.contracts.TreasuryWallet, + constructorArguments: [owners, threshold], + }); + console.log("TreasuryWallet verified"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("Already Verified") || msg.includes("already verified")) { + console.log("TreasuryWallet already verified"); + } else { + console.warn("TreasuryWallet verify:", msg); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/deployment/REMAINING_STEPS.md b/deployment/REMAINING_STEPS.md index 0d653a1..d0861b9 100644 --- a/deployment/REMAINING_STEPS.md +++ b/deployment/REMAINING_STEPS.md @@ -1,105 +1,49 @@ # Remaining Steps to Complete Deployment -## Critical Issues +> **Updated 2026-05-27:** Proxmox production stack **live** on `r630-01`. Public edge **`https://treasury.d-bis.org`** (Cloudflare DNS → NPMplus → LXC 3000 nginx :80). -### 1. Besu RPC Nodes Not Running -**Status**: ❌ Blocking contract deployment +## Production (Proxmox r630-01) — done -**Issue**: The Besu RPC nodes (VMIDs 2500, 2501, 2502) are failing to start due to a RocksDB database metadata corruption issue. +| CT | VMID | IP | Service | +|----|------|-----|---------| +| solace-frontend | 3000 | 192.168.11.60 | Next.js + Nginx (LAN HTTPS self-signed; public via NPMplus LE) | +| solace-backend | 3001 | 192.168.11.61 | API `:3001` | +| solace-db | 3002 | 192.168.11.62 | PostgreSQL | +| solace-indexer | 3003 | 192.168.11.66 | Event indexer | -**Error**: -``` -Failed to retrieve the RocksDB database meta version: No content to map due to end-of-input -``` +**Public URL:** `https://treasury.d-bis.org` (nginx proxies `/api/` → backend) -**Fix Required**: -1. Check Besu data directories in containers 2500, 2501, 2502 -2. Either fix the database metadata or reinitialize the blockchain nodes -3. Verify RPC endpoints are accessible: - - http://192.168.11.250:8545 - - http://192.168.11.251:8545 - - http://192.168.11.252:8545 - -**Commands to diagnose**: +**Verify:** ```bash -ssh root@192.168.11.10 "pct exec 2500 -- ls -la /var/lib/besu/data" -ssh root@192.168.11.10 "pct exec 2500 -- journalctl -u besu-rpc -n 50" +curl -sf https://treasury.d-bis.org/api/config +curl -sf http://192.168.11.61:3001/health +curl -sf http://192.168.11.60/api/config +# From proxmox repo: +./scripts/verify/verify-solace-treasury-public-e2e.sh +./scripts/deployment/sync-solace-treasury-lxc.sh --verify ``` -### 2. Contract Deployment -**Status**: ⏳ Waiting for RPC nodes +## Local development — done -Once RPC nodes are fixed, deploy contracts: -```bash -ssh root@192.168.11.10 "pct exec 3001 -- bash -c 'cd /opt/contracts && export PATH=\"/root/.local/share/pnpm:\$PATH\" && pnpm run deploy:chain138'" -``` +See **[STATUS.md](../STATUS.md)** — `pnpm run verify:full-stack` -After deployment, update these environment files with the deployed addresses: -- `frontend/.env` -- `frontend/.env.production` -- `frontend/.env.local` -- `backend/.env` -- `contracts/.env` - -## Completed Steps ✅ - -1. ✅ Chain 138 network configuration added to frontend, backend, and contracts -2. ✅ Environment files created with Chain 138 RPC URLs -3. ✅ Frontend container deployed (VMID 3000) -4. ✅ Backend container deployed (VMID 3001) -5. ✅ Indexer container deployed (VMID 3002) -6. ✅ Database container deployed (VMID 3003) -7. ✅ Contracts code copied to backend container -8. ✅ Systemd services configured for all components - -## Next Steps After RPC Fix - -1. **Deploy Contracts** - ```bash - ssh root@192.168.11.10 "pct exec 3001 -- bash -c 'cd /opt/contracts && export PATH=\"/root/.local/share/pnpm:\$PATH\" && pnpm run deploy:chain138'" - ``` - -2. **Update Contract Addresses** - - Extract deployed addresses from deployment output - - Update `NEXT_PUBLIC_TREASURY_WALLET_ADDRESS` in frontend env files - - Update `NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS` in frontend env files - - Update `CONTRACT_ADDRESS` in backend/.env - - Update `CONTRACT_ADDRESS` in indexer container - -3. **Restart Services** - ```bash - ssh root@192.168.11.10 "pct exec 3000 -- systemctl restart solace-frontend" - ssh root@192.168.11.10 "pct exec 3001 -- systemctl restart solace-backend" - ssh root@192.168.11.10 "pct exec 3002 -- systemctl restart solace-indexer" - ``` - -4. **Verify Deployment** - - Check frontend: http://192.168.11.60 (or configured domain) - - Check backend API: http://192.168.11.61:3001 - - Check indexer logs: `ssh root@192.168.11.10 "pct exec 3002 -- journalctl -u solace-indexer -f"` - -## Service Status Check +## Re-deploy / update ```bash -# Check all services -ssh root@192.168.11.10 "pct exec 3000 -- systemctl status solace-frontend" -ssh root@192.168.11.10 "pct exec 3001 -- systemctl status solace-backend" -ssh root@192.168.11.10 "pct exec 3002 -- systemctl status solace-indexer" -ssh root@192.168.11.10 "pct exec 3000 -- systemctl status nginx" +# From solace-bg-dubai: +export DATABASE_PASSWORD='your_password' +bash scripts/proxmox-resume-deploy.sh + +# From proxmox repo: +./scripts/deployment/sync-solace-treasury-lxc.sh --resume +./scripts/deployment/sync-solace-treasury-lxc.sh --public # + DNS/NPM/LE ``` -## Network Configuration - -- **Frontend**: 192.168.11.60 (VMID 3000) -- **Backend**: 192.168.11.61 (VMID 3001) -- **Indexer**: 192.168.11.62 (VMID 3002) -- **Database**: 192.168.11.63 (VMID 3003) -- **RPC Nodes**: 192.168.11.250-252 (VMIDs 2500-2502) - -## Environment Files Location - -- Frontend: `/opt/solace-frontend/.env` (VMID 3000) -- Backend: `/opt/solace-backend/.env` (VMID 3001) -- Indexer: `/opt/solace-indexer/.env` (VMID 3002) -- Contracts: `/opt/contracts/.env` (VMID 3001) +## Optional — status +| Item | Status | +|------|--------| +| Let's Encrypt (public) | ✅ Done — NPMplus certs for `treasury.d-bis.org` + `www` | +| Public DNS | ✅ Done — Cloudflare A → `76.53.10.36` | +| nginx `/api/` same-origin | ✅ Fixed — port 80 serves app (NPM edge); HTTPS for LAN | +| Contract security audit | ⏳ Recommended before mainnet-scale treasury use (external) | diff --git a/deployment/proxmox/DEPLOY_INSTRUCTIONS.md b/deployment/proxmox/DEPLOY_INSTRUCTIONS.md index 1d5f00f..a09c037 100644 --- a/deployment/proxmox/DEPLOY_INSTRUCTIONS.md +++ b/deployment/proxmox/DEPLOY_INSTRUCTIONS.md @@ -85,7 +85,7 @@ Before deploying, ensure: 3. **Network access:** - IP addresses 192.168.11.60-63 available - - Access to Chain 138 RPC nodes (192.168.11.250-252) + - Access to Chain 138 RPC nodes (192.168.11.211-252) 4. **Database password set:** ```bash diff --git a/deployment/proxmox/README.md b/deployment/proxmox/README.md index fa4b2dc..3f92e22 100644 --- a/deployment/proxmox/README.md +++ b/deployment/proxmox/README.md @@ -24,7 +24,7 @@ The DApp is deployed across multiple LXC containers: 3. **Network Configuration** - VLAN 103 (Services network) configured - IP addresses available: 192.168.11.60-63 - - Access to Chain 138 RPC nodes (192.168.11.250-252) + - Access to Chain 138 RPC nodes (192.168.11.211-252) ## Quick Start @@ -84,8 +84,8 @@ After deployment, you need to configure environment variables for each service. Create `frontend/.env.production`: ```env -NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.250:8545 -NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.250:8546 +NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.211:8545 +NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.211:8546 NEXT_PUBLIC_CHAIN_ID=138 NEXT_PUBLIC_TREASURY_WALLET_ADDRESS= NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS= @@ -104,7 +104,7 @@ Create `backend/.env`: ```env DATABASE_URL=postgresql://solace_user:password@192.168.11.62:5432/solace_treasury -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS= PORT=3001 @@ -122,7 +122,7 @@ Create `backend/.env.indexer`: ```env DATABASE_URL=postgresql://solace_user:password@192.168.11.62:5432/solace_treasury -RPC_URL=http://192.168.11.250:8545 +RPC_URL=http://192.168.11.211:8545 CHAIN_ID=138 CONTRACT_ADDRESS= START_BLOCK=0 @@ -272,7 +272,7 @@ pct exec 3002 -- journalctl -u postgresql -f # Test RPC connection from backend container pct exec 3001 -- curl -X POST -H "Content-Type: application/json" \ --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - http://192.168.11.250:8545 + http://192.168.11.211:8545 ``` ## Backup and Maintenance diff --git a/deployment/proxmox/config/dapp.conf b/deployment/proxmox/config/dapp.conf index 0cf77be..a2c0ee1 100644 --- a/deployment/proxmox/config/dapp.conf +++ b/deployment/proxmox/config/dapp.conf @@ -5,7 +5,7 @@ PROXMOX_BRIDGE="${PROXMOX_BRIDGE:-vmbr0}" PROXMOX_STORAGE="${PROXMOX_STORAGE:-local-lvm}" CONTAINER_OS_TEMPLATE="${CONTAINER_OS_TEMPLATE:-local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst}" -CONTAINER_UNPRIVILEGED="${CONTAINER_UNPRIVILEGED:-1}" +CONTAINER_UNPRIVILEGED="${CONTAINER_UNPRIVILEGED:-0}" CONTAINER_SWAP="${CONTAINER_SWAP:-512}" CONTAINER_ONBOOT="${CONTAINER_ONBOOT:-1}" CONTAINER_TIMEZONE="${CONTAINER_TIMEZONE:-UTC}" @@ -26,7 +26,7 @@ VMID_NGINX="${VMID_NGINX:-3004}" FRONTEND_IP="${FRONTEND_IP:-192.168.11.60}" BACKEND_IP="${BACKEND_IP:-192.168.11.61}" DATABASE_IP="${DATABASE_IP:-192.168.11.62}" -INDEXER_IP="${INDEXER_IP:-192.168.11.63}" +INDEXER_IP="${INDEXER_IP:-192.168.11.66}" NGINX_IP="${NGINX_IP:-192.168.11.64}" # Container Resources @@ -50,9 +50,12 @@ NGINX_MEMORY="${NGINX_MEMORY:-1024}" NGINX_CORES="${NGINX_CORES:-1}" NGINX_DISK="${NGINX_DISK:-10}" -# Chain 138 RPC Configuration -CHAIN138_RPC_URL="${CHAIN138_RPC_URL:-http://192.168.11.250:8545}" -CHAIN138_WS_URL="${CHAIN138_WS_URL:-ws://192.168.11.250:8546}" +# Chain 138 RPC — LAN for backend/indexer; public HTTPS for browser clients +CHAIN138_RPC_URL="${CHAIN138_RPC_URL:-http://192.168.11.211:8545}" +CHAIN138_WS_URL="${CHAIN138_WS_URL:-ws://192.168.11.211:8546}" +CHAIN138_PUBLIC_RPC_URL="${CHAIN138_PUBLIC_RPC_URL:-https://rpc-http-pub.d-bis.org}" +CHAIN138_PUBLIC_EXPLORER_URL="${CHAIN138_PUBLIC_EXPLORER_URL:-https://explorer.d-bis.org}" +CLIENT_RPC_URL="${CLIENT_RPC_URL:-$CHAIN138_PUBLIC_RPC_URL}" CHAIN_ID="${CHAIN_ID:-138}" # Application Ports diff --git a/deployment/proxmox/deploy-backend.sh b/deployment/proxmox/deploy-backend.sh index 278c9d6..2f2df47 100755 --- a/deployment/proxmox/deploy-backend.sh +++ b/deployment/proxmox/deploy-backend.sh @@ -13,7 +13,7 @@ fi # Default values VMID="${VMID_BACKEND:-3001}" -HOSTNAME="${HOSTNAME:-solace-backend}" +HOSTNAME="${CONTAINER_HOSTNAME:-solace-backend}" IP_ADDRESS="${BACKEND_IP:-192.168.11.61}" MEMORY="${BACKEND_MEMORY:-2048}" CORES="${BACKEND_CORES:-2}" @@ -126,8 +126,7 @@ pct exec "$VMID" -- bash -c " else pnpm install --no-frozen-lockfile fi - # Try to build, but continue if it fails (backend may be placeholder) - pnpm run build || echo 'Build failed, continuing anyway (backend may be placeholder)' + pnpm run build || echo 'Build optional — service runs via tsx' " # Create systemd service @@ -144,7 +143,7 @@ User=root WorkingDirectory=/opt/solace-backend Environment=NODE_ENV=production EnvironmentFile=/opt/solace-backend/.env -ExecStart=/usr/bin/node /opt/solace-backend/dist/index.js +ExecStart=/usr/bin/npx tsx /opt/solace-backend/src/index.ts Restart=always RestartSec=10 StandardOutput=journal diff --git a/deployment/proxmox/deploy-dapp.sh b/deployment/proxmox/deploy-dapp.sh index 1bcb090..35623fd 100755 --- a/deployment/proxmox/deploy-dapp.sh +++ b/deployment/proxmox/deploy-dapp.sh @@ -88,8 +88,7 @@ if [[ "$DEPLOY_NGINX" == "true" ]]; then echo "==========================================" echo "Deploying Nginx..." echo "==========================================" - echo "Nginx deployment script not yet implemented" - echo "You can manually set up Nginx or use the frontend container with Nginx" + "$SCRIPT_DIR/deploy-nginx.sh" fi echo "" diff --git a/deployment/proxmox/deploy-database.sh b/deployment/proxmox/deploy-database.sh index d2956d8..e872e98 100755 --- a/deployment/proxmox/deploy-database.sh +++ b/deployment/proxmox/deploy-database.sh @@ -13,7 +13,7 @@ fi # Default values VMID="${VMID_DATABASE:-3002}" -HOSTNAME="${HOSTNAME:-solace-db}" +HOSTNAME="${CONTAINER_HOSTNAME:-solace-db}" IP_ADDRESS="${DATABASE_IP:-192.168.11.62}" MEMORY="${DATABASE_MEMORY:-4096}" CORES="${DATABASE_CORES:-2}" diff --git a/deployment/proxmox/deploy-frontend.sh b/deployment/proxmox/deploy-frontend.sh index cd8681d..781210d 100755 --- a/deployment/proxmox/deploy-frontend.sh +++ b/deployment/proxmox/deploy-frontend.sh @@ -13,7 +13,7 @@ fi # Default values VMID="${VMID_FRONTEND:-3000}" -HOSTNAME="${HOSTNAME:-solace-frontend}" +HOSTNAME="${CONTAINER_HOSTNAME:-solace-frontend}" IP_ADDRESS="${FRONTEND_IP:-192.168.11.60}" MEMORY="${FRONTEND_MEMORY:-2048}" CORES="${FRONTEND_CORES:-2}" @@ -117,7 +117,11 @@ echo "Installing dependencies and building..." pct exec "$VMID" -- bash -c " cd /opt/solace-frontend export NODE_ENV=production - pnpm install --frozen-lockfile + if [[ -f pnpm-lock.yaml ]]; then + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + else + pnpm install --no-frozen-lockfile + fi pnpm run build " @@ -135,7 +139,7 @@ User=root WorkingDirectory=/opt/solace-frontend Environment=NODE_ENV=production EnvironmentFile=/opt/solace-frontend/.env.production -ExecStart=/usr/bin/node /opt/solace-frontend/node_modules/.bin/next start +ExecStart=/usr/bin/npx next start -p 3000 Restart=always RestartSec=10 StandardOutput=journal diff --git a/deployment/proxmox/deploy-indexer.sh b/deployment/proxmox/deploy-indexer.sh index 7e074f1..908c33f 100755 --- a/deployment/proxmox/deploy-indexer.sh +++ b/deployment/proxmox/deploy-indexer.sh @@ -13,7 +13,7 @@ fi # Default values VMID="${VMID_INDEXER:-3003}" -HOSTNAME="${HOSTNAME:-solace-indexer}" +HOSTNAME="${CONTAINER_HOSTNAME:-solace-indexer}" IP_ADDRESS="${INDEXER_IP:-192.168.11.63}" MEMORY="${INDEXER_MEMORY:-2048}" CORES="${INDEXER_CORES:-2}" @@ -103,7 +103,9 @@ pct exec "$VMID" -- bash -c " # Copy indexer code to container (uses backend codebase) echo "Copying indexer code to container..." if [[ -d "$PROJECT_ROOT/backend" ]]; then - pct push "$VMID" "$PROJECT_ROOT/backend" /opt/solace-indexer + pct exec "$VMID" -- bash -c "rm -rf /opt/solace-indexer/* /opt/solace-indexer/.* 2>/dev/null || true" + cd "$PROJECT_ROOT" + tar czf - backend | pct exec "$VMID" -- bash -c "cd /opt && tar xzf - && mv backend/* solace-indexer/ && mv backend/.* solace-indexer/ 2>/dev/null || true && rmdir backend 2>/dev/null || true" else echo "WARNING: Backend directory not found at $PROJECT_ROOT/backend" echo "You will need to copy the code manually or clone the repository" @@ -114,8 +116,12 @@ echo "Installing dependencies..." pct exec "$VMID" -- bash -c " cd /opt/solace-indexer export NODE_ENV=production - pnpm install --frozen-lockfile - pnpm run build + if [[ -f pnpm-lock.yaml ]]; then + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + else + pnpm install --no-frozen-lockfile + fi + pnpm run build || echo 'Build optional — service runs via tsx' " # Create systemd service @@ -132,7 +138,7 @@ User=root WorkingDirectory=/opt/solace-indexer Environment=NODE_ENV=production EnvironmentFile=/opt/solace-indexer/.env.indexer -ExecStart=/usr/bin/node /opt/solace-indexer/dist/indexer/indexer.js +ExecStart=/usr/bin/npx tsx /opt/solace-indexer/src/indexer/indexer.ts Restart=always RestartSec=10 StandardOutput=journal diff --git a/deployment/proxmox/deploy-nginx.sh b/deployment/proxmox/deploy-nginx.sh new file mode 100755 index 0000000..62d806f --- /dev/null +++ b/deployment/proxmox/deploy-nginx.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Install Nginx reverse proxy on the Solace frontend CT (VMID 3000). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_FILE="$SCRIPT_DIR/config/dapp.conf" +[[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE" + +VMID="${VMID_FRONTEND:-3000}" +NGINX_TEMPLATE="$SCRIPT_DIR/templates/nginx.conf" +LOCATIONS_TEMPLATE="$SCRIPT_DIR/templates/solace-treasury-locations.conf" + +if ! command -v pct &>/dev/null; then + echo "ERROR: pct not found — run on Proxmox host" >&2 + exit 1 +fi + +echo "Installing Nginx on CT $VMID..." + +pct exec "$VMID" -- bash -c ' + set -euo pipefail + apt-get update -qq + apt-get install -y nginx + systemctl enable nginx +' + +pct push "$VMID" "$NGINX_TEMPLATE" /etc/nginx/sites-available/solace-treasury +pct push "$VMID" "$LOCATIONS_TEMPLATE" /etc/nginx/snippets/solace-treasury-locations.conf +pct exec "$VMID" -- bash -c ' + mkdir -p /etc/nginx/snippets + ln -sf /etc/nginx/sites-available/solace-treasury /etc/nginx/sites-enabled/solace-treasury + rm -f /etc/nginx/sites-enabled/default + mkdir -p /etc/nginx/ssl + if [[ ! -f /etc/nginx/ssl/cert.pem ]]; then + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /etc/nginx/ssl/key.pem -out /etc/nginx/ssl/cert.pem \ + -subj "/CN=solace-treasury.local" + fi + nginx -t + systemctl restart nginx +' + +echo "Nginx deployed on CT $VMID (self-signed SSL in /etc/nginx/ssl/)" diff --git a/deployment/proxmox/deploy-remote.sh b/deployment/proxmox/deploy-remote.sh index 63670a5..48aa7d6 100755 --- a/deployment/proxmox/deploy-remote.sh +++ b/deployment/proxmox/deploy-remote.sh @@ -4,7 +4,7 @@ set -euo pipefail -PROXMOX_HOST="${PROXMOX_HOST:-192.168.11.10}" +PROXMOX_HOST="${PROXMOX_HOST:-192.168.11.11}" PROXMOX_USER="${PROXMOX_USER:-root}" DEPLOYMENT_DIR="/tmp/solace-dapp-deployment" @@ -30,22 +30,33 @@ echo "Copying deployment files to Proxmox host..." ssh "$PROXMOX_USER@$PROXMOX_HOST" "mkdir -p $DEPLOYMENT_DIR" scp -r "$SCRIPT_DIR"/* "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/" -echo "Copying project files..." +echo "Copying project files (excluding node_modules and .next)..." ssh "$PROXMOX_USER@$PROXMOX_HOST" "mkdir -p $DEPLOYMENT_DIR/project" -scp -r "$PROJECT_ROOT/backend" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/" -scp -r "$PROJECT_ROOT/frontend" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/" -scp -r "$PROJECT_ROOT/contracts" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/" +rsync -az --delete \ + --exclude node_modules --exclude .next --exclude dist --exclude cache --exclude artifacts \ + "$PROJECT_ROOT/backend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/backend/" +rsync -az --delete \ + --exclude node_modules --exclude .next \ + "$PROJECT_ROOT/frontend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/frontend/" +rsync -az --delete \ + --exclude node_modules --exclude cache --exclude artifacts \ + "$PROJECT_ROOT/contracts/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/contracts/" +# Monorepo lockfile (optional; deploy scripts fall back to --no-frozen-lockfile) +scp "$PROJECT_ROOT/pnpm-lock.yaml" "$PROJECT_ROOT/package.json" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/" 2>/dev/null || true echo "Setting up configuration..." ssh "$PROXMOX_USER@$PROXMOX_HOST" "cat > $DEPLOYMENT_DIR/config/dapp.conf < + + + ); +} + +function ActivityContent() { + const { treasuryId, loading: treasuryLoading } = useTreasury(); + const [filter, setFilter] = useState("all"); + const [proposals, setProposals] = useState([]); + const [ledger, setLedger] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const treasuryWallet = CONTRACT_ADDRESSES.TreasuryWallet; + if (!treasuryId && !treasuryWallet) { + setLoading(false); + return; + } + + setLoading(true); + const proposalPromise = treasuryId + ? fetchTransactions(treasuryId) + : Promise.resolve([] as TransactionRecord[]); + const ledgerPromise = treasuryId + ? fetchLedger(treasuryId) + : treasuryWallet + ? fetchLedgerByWallet(treasuryWallet) + : Promise.resolve([] as LedgerEntry[]); + + Promise.allSettled([proposalPromise, ledgerPromise]) + .then(([proposalResult, ledgerResult]) => { + setProposals(proposalResult.status === "fulfilled" ? proposalResult.value : []); + setLedger(ledgerResult.status === "fulfilled" ? ledgerResult.value : []); + }) + .finally(() => setLoading(false)); + }, [treasuryId]); + + const deposits = useMemo( + () => ledger.filter((entry) => entry.kind === "deposit"), + [ledger] + ); + + const filteredProposals = proposals.filter((tx) => { + if (filter === "pending" || filter === "executed") return tx.status === filter; + return true; + }); + + const showDeposits = filter === "all" || filter === "deposits"; + const showProposals = filter !== "deposits"; + + const isEmpty = + (showDeposits ? deposits.length === 0 : true) && + (showProposals ? filteredProposals.length === 0 : true); + + const handleExport = async () => { + if (!treasuryId) return; + const csv = await exportTransactionsCsv(treasuryId); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `transactions-${Date.now()}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ + Export CSV + + } + /> + +
+
+ {(["all", "deposits", "pending", "executed"] as const).map((status) => ( + + ))} +
+ + {treasuryLoading || loading ? ( +
Loading transactions...
+ ) : isEmpty ? ( +
No transactions found
+ ) : ( +
+ {showDeposits && + deposits.map((entry) => ( + + ))} + {showProposals && + filteredProposals.map((tx) => ( + + ))} +
+ )} +
+
+ ); +} + +function LedgerRow({ entry }: { entry: LedgerEntry }) { + const token = getTokenByAddress(entry.token); + const symbol = entry.tokenSymbol ?? token?.symbol ?? "ETH"; + const amountLabel = formatTokenAmount( + entry.value, + token ?? { symbol, decimals: symbol === "ETH" ? 18 : 6, name: symbol, address: "native" } + ); + + return ( +
+
+
+

Type

+ + {entry.kind === "deposit" ? "Deposit" : "Transfer out"} + +
+
+

From

+

{formatAddress(entry.from)}

+
+
+

Amount

+

{amountLabel}

+
+
+

Status

+ + confirmed + +
+
+
+

+ {format(new Date(entry.timestamp), "MMM dd, yyyy HH:mm:ss")} · block{" "} + {entry.blockNumber} +

+ + View on explorer + +
+
+ ); +} + +function ProposalRow({ tx }: { tx: TransactionRecord }) { + return ( +
+
+
+

Proposal ID

+

#{tx.proposalId}

+
+
+

To

+

{formatAddress(tx.to)}

+
+
+

Amount

+

+ {formatTokenAmount(tx.value, getTokenByAddress(tx.token))} +

+
+
+

Status

+ + {tx.status} + +
+
+
+

+ Created: {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm:ss")} +

+ {decodeMemoData(tx.data) && ( +

Memo: {decodeMemoData(tx.data)}

+ )} +
+
+ ); +} diff --git a/frontend/app/approvals/page.tsx b/frontend/app/(dashboard)/approvals/page.tsx similarity index 50% rename from frontend/app/approvals/page.tsx rename to frontend/app/(dashboard)/approvals/page.tsx index 8c41d30..9488e52 100644 --- a/frontend/app/approvals/page.tsx +++ b/frontend/app/(dashboard)/approvals/page.tsx @@ -1,36 +1,50 @@ "use client"; -import { useState } from "react"; -import { useAccount, useWriteContract } from "wagmi"; -import { WalletConnect } from "@/components/web3/WalletConnect"; +import { useEffect, useState } from "react"; +import { useWriteContract, useReadContract } from "wagmi"; +import { WalletGate } from "@/components/web3/WalletGate"; +import { PageHeader } from "@/components/layout/PageHeader"; import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; import { formatAddress } from "@/lib/utils"; -import { formatEther } from "viem"; - -interface Proposal { - id: number; - to: string; - value: bigint; - approvalCount: number; - threshold: number; -} +import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens"; +import { useTreasury } from "@/lib/hooks/useTreasury"; +import { fetchPendingProposals, type TransactionRecord } from "@/lib/api/client"; export default function ApprovalsPage() { - const { isConnected } = useAccount(); + return ( + + + + ); +} + +function ApprovalsContent() { + const { treasuryId, loading: treasuryLoading } = useTreasury(); const { writeContract } = useWriteContract(); + const [proposals, setProposals] = useState([]); + const [loading, setLoading] = useState(true); - // TODO: Fetch pending proposals from contract/backend - const [proposals] = useState([]); + const { data: threshold } = useReadContract({ + address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, + abi: TREASURY_WALLET_ABI, + functionName: "threshold", + }); - if (!isConnected) { - return ( -
-
- -
-
- ); - } + useEffect(() => { + if (!treasuryId) { + setLoading(false); + return; + } + + setLoading(true); + fetchPendingProposals(treasuryId) + .then(setProposals) + .catch(() => setProposals([])) + .finally(() => setLoading(false)); + }, [treasuryId]); const handleApprove = async (proposalId: number) => { if (!CONTRACT_ADDRESSES.TreasuryWallet) return; @@ -54,30 +68,32 @@ export default function ApprovalsPage() { }); }; - return ( -
-
-
-

Pending Approvals

- -
+ const requiredThreshold = threshold ? Number(threshold) : 1; -
- {proposals.length === 0 ? ( -
- No pending transactions requiring approval -
- ) : ( -
- {proposals.map((proposal) => ( + return ( +
+ + +
+ {treasuryLoading || loading ? ( +
Loading proposals...
+ ) : proposals.length === 0 ? ( +
+ No pending transactions requiring approval +
+ ) : ( +
+ {proposals.map((proposal) => { + const approvalCount = proposal.approvalCount ?? 0; + return (
-
+

- Proposal #{proposal.id} + Proposal #{proposal.proposalId}

@@ -86,25 +102,27 @@ export default function ApprovalsPage() {
Amount:{" "} - {formatEther(proposal.value)} ETH + {formatTokenAmount(proposal.value, getTokenByAddress(proposal.token))}
Approvals:{" "} - {proposal.approvalCount} / {proposal.threshold} + {approvalCount} / {requiredThreshold}
- {proposal.approvalCount >= proposal.threshold ? ( + {approvalCount >= requiredThreshold ? ( ) : (
- ))} -
- )} -
+ ); + })} +
+ )}
); } - diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..a6175ac --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,9 @@ +import { AppShell } from "@/components/layout/AppShell"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/frontend/app/(dashboard)/page.tsx b/frontend/app/(dashboard)/page.tsx new file mode 100644 index 0000000..ae1993e --- /dev/null +++ b/frontend/app/(dashboard)/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { Dashboard } from "@/components/dashboard/Dashboard"; + +export default function Home() { + return ; +} diff --git a/frontend/app/(dashboard)/receive/page.tsx b/frontend/app/(dashboard)/receive/page.tsx new file mode 100644 index 0000000..9591fd4 --- /dev/null +++ b/frontend/app/(dashboard)/receive/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { QRCodeSVG } from "qrcode.react"; +import { WalletGate } from "@/components/web3/WalletGate"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { formatAddress } from "@/lib/utils"; + +export default function ReceivePage() { + const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet; + + return ( + + + + ); +} + +function ReceiveContent({ address }: { address: string }) { + if (!address) { + return ( +
+ Treasury wallet address not configured. +
+ ); + } + + return ( +
+ + +
+
+

Treasury Deposit Address

+
+ {address} + +
+

{formatAddress(address)} on Chain 138

+
+ +
+
+ +
+
+ +
+

Network Warning

+

+ Send funds to this address on Solace Chain 138 (Chain ID: 138). + Sending on the wrong network may result in permanent loss. +

+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/send/page.tsx b/frontend/app/(dashboard)/send/page.tsx new file mode 100644 index 0000000..920d78b --- /dev/null +++ b/frontend/app/(dashboard)/send/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { useBalance, useWriteContract, useReadContract } from "wagmi"; +import { parseUnits, isAddress, zeroAddress } from "viem"; +import { WalletGate } from "@/components/web3/WalletGate"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { encodeMemoData } from "@/lib/memo"; +import { CHAIN138_TOKENS, ERC20_BALANCE_ABI, formatTokenAmount } from "@/lib/tokens"; + +export default function SendPage() { + return ( + + + + ); +} + +function SendForm() { + const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}` | undefined; + const [selectedToken, setSelectedToken] = useState(CHAIN138_TOKENS[0]); + const { writeContract } = useWriteContract(); + + const isNative = selectedToken.address === "native"; + + const { data: nativeBalance } = useBalance({ + address: treasuryAddress, + query: { enabled: Boolean(treasuryAddress && isNative) }, + }); + + const { data: erc20Balance } = useReadContract({ + address: isNative ? undefined : (selectedToken.address as `0x${string}`), + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: treasuryAddress ? [treasuryAddress] : undefined, + query: { enabled: Boolean(treasuryAddress && !isNative) }, + }); + + const [recipient, setRecipient] = useState(""); + const [amount, setAmount] = useState(""); + const [memo, setMemo] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const balanceLabel = isNative + ? nativeBalance + ? `${nativeBalance.formatted} ${nativeBalance.symbol}` + : null + : erc20Balance !== undefined + ? formatTokenAmount(erc20Balance.toString(), selectedToken) + : null; + + const handleSend = async () => { + setError(""); + setLoading(true); + + try { + if (!isAddress(recipient)) { + throw new Error("Invalid recipient address"); + } + + if (!amount || parseFloat(amount) <= 0) { + throw new Error("Invalid amount"); + } + + if (!CONTRACT_ADDRESSES.TreasuryWallet) { + throw new Error("Treasury wallet not configured"); + } + + const value = parseUnits(amount, selectedToken.decimals); + const data = encodeMemoData(memo); + const tokenArg = isNative ? zeroAddress : (selectedToken.address as `0x${string}`); + + await writeContract({ + address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, + abi: TREASURY_WALLET_ABI, + functionName: "proposeTokenTransfer", + args: [recipient as `0x${string}`, tokenArg, value, data], + }); + } catch (err: unknown) { + const sendError = err instanceof Error ? err : new Error(String(err)); + setError(sendError.message || "Transaction failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+
+ + +
+ +
+ + setRecipient(e.target.value)} + placeholder="0x..." + className="w-full bg-gray-800 rounded-lg p-3 font-mono text-sm" + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 rounded-lg p-3" + /> + {balanceLabel && ( +

Treasury balance: {balanceLabel}

+ )} +
+ +
+ + setMemo(e.target.value)} + placeholder="Payment reference..." + className="w-full bg-gray-800 rounded-lg p-3" + /> +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ); +} diff --git a/frontend/app/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx similarity index 89% rename from frontend/app/settings/page.tsx rename to frontend/app/(dashboard)/settings/page.tsx index cc9dab8..c1f206a 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -1,20 +1,31 @@ "use client"; import { useState } from "react"; -import { useAccount, useWriteContract, useReadContract } from "wagmi"; -import { WalletConnect } from "@/components/web3/WalletConnect"; +import { useWriteContract, useReadContract } from "wagmi"; +import { WalletGate } from "@/components/web3/WalletGate"; +import { PageHeader } from "@/components/layout/PageHeader"; import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; import { formatAddress, isAddress } from "@/lib/utils"; import { getAddress } from "viem"; +import { SubAccountsSettings } from "@/components/settings/SubAccountsSettings"; export default function SettingsPage() { - const { isConnected } = useAccount(); + return ( + + + + ); +} + +function SettingsContent() { const { writeContract } = useWriteContract(); const [newSigner, setNewSigner] = useState(""); const [signerToRemove, setSignerToRemove] = useState(""); const [newThreshold, setNewThreshold] = useState(""); - // TODO: Fetch owners and threshold from contract const { data: owners } = useReadContract({ address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, abi: TREASURY_WALLET_ABI, @@ -27,15 +38,6 @@ export default function SettingsPage() { functionName: "threshold", }); - if (!isConnected) { - return ( -
-
- -
-
- ); - } const handleAddSigner = async () => { if (!isAddress(newSigner) || !CONTRACT_ADDRESSES.TreasuryWallet) return; @@ -78,14 +80,10 @@ export default function SettingsPage() { }; return ( -
-
-
-

Treasury Settings

- -
+
+ -
+
{/* Current Configuration */}

Current Configuration

@@ -163,6 +161,9 @@ export default function SettingsPage() {
+ {/* Sub-Accounts */} + + {/* Change Threshold */}

Change Threshold

@@ -187,7 +188,6 @@ export default function SettingsPage() {
-
); } diff --git a/frontend/app/(dashboard)/transfer/page.tsx b/frontend/app/(dashboard)/transfer/page.tsx new file mode 100644 index 0000000..7f42e52 --- /dev/null +++ b/frontend/app/(dashboard)/transfer/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAccount, useBalance, useWriteContract } from "wagmi"; +import { parseEther } from "viem"; +import { WalletGate } from "@/components/web3/WalletGate"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { useTreasury } from "@/lib/hooks/useTreasury"; +import { fetchSubAccounts } from "@/lib/api/client"; + +export default function TransferPage() { + return ( + + + + ); +} + +function TransferForm() { + const { address } = useAccount(); + const { treasuryId } = useTreasury(); + const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}` | undefined; + const { data: balance } = useBalance({ + address: treasuryAddress, + query: { enabled: Boolean(treasuryAddress) }, + }); + const { writeContract } = useWriteContract(); + + const [fromAccount, setFromAccount] = useState("main"); + const [toAccount, setToAccount] = useState(""); + const [amount, setAmount] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [subAccounts, setSubAccounts] = useState([]); + + useEffect(() => { + if (!treasuryId) return; + fetchSubAccounts(treasuryId) + .then((accounts) => setSubAccounts(accounts.map((a) => a.address))) + .catch(() => setSubAccounts([])); + }, [treasuryId]); + + const handleTransfer = async () => { + setError(""); + setLoading(true); + + try { + if (!toAccount) { + throw new Error("Please select destination account"); + } + + if (!amount || parseFloat(amount) <= 0) { + throw new Error("Invalid amount"); + } + + if (!CONTRACT_ADDRESSES.TreasuryWallet) { + throw new Error("Treasury wallet not configured"); + } + + const value = parseEther(amount); + const recipient = toAccount; + + await writeContract({ + address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, + abi: TREASURY_WALLET_ABI, + functionName: "proposeTransaction", + args: [recipient as `0x${string}`, value, "0x"], + }); + } catch (err: unknown) { + const transferError = err instanceof Error ? err : new Error(String(err)); + setError(transferError.message || "Transfer failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + setAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 rounded-lg p-3" + /> + {balance && ( +

+ Treasury balance: {balance.formatted} {balance.symbol} +

+ )} +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ); +} diff --git a/frontend/app/activity/page.tsx b/frontend/app/activity/page.tsx deleted file mode 100644 index 16ee4bd..0000000 --- a/frontend/app/activity/page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useAccount } from "wagmi"; -import { WalletConnect } from "@/components/web3/WalletConnect"; -import { formatAddress } from "@/lib/utils"; -import { format } from "date-fns"; -import { formatEther } from "viem"; - -interface Transaction { - id: string; - proposalId: number; - to: string; - value: string; - status: "pending" | "executed" | "rejected"; - createdAt: Date | string; -} - -export default function ActivityPage() { - const { isConnected } = useAccount(); - const [filter, setFilter] = useState<"all" | "pending" | "executed">("all"); - - // TODO: Fetch transactions from backend - const transactions: Transaction[] = []; - - if (!isConnected) { - return ( -
-
- -
-
- ); - } - - const filteredTransactions = transactions.filter((tx) => { - if (filter === "all") return true; - return tx.status === filter; - }); - - const handleExport = async () => { - // TODO: Fetch CSV from backend API - const csv = ""; // Placeholder - const blob = new Blob([csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `transactions-${Date.now()}.csv`; - a.click(); - URL.revokeObjectURL(url); - }; - - return ( -
-
-
-

Transaction History

-
- - -
-
- -
- {/* Filters */} -
- {(["all", "pending", "executed"] as const).map((status) => ( - - ))} -
- - {/* Transaction List */} - {filteredTransactions.length === 0 ? ( -
- No transactions found -
- ) : ( -
- {filteredTransactions.map((tx) => ( -
-
-
-
Proposal ID
-
#{tx.proposalId}
-
-
-
To
-
{formatAddress(tx.to)}
-
-
-
Amount
-
{formatEther(BigInt(tx.value))} ETH
-
-
-
Status
- - {tx.status} - -
-
-
-
- Created: {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm:ss")} -
-
-
- ))} -
- )} -
-
-
- ); -} - diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a879392..2e8c021 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -26,6 +26,15 @@ body { } } +@keyframes particle-drift { + from { + transform: translate3d(0, 0, 0) scale(1); + } + to { + transform: translate3d(-2%, -1%, 0) scale(1.02); + } +} + /* Smooth scrolling */ html { scroll-behavior: smooth; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 81337bb..24ced13 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,14 +1,30 @@ import type { Metadata } from "next"; +import dynamic from "next/dynamic"; import { Inter } from "next/font/google"; import "./globals.css"; -import { Providers } from "./providers"; -import { ParticleBackground } from "@/components/ui/ParticleBackground"; + +const Providers = dynamic( + () => import("./providers").then((mod) => mod.Providers), + { ssr: false } +); + +const ParticleBackground = dynamic( + () => + import("@/components/ui/ParticleBackground").then( + (mod) => mod.ParticleBackground + ), + { ssr: false } +); const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: "Solace Treasury Management", description: "Treasury Management DApp for Solace Bank Group", + icons: { + icon: [{ url: "/icon.svg", type: "image/svg+xml" }], + shortcut: "/icon.svg", + }, }; export default function RootLayout({ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx deleted file mode 100644 index f6c67eb..0000000 --- a/frontend/app/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { WalletConnect } from "@/components/web3/WalletConnect"; -import { Dashboard } from "@/components/dashboard/Dashboard"; -import { Navigation } from "@/components/layout/Navigation"; - -export default function Home() { - return ( -
-
-
-

Solace Treasury Management

-
- -
-
-
- -
-
- -
-
-
- ); -} - diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index eb826d6..458e199 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -2,11 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider } from "wagmi"; -import { config } from "@/lib/web3/config"; +import { getConfig } from "@/lib/web3/config"; import { useState } from "react"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); + const [config] = useState(() => getConfig()); return ( diff --git a/frontend/app/receive/page.tsx b/frontend/app/receive/page.tsx deleted file mode 100644 index 7daa858..0000000 --- a/frontend/app/receive/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useAccount, useChainId } from "wagmi"; -import { QRCodeSVG } from "qrcode.react"; -import { WalletConnect } from "@/components/web3/WalletConnect"; - -export default function ReceivePage() { - const { address, isConnected } = useAccount(); - const chainId = useChainId(); - - if (!isConnected || !address) { - return ( -
-
- -
-
- ); - } - - const networkName = - chainId === 1 - ? "Ethereum Mainnet" - : chainId === 11155111 - ? "Sepolia Testnet" - : chainId === 138 - ? "Solace Chain 138" - : "Unknown Network"; - - return ( -
-
-
-

Receive Funds

- -
- -
-
-

Deposit Address

-
- {address} - -
-
- -
-
- -
-
- -
-

Network Warning

-

- Make sure you are sending funds on {networkName} (Chain ID: {chainId}). - Sending funds on the wrong network may result in permanent loss. -

-
-
-
-
- ); -} - diff --git a/frontend/app/send/page.tsx b/frontend/app/send/page.tsx deleted file mode 100644 index b358bb9..0000000 --- a/frontend/app/send/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useAccount, useBalance, useWriteContract } from "wagmi"; -import { parseEther, isAddress } from "viem"; -import { WalletConnect } from "@/components/web3/WalletConnect"; -import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; - -export default function SendPage() { - const { address, isConnected } = useAccount(); - const { data: balance } = useBalance({ address }); - const { writeContract } = useWriteContract(); - - const [recipient, setRecipient] = useState(""); - const [amount, setAmount] = useState(""); - const [memo, setMemo] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - if (!isConnected) { - return ( -
-
- -
-
- ); - } - - const handleSend = async () => { - setError(""); - setLoading(true); - - try { - if (!isAddress(recipient)) { - throw new Error("Invalid recipient address"); - } - - if (!amount || parseFloat(amount) <= 0) { - throw new Error("Invalid amount"); - } - - if (!CONTRACT_ADDRESSES.TreasuryWallet) { - throw new Error("Treasury wallet not configured"); - } - - const value = parseEther(amount); - - // Propose transaction on treasury wallet - await writeContract({ - address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, - abi: TREASURY_WALLET_ABI, - functionName: "proposeTransaction", - args: [recipient as `0x${string}`, value, "0x"], - }); - } catch (err: unknown) { - const error = err instanceof Error ? err : new Error(String(err)); - setError(error.message || "Transaction failed"); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
-

Send Payment

- -
- -
-
- - setRecipient(e.target.value)} - placeholder="0x..." - className="w-full bg-gray-800 rounded-lg p-3 font-mono text-sm" - /> -
- -
- - setAmount(e.target.value)} - placeholder="0.0" - className="w-full bg-gray-800 rounded-lg p-3" - /> - {balance && ( -

- Available: {balance.formatted} {balance.symbol} -

- )} -
- -
- - setMemo(e.target.value)} - placeholder="Payment reference..." - className="w-full bg-gray-800 rounded-lg p-3" - /> -
- - {error && ( -
-

{error}

-
- )} - - -
-
-
- ); -} - diff --git a/frontend/app/transfer/page.tsx b/frontend/app/transfer/page.tsx deleted file mode 100644 index e2df0d6..0000000 --- a/frontend/app/transfer/page.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useAccount, useBalance, useWriteContract } from "wagmi"; -import { parseEther } from "viem"; -import { WalletConnect } from "@/components/web3/WalletConnect"; -import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; - -export default function TransferPage() { - const { address, isConnected } = useAccount(); - const { data: balance } = useBalance({ address }); - const { writeContract } = useWriteContract(); - - const [fromAccount, setFromAccount] = useState("main"); - const [toAccount, setToAccount] = useState(""); - const [amount, setAmount] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - // TODO: Fetch sub-accounts from backend/contract - const subAccounts: string[] = []; - - if (!isConnected) { - return ( -
-
- -
-
- ); - } - - const handleTransfer = async () => { - setError(""); - setLoading(true); - - try { - if (!toAccount) { - throw new Error("Please select destination account"); - } - - if (!amount || parseFloat(amount) <= 0) { - throw new Error("Invalid amount"); - } - - if (!CONTRACT_ADDRESSES.TreasuryWallet) { - throw new Error("Treasury wallet not configured"); - } - - const value = parseEther(amount); - const recipient = fromAccount === "main" ? toAccount : toAccount; - - await writeContract({ - address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, - abi: TREASURY_WALLET_ABI, - functionName: "proposeTransaction", - args: [recipient as `0x${string}`, value, "0x"], - }); - } catch (err: unknown) { - const error = err instanceof Error ? err : new Error(String(err)); - setError(error.message || "Transfer failed"); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
-

Internal Transfer

- -
- -
-
- - -
- -
- - -
- -
- - setAmount(e.target.value)} - placeholder="0.0" - className="w-full bg-gray-800 rounded-lg p-3" - /> - {balance && ( -

- Available: {balance.formatted} {balance.symbol} -

- )} -
- - {error && ( -
-

{error}

-
- )} - - -
-
-
- ); -} - diff --git a/frontend/components/dashboard/BalanceDisplay.tsx b/frontend/components/dashboard/BalanceDisplay.tsx index fc28c29..33afef4 100644 --- a/frontend/components/dashboard/BalanceDisplay.tsx +++ b/frontend/components/dashboard/BalanceDisplay.tsx @@ -1,19 +1,31 @@ "use client"; import { useEffect, useRef } from "react"; -import { useAccount, useBalance } from "wagmi"; -import { Canvas } from "@react-three/fiber"; -import { OrbitControls } from "@react-three/drei"; import { gsap } from "gsap"; -import { formatBalance } from "@/lib/utils"; +import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { CHAIN138_TOKENS, formatTokenAmount } from "@/lib/tokens"; +import { useTokenBalances } from "@/lib/hooks/useTokenBalances"; +import { formatUsd, tokenUsdValue, useUsdPrices } from "@/lib/prices"; +import { formatAddress } from "@/lib/utils"; +import { CHAIN138_ID } from "@/lib/constants/chain138"; export function BalanceDisplay() { - const { address } = useAccount(); - const { data: balance, isLoading } = useBalance({ address }); + const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}` | undefined; const displayRef = useRef(null); + const { ethUsd, isLoading: pricesLoading } = useUsdPrices(); + const { + nativeBalance, + cusdtRaw, + cusdcRaw, + erc20Tokens, + isLoading: balancesLoading, + } = useTokenBalances(treasuryAddress); + + const loading = balancesLoading || pricesLoading; + useEffect(() => { - if (displayRef.current && balance) { + if (displayRef.current && nativeBalance) { gsap.from(displayRef.current, { opacity: 0, y: 20, @@ -21,55 +33,94 @@ export function BalanceDisplay() { ease: "power3.out", }); } - }, [balance]); + }, [nativeBalance]); - if (isLoading) { + if (loading && !nativeBalance && cusdtRaw === undefined) { return (
-
Loading balance...
+

Loading treasury balance…

); } - const balanceValue = balance?.value || BigInt(0); - const formattedBalance = formatBalance(balanceValue); + if (!treasuryAddress) { + return ( +
+

Treasury wallet not configured

+
+ ); + } + + const ethRaw = nativeBalance?.value; + const ethUsdVal = tokenUsdValue(ethRaw, 18, "ETH", ethUsd); + const cusdtUsd = tokenUsdValue(cusdtRaw, erc20Tokens[0].decimals, "cUSDT", ethUsd); + const cusdcUsd = tokenUsdValue(cusdcRaw, erc20Tokens[1].decimals, "cUSDC", ethUsd); + const totalUsd = + [ethUsdVal, cusdtUsd, cusdcUsd].reduce((sum, v) => { + if (v == null) return sum; + return (sum ?? 0) + v; + }, null) ?? 0; + + const formattedNative = nativeBalance?.formatted ?? "0"; return (
-

- Total Balance -

-
-
-
- {formattedBalance} -
-
{balance?.symbol || "ETH"}
+
+
+

+ Treasury Balance +

+

+ Multisig · {formatAddress(treasuryAddress)} · Chain {CHAIN138_ID} +

-
- - - - - - - - - - +
+

Estimated total

+

{formatUsd(totalUsd)}

+
+
+
+
+
+
+

+ {formattedNative} +

+

{nativeBalance?.symbol ?? "ETH"}

+
+

{formatUsd(ethUsdVal)}

+
+ {cusdtRaw !== undefined && ( +
+ + {formatTokenAmount(cusdtRaw.toString(), erc20Tokens[0])} + + {formatUsd(cusdtUsd)} +
+ )} + {cusdcRaw !== undefined && ( +
+ + {formatTokenAmount(cusdcRaw.toString(), erc20Tokens[1])} + + {formatUsd(cusdcUsd)} +
+ )} +
+
+
+
+
+
); } - diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx index da355e4..a753810 100644 --- a/frontend/components/dashboard/Dashboard.tsx +++ b/frontend/components/dashboard/Dashboard.tsx @@ -11,9 +11,11 @@ export function Dashboard() { if (!isConnected) { return ( -
-

Please connect your wallet to continue

-

+

+

+ Please connect your wallet to continue +

+

Connect your Web3 wallet to access the treasury management dashboard

diff --git a/frontend/components/dashboard/PendingApprovals.tsx b/frontend/components/dashboard/PendingApprovals.tsx index 7d314c3..1cc0d35 100644 --- a/frontend/components/dashboard/PendingApprovals.tsx +++ b/frontend/components/dashboard/PendingApprovals.tsx @@ -1,11 +1,21 @@ "use client"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import { useTreasury } from "@/lib/hooks/useTreasury"; +import { fetchPendingProposals } from "@/lib/api/client"; export function PendingApprovals() { const router = useRouter(); - // TODO: Fetch pending approvals from contract/backend - const pendingCount = 0; // Placeholder + const { treasuryId } = useTreasury(); + const [pendingCount, setPendingCount] = useState(0); + + useEffect(() => { + if (!treasuryId) return; + fetchPendingProposals(treasuryId) + .then((rows) => setPendingCount(rows.length)) + .catch(() => setPendingCount(0)); + }, [treasuryId]); if (pendingCount === 0) { return null; @@ -30,4 +40,3 @@ export function PendingApprovals() {
); } - diff --git a/frontend/components/dashboard/RecentActivity.tsx b/frontend/components/dashboard/RecentActivity.tsx index 6999e58..83c4b15 100644 --- a/frontend/components/dashboard/RecentActivity.tsx +++ b/frontend/components/dashboard/RecentActivity.tsx @@ -1,28 +1,70 @@ "use client"; -import { useEffect, useRef, useMemo } from "react"; +import { useEffect, useRef, useState } from "react"; import { formatAddress } from "@/lib/utils"; import { format } from "date-fns"; +import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens"; import { gsap } from "gsap"; +import { useTreasury } from "@/lib/hooks/useTreasury"; +import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { + fetchLedger, + fetchLedgerByWallet, + fetchTransactions, + type LedgerEntry, + type TransactionRecord, +} from "@/lib/api/client"; -interface Transaction { - id: string; - proposalId: number; - to: string; - value: string; - status: "pending" | "executed" | "rejected"; - createdAt: Date; -} +type ActivityItem = + | { kind: "ledger"; entry: LedgerEntry; at: number } + | { kind: "proposal"; tx: TransactionRecord; at: number }; export function RecentActivity() { - // TODO: Fetch recent transactions from backend/indexer - const transactions = useMemo(() => [], []); // Placeholder + const { treasuryId } = useTreasury(); + const [items, setItems] = useState([]); const containerRef = useRef(null); useEffect(() => { - if (containerRef.current && transactions.length > 0) { - const items = containerRef.current.children; - gsap.from(items, { + const treasuryWallet = CONTRACT_ADDRESSES.TreasuryWallet; + if (!treasuryId && !treasuryWallet) return; + + const proposalPromise = treasuryId + ? fetchTransactions(treasuryId) + : Promise.resolve([] as TransactionRecord[]); + const ledgerPromise = treasuryId + ? fetchLedger(treasuryId) + : treasuryWallet + ? fetchLedgerByWallet(treasuryWallet) + : Promise.resolve([] as LedgerEntry[]); + + Promise.allSettled([proposalPromise, ledgerPromise]).then( + ([proposalResult, ledgerResult]) => { + const proposals = + proposalResult.status === "fulfilled" ? proposalResult.value : []; + const ledger = + ledgerResult.status === "fulfilled" ? ledgerResult.value : []; + const merged: ActivityItem[] = [ + ...ledger.map((entry) => ({ + kind: "ledger" as const, + entry, + at: new Date(entry.timestamp).getTime(), + })), + ...proposals.map((tx) => ({ + kind: "proposal" as const, + tx, + at: new Date(tx.createdAt).getTime(), + })), + ]; + merged.sort((a, b) => b.at - a.at); + setItems(merged.slice(0, 5)); + } + ); + }, [treasuryId]); + + useEffect(() => { + if (containerRef.current && items.length > 0) { + const children = containerRef.current.children; + gsap.from(children, { opacity: 0, x: -20, duration: 0.5, @@ -30,57 +72,100 @@ export function RecentActivity() { ease: "power2.out", }); } - }, [transactions]); + }, [items]); return (

Recent Activity

- {transactions.length === 0 ? ( -
- No recent transactions -
+ {items.length === 0 ? ( +
No recent transactions
) : (
- {transactions.map((tx) => ( -
-
-
-
- - #{tx.proposalId} - - - {tx.status} - -
-
- To: {formatAddress(tx.to)} -
-
- {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm")} -
-
-
-
{tx.value} ETH
-
-
-
- ))} + {items.map((item) => + item.kind === "ledger" ? ( + + ) : ( + + ) + )}
)}
); } + +function LedgerActivityRow({ entry }: { entry: LedgerEntry }) { + const token = getTokenByAddress(entry.token); + const symbol = entry.tokenSymbol ?? token?.symbol ?? "ETH"; + return ( +
+
+
+
+ Deposit + + confirmed + +
+
+ From: {formatAddress(entry.from)} +
+
+ {format(new Date(entry.timestamp), "MMM dd, yyyy HH:mm")} +
+
+
+
+ {formatTokenAmount( + entry.value, + token ?? { + symbol, + decimals: symbol === "ETH" ? 18 : 6, + name: symbol, + address: "native", + } + )} +
+
+
+
+ ); +} + +function ProposalActivityRow({ tx }: { tx: TransactionRecord }) { + return ( +
+
+
+
+ #{tx.proposalId} + + {tx.status} + +
+
+ To: {formatAddress(tx.to)} +
+
+ {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm")} +
+
+
+
+ {formatTokenAmount(tx.value, getTokenByAddress(tx.token))} +
+
+
+
+ ); +} diff --git a/frontend/components/layout/AppShell.tsx b/frontend/components/layout/AppShell.tsx new file mode 100644 index 0000000..c744ea9 --- /dev/null +++ b/frontend/components/layout/AppShell.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { WalletConnect } from "@/components/web3/WalletConnect"; +import { Navigation } from "@/components/layout/Navigation"; + +export function AppShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+

Solace Treasury Management

+ +
+
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/components/layout/Navigation.tsx b/frontend/components/layout/Navigation.tsx index dedd8ff..eedb974 100644 --- a/frontend/components/layout/Navigation.tsx +++ b/frontend/components/layout/Navigation.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -14,33 +15,91 @@ const navItems = [ { label: "Settings", href: "/settings" }, ]; +function NavLink({ + href, + label, + isActive, + onNavigate, +}: { + href: string; + label: string; + isActive: boolean; + onNavigate?: () => void; +}) { + return ( + + {label} + + ); +} + export function Navigation() { const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); + + const closeMobile = () => setMobileOpen(false); return ( - ); } - diff --git a/frontend/components/layout/PageHeader.tsx b/frontend/components/layout/PageHeader.tsx new file mode 100644 index 0000000..56dc41e --- /dev/null +++ b/frontend/components/layout/PageHeader.tsx @@ -0,0 +1,13 @@ +interface PageHeaderProps { + title: string; + actions?: React.ReactNode; +} + +export function PageHeader({ title, actions }: PageHeaderProps) { + return ( +
+

{title}

+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/frontend/components/settings/SubAccountsSettings.tsx b/frontend/components/settings/SubAccountsSettings.tsx new file mode 100644 index 0000000..a9e7bf3 --- /dev/null +++ b/frontend/components/settings/SubAccountsSettings.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useWriteContract, useReadContract } from "wagmi"; +import { keccak256, toBytes } from "viem"; +import { + CONTRACT_ADDRESSES, + SUB_ACCOUNT_FACTORY_ABI, +} from "@/lib/web3/contracts"; +import { formatAddress } from "@/lib/utils"; +import { useTreasury } from "@/lib/hooks/useTreasury"; +import { fetchSubAccounts, type SubAccountRecord } from "@/lib/api/client"; + +export function SubAccountsSettings() { + const { treasuryId } = useTreasury(); + const [label, setLabel] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [subAccounts, setSubAccounts] = useState([]); + const { writeContractAsync } = useWriteContract(); + + const { data: onChainAccounts } = useReadContract({ + address: CONTRACT_ADDRESSES.SubAccountFactory as `0x${string}`, + abi: SUB_ACCOUNT_FACTORY_ABI, + functionName: "getSubAccounts", + args: [CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`], + query: { enabled: Boolean(CONTRACT_ADDRESSES.SubAccountFactory && CONTRACT_ADDRESSES.TreasuryWallet) }, + }); + + useEffect(() => { + if (!treasuryId) return; + fetchSubAccounts(treasuryId) + .then(setSubAccounts) + .catch(() => setSubAccounts([])); + }, [treasuryId]); + + const handleCreate = async () => { + setError(""); + setLoading(true); + try { + if (!CONTRACT_ADDRESSES.SubAccountFactory || !CONTRACT_ADDRESSES.TreasuryWallet) { + throw new Error("Factory or treasury not configured"); + } + if (!treasuryId) { + throw new Error("Treasury not loaded from backend"); + } + + const metadataHash = keccak256(toBytes(label.trim() || `sub-${Date.now()}`)); + await writeContractAsync({ + address: CONTRACT_ADDRESSES.SubAccountFactory as `0x${string}`, + abi: SUB_ACCOUNT_FACTORY_ABI, + functionName: "createSubAccount", + args: [CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`, metadataHash], + }); + + setLabel(""); + const refreshed = await fetchSubAccounts(treasuryId); + setSubAccounts(refreshed); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to create sub-account"); + } finally { + setLoading(false); + } + }; + + const chainList = (onChainAccounts as `0x${string}`[] | undefined) ?? []; + + return ( +
+

Sub-Accounts

+

+ Create child treasury wallets that inherit the main multisig configuration. +

+ +
+ setLabel(e.target.value)} + placeholder="Label (optional)" + className="flex-1 bg-gray-800 rounded-lg p-3" + /> + +
+ + {error && ( +
+

{error}

+
+ )} + +
+

Registered accounts

+ {subAccounts.length === 0 && chainList.length === 0 ? ( +

No sub-accounts yet

+ ) : ( + [...new Set([ + ...subAccounts.map((a) => a.address), + ...chainList.map((a) => a.toLowerCase()), + ])].map((address) => ( +
+ {formatAddress(address)} + {subAccounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label && ( + + {subAccounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label} + + )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/components/ui/ParticleBackground.tsx b/frontend/components/ui/ParticleBackground.tsx index 108621a..60e0a47 100644 --- a/frontend/components/ui/ParticleBackground.tsx +++ b/frontend/components/ui/ParticleBackground.tsx @@ -1,54 +1,30 @@ "use client"; -import { useRef } from "react"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { Points, PointMaterial } from "@react-three/drei"; -import * as THREE from "three"; - -function ParticleField() { - const ref = useRef(null); - - // Generate random points in a sphere - const particleCount = 5000; - const positions = new Float32Array(particleCount * 3); - for (let i = 0; i < particleCount * 3; i += 3) { - const radius = Math.random() * 1.5; - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(Math.random() * 2 - 1); - - positions[i] = radius * Math.sin(phi) * Math.cos(theta); - positions[i + 1] = radius * Math.sin(phi) * Math.sin(theta); - positions[i + 2] = radius * Math.cos(phi); - } - - useFrame((state, delta) => { - if (ref.current) { - ref.current.rotation.x -= delta / 10; - ref.current.rotation.y -= delta / 15; - } - }); - - return ( - - - - - - ); -} +import { useEffect, useState } from "react"; +/** + * Lightweight CSS background — avoids WebGL ReadPixels GPU stall warnings from Three.js. + */ export function ParticleBackground() { + const [enabled, setEnabled] = useState(true); + + useEffect(() => { + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const sync = () => setEnabled(!reducedMotion.matches); + sync(); + reducedMotion.addEventListener("change", sync); + return () => reducedMotion.removeEventListener("change", sync); + }, []); + return ( -
- - - +
+
+ {enabled && ( +
+ )}
); } diff --git a/frontend/components/web3/WalletBalancePanel.tsx b/frontend/components/web3/WalletBalancePanel.tsx new file mode 100644 index 0000000..27fc12d --- /dev/null +++ b/frontend/components/web3/WalletBalancePanel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useTokenBalances } from "@/lib/hooks/useTokenBalances"; +import { + formatUsd, + tokenUsdValue, + useUsdPrices, +} from "@/lib/prices"; +import { formatTokenAmount } from "@/lib/tokens"; +import { formatAddress } from "@/lib/utils"; +import { CHAIN138_ID } from "@/lib/constants/chain138"; + +interface WalletBalancePanelProps { + address: `0x${string}`; + onDisconnect?: () => void; +} + +function BalanceRow({ + label, + amount, + usd, + loading, +}: { + label: string; + amount: string; + usd: number | null; + loading?: boolean; +}) { + return ( +
+
+

{label}

+

+ {loading ? "Loading…" : amount} +

+
+

+ {loading ? "…" : formatUsd(usd)} +

+
+ ); +} + +export function WalletBalancePanel({ address, onDisconnect }: WalletBalancePanelProps) { + const { ethUsd, isLoading: pricesLoading } = useUsdPrices(); + const { + nativeBalance, + cusdtRaw, + cusdcRaw, + erc20Tokens, + isLoading: balancesLoading, + refetch, + } = useTokenBalances(address); + + const loading = balancesLoading || pricesLoading; + + const ethRaw = nativeBalance?.value; + const ethUsdVal = tokenUsdValue(ethRaw, 18, "ETH", ethUsd); + const cusdtUsd = tokenUsdValue(cusdtRaw, erc20Tokens[0].decimals, "cUSDT", ethUsd); + const cusdcUsd = tokenUsdValue(cusdcRaw, erc20Tokens[1].decimals, "cUSDC", ethUsd); + + const totalUsd = + [ethUsdVal, cusdtUsd, cusdcUsd].reduce((sum, v) => { + if (v == null) return sum; + return (sum ?? 0) + v; + }, null); + + return ( +
+
+

Connected wallet

+

{formatAddress(address)}

+

Chain {CHAIN138_ID} balances

+
+ +
+ + + +
+ +
+ Estimated total + + {loading ? "…" : formatUsd(totalUsd)} + +
+ +
+ + {onDisconnect && ( + + )} +
+
+ ); +} diff --git a/frontend/components/web3/WalletConnect.tsx b/frontend/components/web3/WalletConnect.tsx index 2b9daa6..f08d82c 100644 --- a/frontend/components/web3/WalletConnect.tsx +++ b/frontend/components/web3/WalletConnect.tsx @@ -1,42 +1,113 @@ "use client"; +import { useState, useEffect, useRef } from "react"; import { useAccount, useConnect, useDisconnect } from "wagmi"; import { formatAddress } from "@/lib/utils"; +import { isValidWalletConnectProjectId, getWalletConnectProjectId } from "@/lib/web3/walletconnect"; +import { WalletBalancePanel } from "@/components/web3/WalletBalancePanel"; export function WalletConnect() { const { address, isConnected } = useAccount(); - const { connectors, connect } = useConnect(); + const { connectors, connect, isPending } = useConnect(); const { disconnect } = useDisconnect(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [menuOpen]); + + const walletConnectConfigured = isValidWalletConnectProjectId( + getWalletConnectProjectId() + ); + + const availableConnectors = connectors.filter((connector) => { + if (connector.id === "walletConnect" && !walletConnectConfigured) { + return false; + } + return true; + }); if (isConnected && address) { return ( -
-
- Connected:{" "} - {formatAddress(address)} -
+
+ {menuOpen && ( +
+ { + disconnect(); + setMenuOpen(false); + }} + /> +
+ )}
); } return ( -
- {connectors.map((connector) => ( - + {menuOpen && ( +
- Connect {connector.name} - - ))} + {availableConnectors.map((connector) => ( + + ))} + {!walletConnectConfigured && ( +

+ WalletConnect disabled — set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID +

+ )} +
+ )}
); } - diff --git a/frontend/components/web3/WalletGate.tsx b/frontend/components/web3/WalletGate.tsx new file mode 100644 index 0000000..c6931e3 --- /dev/null +++ b/frontend/components/web3/WalletGate.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useAccount } from "wagmi"; +import { WalletConnect } from "@/components/web3/WalletConnect"; + +interface WalletGateProps { + title: string; + description?: string; + children: React.ReactNode; +} + +export function WalletGate({ title, description, children }: WalletGateProps) { + const { isConnected } = useAccount(); + + if (!isConnected) { + return ( +
+
+

{title}

+

+ {description ?? + "Connect your Web3 wallet to access this treasury feature."} +

+
+ +
+
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts new file mode 100644 index 0000000..a159777 --- /dev/null +++ b/frontend/e2e/smoke.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test"; + +const routes = [ + "/", + "/send", + "/receive", + "/transfer", + "/approvals", + "/activity", + "/settings", +]; + +test.describe.configure({ mode: "serial", timeout: 90_000 }); + +for (const path of routes) { + test(`loads ${path}`, async ({ page }) => { + const response = await page.goto(path, { waitUntil: "domcontentloaded", timeout: 90_000 }); + expect(response?.ok()).toBeTruthy(); + await expect(page).toHaveTitle(/Solace Treasury Management/); + await expect( + page.getByRole("heading", { name: "Solace Treasury Management", level: 1 }) + ).toBeVisible({ timeout: 20_000 }); + }); +} + +test("backend health when API is up", async ({ request }) => { + const api = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; + const res = await request.get(`${api}/health`); + test.skip(res.status() !== 200, "Backend not running"); + const body = await res.json(); + expect(body.status).toBe("ok"); +}); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts new file mode 100644 index 0000000..99627c7 --- /dev/null +++ b/frontend/lib/api/client.ts @@ -0,0 +1,133 @@ +/** Same-origin in production (nginx /api/ → backend). Local dev uses localhost:3001. */ +function apiUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + const configured = (process.env.NEXT_PUBLIC_API_URL ?? "").trim(); + + if (typeof window !== "undefined") { + // Ignore localhost baked in at build time — browser must hit same-origin /api/. + if (configured && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(configured)) { + return `${configured.replace(/\/$/, "")}${normalized}`; + } + return normalized; + } + + if (configured) { + return `${configured.replace(/\/$/, "")}${normalized}`; + } + if (process.env.NODE_ENV === "production") { + return normalized; + } + return `http://localhost:3001${normalized}`; +} + +export interface TreasuryRecord { + id: string; + organizationId: string; + chainId: number; + mainWallet: string; + label: string | null; + createdAt: string; + updatedAt: string; +} + +export interface TransactionRecord { + id: string; + treasuryId: string; + proposalId: number; + walletAddress: string; + to: string; + value: string; + token: string | null; + data: string | null; + status: string; + proposer: string; + createdAt: string; + executedAt: string | null; + approvalCount?: number; +} + +export interface SubAccountRecord { + id: string; + treasuryId: string; + address: string; + label: string | null; + metadataHash: string | null; + createdAt: string; +} + +export interface LedgerEntry { + id: string; + kind: "deposit" | "transfer_out"; + txHash: string; + from: string; + to: string; + value: string; + token: string | null; + tokenSymbol: string | null; + timestamp: string; + blockNumber: number; +} + +async function apiFetch(path: string): Promise { + const res = await fetch(apiUrl(path), { cache: "no-store" }); + if (!res.ok) { + throw new Error(`API ${path} failed: ${res.status}`); + } + return res.json() as Promise; +} + +export async function fetchTreasury(wallet: string): Promise { + const data = await apiFetch<{ treasury: TreasuryRecord | null }>( + `/api/treasury?wallet=${encodeURIComponent(wallet)}` + ); + return data.treasury; +} + +export async function fetchTransactions( + treasuryId: string, + status?: string +): Promise { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + const data = await apiFetch<{ proposals: TransactionRecord[] }>( + `/api/transactions/${treasuryId}${query}` + ); + return data.proposals; +} + +export async function fetchPendingProposals(treasuryId: string): Promise { + const data = await apiFetch<{ proposals: TransactionRecord[] }>( + `/api/proposals/${treasuryId}?status=pending&includeApprovals=1` + ); + return data.proposals; +} + +export async function fetchSubAccounts(treasuryId: string): Promise { + const data = await apiFetch<{ subAccounts: SubAccountRecord[] }>( + `/api/treasury/${treasuryId}/sub-accounts` + ); + return data.subAccounts; +} + +export async function fetchLedger(treasuryId: string): Promise { + const data = await apiFetch<{ ledger: LedgerEntry[] }>( + `/api/treasury/${treasuryId}/ledger` + ); + return data.ledger; +} + +export async function fetchLedgerByWallet(wallet: string): Promise { + const data = await apiFetch<{ ledger: LedgerEntry[] }>( + `/api/ledger?wallet=${encodeURIComponent(wallet)}` + ); + return data.ledger; +} + +export async function exportTransactionsCsv(treasuryId: string): Promise { + const res = await fetch( + apiUrl(`/api/transactions/export?treasuryId=${encodeURIComponent(treasuryId)}&format=csv`) + ); + if (!res.ok) { + throw new Error(`CSV export failed: ${res.status}`); + } + return res.text(); +} diff --git a/frontend/lib/constants/chain138.ts b/frontend/lib/constants/chain138.ts new file mode 100644 index 0000000..d8c9224 --- /dev/null +++ b/frontend/lib/constants/chain138.ts @@ -0,0 +1,2 @@ +/** Defi Oracle Meta Mainnet — treasury DApp target chain. */ +export const CHAIN138_ID = 138 as const; diff --git a/frontend/lib/hooks/useTokenBalances.ts b/frontend/lib/hooks/useTokenBalances.ts new file mode 100644 index 0000000..37869d2 --- /dev/null +++ b/frontend/lib/hooks/useTokenBalances.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useBalance, useReadContract } from "wagmi"; +import { CHAIN138_ID } from "@/lib/constants/chain138"; +import { CHAIN138_TOKENS, ERC20_BALANCE_ABI } from "@/lib/tokens"; + +const ERC20_TOKENS = CHAIN138_TOKENS.filter((t) => t.address !== "native"); + +export function useTokenBalances(address?: `0x${string}`) { + const enabled = Boolean(address); + + const { data: nativeBalance, isLoading: nativeLoading, refetch: refetchNative } = + useBalance({ + address, + chainId: CHAIN138_ID, + query: { enabled }, + }); + + const { data: cusdtRaw, isLoading: cusdtLoading, refetch: refetchCusdt } = + useReadContract({ + address: ERC20_TOKENS[0]?.address as `0x${string}`, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: address ? [address] : undefined, + chainId: CHAIN138_ID, + query: { enabled }, + }); + + const { data: cusdcRaw, isLoading: cusdcLoading, refetch: refetchCusdc } = + useReadContract({ + address: ERC20_TOKENS[1]?.address as `0x${string}`, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: address ? [address] : undefined, + chainId: CHAIN138_ID, + query: { enabled }, + }); + + const isLoading = nativeLoading || cusdtLoading || cusdcLoading; + + const refetch = () => { + void refetchNative(); + void refetchCusdt(); + void refetchCusdc(); + }; + + return { + nativeBalance, + cusdtRaw: cusdtRaw as bigint | undefined, + cusdcRaw: cusdcRaw as bigint | undefined, + erc20Tokens: ERC20_TOKENS, + isLoading, + refetch, + }; +} diff --git a/frontend/lib/hooks/useTreasury.ts b/frontend/lib/hooks/useTreasury.ts new file mode 100644 index 0000000..8d35934 --- /dev/null +++ b/frontend/lib/hooks/useTreasury.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts"; +import { fetchTreasury, type TreasuryRecord } from "@/lib/api/client"; + +export function useTreasury() { + const [treasury, setTreasury] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const wallet = CONTRACT_ADDRESSES.TreasuryWallet; + if (!wallet) { + setLoading(false); + setError("Treasury wallet not configured"); + return; + } + + fetchTreasury(wallet) + .then((record) => { + setTreasury(record); + if (!record) { + setError("Treasury not found in backend"); + } + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load treasury"); + }) + .finally(() => setLoading(false)); + }, []); + + return { treasury, treasuryId: treasury?.id ?? null, loading, error }; +} diff --git a/frontend/lib/memo.ts b/frontend/lib/memo.ts new file mode 100644 index 0000000..fb92a46 --- /dev/null +++ b/frontend/lib/memo.ts @@ -0,0 +1,20 @@ +import { hexToString, stringToHex } from "viem"; + +export function encodeMemoData(memo: string): `0x${string}` { + const trimmed = memo.trim(); + if (!trimmed) return "0x"; + return stringToHex(trimmed); +} + +export function decodeMemoData(data: string | null | undefined): string | null { + if (!data || data === "0x") return null; + try { + const text = hexToString(data as `0x${string}`); + if (text && /^[\x20-\x7E\s]+$/.test(text)) { + return text.trim(); + } + } catch { + return null; + } + return null; +} diff --git a/frontend/lib/prices.ts b/frontend/lib/prices.ts new file mode 100644 index 0000000..51c2d39 --- /dev/null +++ b/frontend/lib/prices.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; + +const STABLE_USD = 1; + +async function fetchEthUsd(): Promise { + const res = await fetch( + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" + ); + if (!res.ok) { + throw new Error(`Price feed HTTP ${res.status}`); + } + const json = (await res.json()) as { ethereum?: { usd?: number } }; + const usd = json.ethereum?.usd; + if (typeof usd !== "number" || !Number.isFinite(usd)) { + throw new Error("Invalid ETH price payload"); + } + return usd; +} + +export function useUsdPrices() { + const query = useQuery({ + queryKey: ["usd-prices", "eth"], + queryFn: fetchEthUsd, + staleTime: 60_000, + retry: 2, + }); + + return { + ethUsd: query.data ?? null, + stableUsd: STABLE_USD, + isLoading: query.isLoading, + error: query.error, + }; +} + +export function rawToDecimal(raw: bigint, decimals: number): number { + const divisor = 10 ** decimals; + return Number(raw) / divisor; +} + +export function formatUsd(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "—"; + } + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} + +export function tokenUsdValue( + raw: bigint | undefined, + decimals: number, + symbol: string, + ethUsd: number | null +): number | null { + if (raw === undefined) return null; + const amount = rawToDecimal(raw, decimals); + if (symbol === "ETH") { + return ethUsd != null ? amount * ethUsd : null; + } + if (symbol === "cUSDT" || symbol === "cUSDC") { + return amount * STABLE_USD; + } + return null; +} diff --git a/frontend/lib/stubs/async-storage.js b/frontend/lib/stubs/async-storage.js new file mode 100644 index 0000000..ec5235e --- /dev/null +++ b/frontend/lib/stubs/async-storage.js @@ -0,0 +1,8 @@ +const asyncStorage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, +}; + +module.exports = asyncStorage; +module.exports.default = asyncStorage; diff --git a/frontend/lib/tokens.ts b/frontend/lib/tokens.ts new file mode 100644 index 0000000..b640d2d --- /dev/null +++ b/frontend/lib/tokens.ts @@ -0,0 +1,59 @@ +export interface Chain138Token { + symbol: string; + name: string; + address: `0x${string}` | "native"; + decimals: number; +} + +/** Canonical Chain 138 stablecoins — see EXPLORER_TOKEN_LIST_CROSSCHECK §5 */ +export const CHAIN138_TOKENS: Chain138Token[] = [ + { + symbol: "ETH", + name: "Native ETH", + address: "native", + decimals: 18, + }, + { + symbol: "cUSDT", + name: "Compliant USDT", + address: "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", + decimals: 6, + }, + { + symbol: "cUSDC", + name: "Compliant USDC", + address: "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + decimals: 6, + }, +]; + +export function getTokenByAddress(address: string | null | undefined): Chain138Token | null { + if (!address || address === "0x0000000000000000000000000000000000000000") { + return CHAIN138_TOKENS[0]; + } + return ( + CHAIN138_TOKENS.find((t) => t.address !== "native" && t.address.toLowerCase() === address.toLowerCase()) ?? + null + ); +} + +export function formatTokenAmount(value: string, token: Chain138Token | null): string { + const raw = BigInt(value); + const decimals = token?.decimals ?? 18; + const divisor = 10n ** BigInt(decimals); + const whole = raw / divisor; + const frac = raw % divisor; + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + const symbol = token?.symbol ?? "ETH"; + return fracStr ? `${whole}.${fracStr} ${symbol}` : `${whole} ${symbol}`; +} + +export const ERC20_BALANCE_ABI = [ + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/frontend/lib/web3/chain138-lan.dev.ts b/frontend/lib/web3/chain138-lan.dev.ts new file mode 100644 index 0000000..a5ceb2e --- /dev/null +++ b/frontend/lib/web3/chain138-lan.dev.ts @@ -0,0 +1,6 @@ +/** LAN Chain 138 endpoints — local operator dev only; never import from production client bundles. */ +export const CHAIN138_LAN = { + httpRpc: "http://192.168.11.211:8545", + wsRpc: "ws://192.168.11.211:8546", + explorer: "https://explorer.d-bis.org", +} as const; diff --git a/frontend/lib/web3/chain138-public.ts b/frontend/lib/web3/chain138-public.ts new file mode 100644 index 0000000..b1b96d7 --- /dev/null +++ b/frontend/lib/web3/chain138-public.ts @@ -0,0 +1,6 @@ +/** Public Chain 138 endpoints for browser clients (HTTPS/WSS — safe from treasury.d-bis.org). */ +export const CHAIN138_PUBLIC = { + httpRpc: "https://rpc-http-pub.d-bis.org", + wsRpc: "wss://rpc-ws-pub.d-bis.org", + explorer: "https://explorer.d-bis.org", +} as const; diff --git a/frontend/lib/web3/config.ts b/frontend/lib/web3/config.ts index b30799d..126a0e1 100644 --- a/frontend/lib/web3/config.ts +++ b/frontend/lib/web3/config.ts @@ -1,11 +1,37 @@ -import { createConfig, http, webSocket } from "wagmi"; +import { createConfig, http, webSocket, type Config } from "wagmi"; import { mainnet, sepolia } from "wagmi/chains"; import { defineChain } from "viem"; import { walletConnect, injected, metaMask } from "wagmi/connectors"; +import { + getWalletConnectProjectId, + isValidWalletConnectProjectId, +} from "./walletconnect"; +import { CHAIN138_PUBLIC } from "./chain138-public"; -const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || ""; +function buildConnectors() { + const projectId = getWalletConnectProjectId(); + if (isValidWalletConnectProjectId(projectId)) { + return [injected(), metaMask(), walletConnect({ projectId })]; + } + return [injected(), metaMask()]; +} + +const chain138HttpRpc = + process.env.NEXT_PUBLIC_CHAIN138_RPC_URL?.trim() || CHAIN138_PUBLIC.httpRpc; + +const chain138WsRpc = process.env.NEXT_PUBLIC_CHAIN138_WS_URL?.trim() || ""; + +const chain138Explorer = + process.env.NEXT_PUBLIC_CHAIN138_EXPLORER_URL?.trim() || + CHAIN138_PUBLIC.explorer; + +function chain138Transport() { + if (chain138WsRpc.startsWith("wss://")) { + return webSocket(chain138WsRpc); + } + return http(chain138HttpRpc); +} -// Define Chain 138 (Custom Besu Network) const chain138 = defineChain({ id: 138, name: "Solace Chain 138", @@ -16,46 +42,41 @@ const chain138 = defineChain({ }, rpcUrls: { default: { - http: [ - process.env.NEXT_PUBLIC_CHAIN138_RPC_URL || "http://192.168.11.250:8545", - "http://192.168.11.251:8545", - "http://192.168.11.252:8545", - ], - webSocket: [ - process.env.NEXT_PUBLIC_CHAIN138_WS_URL || "ws://192.168.11.250:8546", - "ws://192.168.11.251:8546", - "ws://192.168.11.252:8546", - ], + http: [chain138HttpRpc], + ...(chain138WsRpc.startsWith("wss://") + ? { webSocket: [chain138WsRpc] } + : {}), }, }, blockExplorers: { default: { name: "Chain 138 Explorer", - url: "http://192.168.11.140", + url: chain138Explorer, }, }, testnet: false, }); -export const config = createConfig({ - chains: [chain138, mainnet, sepolia], - connectors: [ - injected(), - metaMask(), - walletConnect({ projectId }), - ], - transports: { - [chain138.id]: process.env.NEXT_PUBLIC_CHAIN138_WS_URL - ? webSocket(process.env.NEXT_PUBLIC_CHAIN138_WS_URL) - : http(process.env.NEXT_PUBLIC_CHAIN138_RPC_URL || "http://192.168.11.250:8545"), - [mainnet.id]: http(), - [sepolia.id]: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL), - }, -}); +let wagmiConfig: Config | undefined; + +export function getConfig(): Config { + if (!wagmiConfig) { + wagmiConfig = createConfig({ + chains: [chain138, mainnet, sepolia], + connectors: buildConnectors(), + transports: { + [chain138.id]: chain138Transport(), + [mainnet.id]: http(), + [sepolia.id]: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL), + }, + ssr: false, + }); + } + return wagmiConfig; +} declare module "wagmi" { interface Register { - config: typeof config; + config: ReturnType; } } - diff --git a/frontend/lib/web3/contracts.ts b/frontend/lib/web3/contracts.ts index 38990a7..2cb1db0 100644 --- a/frontend/lib/web3/contracts.ts +++ b/frontend/lib/web3/contracts.ts @@ -112,6 +112,18 @@ export const TREASURY_WALLET_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "address", name: "token", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + name: "proposeTokenTransfer", + outputs: [{ internalType: "uint256", name: "proposalId", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [{ internalType: "address", name: "ownerToRemove", type: "address" }], name: "removeOwner", @@ -157,13 +169,15 @@ export const TREASURY_WALLET_ABI = [ }, { inputs: [{ internalType: "uint256", name: "proposalId", type: "uint256" }], - name: "getTransaction", + name: "getTransactionFull", outputs: [ { internalType: "address", name: "to", type: "address" }, + { internalType: "address", name: "token", type: "address" }, { internalType: "uint256", name: "value", type: "uint256" }, { internalType: "bytes", name: "data", type: "bytes" }, { internalType: "bool", name: "executed", type: "bool" }, - { internalType: "uint256", name: "approvalCount", type: "uint256" }], + { internalType: "uint256", name: "approvalCount", type: "uint256" }, + ], stateMutability: "view", type: "function", }, @@ -180,3 +194,40 @@ export const TREASURY_WALLET_ABI = [ }, ] as const; +export const SUB_ACCOUNT_FACTORY_ABI = [ + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "parentTreasury", type: "address" }, + { indexed: true, internalType: "address", name: "subAccount", type: "address" }, + { indexed: false, internalType: "bytes32", name: "metadataHash", type: "bytes32" }, + ], + name: "SubAccountCreated", + type: "event", + }, + { + inputs: [ + { internalType: "address", name: "parentTreasuryAddress", type: "address" }, + { internalType: "bytes32", name: "metadataHash", type: "bytes32" }, + ], + name: "createSubAccount", + outputs: [{ internalType: "address", name: "subAccount", type: "address" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "parentTreasuryAddress", type: "address" }], + name: "getSubAccounts", + outputs: [{ internalType: "address[]", name: "", type: "address[]" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "isSubAccount", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, +] as const; + diff --git a/frontend/lib/web3/walletconnect.ts b/frontend/lib/web3/walletconnect.ts new file mode 100644 index 0000000..623123a --- /dev/null +++ b/frontend/lib/web3/walletconnect.ts @@ -0,0 +1,17 @@ +const PLACEHOLDER_PROJECT_IDS = new Set([ + "", + "temp_project_id_replace_me", + "your_walletconnect_project_id", +]); + +export function isValidWalletConnectProjectId(projectId: string | undefined): boolean { + if (!projectId?.trim()) return false; + const normalized = projectId.trim(); + if (PLACEHOLDER_PROJECT_IDS.has(normalized)) return false; + if (/replace_me|your_/i.test(normalized)) return false; + return normalized.length >= 32; +} + +export function getWalletConnectProjectId(): string { + return process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID?.trim() ?? ""; +} diff --git a/frontend/next.config.js b/frontend/next.config.js index c933fce..cebd64e 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,13 +1,32 @@ +const path = require("path"); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - webpack: (config) => { + webpack: (config, { isServer }) => { config.resolve.fallback = { ...config.resolve.fallback, fs: false, net: false, tls: false, }; + + config.resolve.alias = { + ...config.resolve.alias, + "@react-native-async-storage/async-storage": path.join( + __dirname, + "lib/stubs/async-storage.js" + ), + "pino-pretty": false, + }; + + if (!isServer) { + config.externals = config.externals || []; + if (Array.isArray(config.externals)) { + config.externals.push("pino-pretty", "lokijs", "encoding"); + } + } + return config; }, }; diff --git a/frontend/package.json b/frontend/package.json index 83e0a36..72878c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "next": "^14.0.4", @@ -40,7 +42,8 @@ "postcss": "^8.4.32", "autoprefixer": "^10.4.16", "eslint": "^8.56.0", - "eslint-config-next": "^14.0.4" + "eslint-config-next": "^14.0.4", + "@playwright/test": "^1.49.0" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..498893b --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "list", + use: { + baseURL: process.env.FRONTEND_URL || "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: process.env.PW_SKIP_WEBSERVER + ? undefined + : { + command: "pnpm run dev", + url: "http://localhost:3000", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..da869ee --- /dev/null +++ b/frontend/public/favicon.ico @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..da869ee --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/package.json b/package.json index 8a45dde..dd83523 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,13 @@ "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,sol}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,sol}\"", - "check-setup": "bash scripts/check-setup.sh" + "check-setup": "bash scripts/check-setup.sh", + "audit-env": "bash scripts/audit-env.sh", + "sync-env": "bash scripts/sync-env-from-deployment.sh", + "start:local": "bash scripts/start-local-stack.sh", + "verify:full-stack": "bash scripts/verify-full-stack.sh", + "test:e2e": "cd frontend && pnpm run test:e2e", + "verify:contracts": "cd contracts && pnpm run verify:chain138" }, "devDependencies": { "prettier": "^3.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae26d3f..0147cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.4.7 viem: specifier: ^2.0.0 - version: 2.43.2(typescript@5.9.3)(zod@3.25.76) + version: 2.43.2(typescript@5.3.3)(zod@3.25.76) zod: specifier: ^3.22.4 version: 3.25.76 @@ -49,8 +49,8 @@ importers: specifier: ^4.7.0 version: 4.21.0 typescript: - specifier: ^5.3.3 - version: 5.9.3 + specifier: 5.3.3 + version: 5.3.3 contracts: dependencies: @@ -111,7 +111,7 @@ importers: version: 0.10.8(@types/three@0.160.0)(three@0.160.1) next: specifier: ^14.0.4 - version: 14.2.35(react-dom@18.3.1)(react@18.3.1) + version: 14.2.35(@playwright/test@1.60.0)(react-dom@18.3.1)(react@18.3.1) qrcode.react: specifier: ^3.1.0 version: 3.2.0(react@18.3.1) @@ -137,6 +137,9 @@ importers: specifier: ^3.22.4 version: 3.25.76 devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.60.0 '@types/node': specifier: ^20.10.0 version: 20.19.27 @@ -2297,6 +2300,13 @@ packages: dev: true optional: true + /@playwright/test@1.60.0: + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright: 1.60.0 + /@react-spring/animated@9.7.5(react@18.3.1): resolution: {integrity: sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==} peerDependencies: @@ -5664,6 +5674,21 @@ packages: zod: 3.25.76 dev: false + /abitype@1.2.3(typescript@5.3.3)(zod@3.25.76): + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + dependencies: + typescript: 5.3.3 + zod: 3.25.76 + dev: false + /abitype@1.2.3(typescript@5.9.3)(zod@3.22.4): resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -8197,6 +8222,13 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -8314,7 +8346,7 @@ packages: /glob@5.0.15: resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: inflight: 1.0.6 inherits: 2.0.4 @@ -8325,7 +8357,7 @@ packages: /glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -9666,7 +9698,7 @@ packages: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} dev: true - /next@14.2.35(react-dom@18.3.1)(react@18.3.1): + /next@14.2.35(@playwright/test@1.60.0)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} engines: {node: '>=18.17.0'} hasBin: true @@ -9685,6 +9717,7 @@ packages: optional: true dependencies: '@next/env': 14.2.35 + '@playwright/test': 1.60.0 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001761 @@ -9929,6 +9962,27 @@ packages: safe-push-apply: 1.0.0 dev: true + /ox@0.10.6(typescript@5.3.3)(zod@3.25.76): + resolution: {integrity: sha512-J3QUxlwSM0uCL7sm5OsprlEeU6vNdKUyyukh1nUT3Jrog4l2FMJNIZPlffjPXCaS/hJYjdNe3XbEN8jCq1mnEQ==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.3.3)(zod@3.25.76) + eventemitter3: 5.0.1 + typescript: 5.3.3 + transitivePeerDependencies: + - zod + dev: false + /ox@0.10.6(typescript@5.9.3)(zod@3.22.4): resolution: {integrity: sha512-J3QUxlwSM0uCL7sm5OsprlEeU6vNdKUyyukh1nUT3Jrog4l2FMJNIZPlffjPXCaS/hJYjdNe3XbEN8jCq1mnEQ==} peerDependencies: @@ -10242,6 +10296,20 @@ packages: engines: {node: '>= 6'} dev: true + /playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + /playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + /pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -11977,6 +12045,11 @@ packages: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} dev: true + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + /typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -12324,6 +12397,29 @@ packages: - zod dev: false + /viem@2.43.2(typescript@5.3.3)(zod@3.25.76): + resolution: {integrity: sha512-9fLAuPArLHnePaXiyj1jHsB7AaMXMD1WCV3q9QhpJk3+O6u8R5Ey7XjTIx4e2n4OrtkL3tcJDK9qVL770+SVyA==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.3.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3) + ox: 0.10.6(typescript@5.3.3)(zod@3.25.76) + typescript: 5.3.3 + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + dev: false + /viem@2.43.2(typescript@5.9.3)(zod@3.22.4): resolution: {integrity: sha512-9fLAuPArLHnePaXiyj1jHsB7AaMXMD1WCV3q9QhpJk3+O6u8R5Ey7XjTIx4e2n4OrtkL3tcJDK9qVL770+SVyA==} peerDependencies: diff --git a/scripts/audit-env.sh b/scripts/audit-env.sh new file mode 100755 index 0000000..14f4945 --- /dev/null +++ b/scripts/audit-env.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Audit required and optional credentials across workspace dotenv files. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +ok() { echo -e "${GREEN}✅${NC} $1"; } +warn() { echo -e "${YELLOW}⚠️${NC} $1"; } +fail() { echo -e "${RED}❌${NC} $1"; } + +is_placeholder() { + local v="$1" + [[ -z "$v" ]] && return 0 + [[ "$v" == *replace_me* ]] && return 0 + [[ "$v" == *your_* ]] && return 0 + [[ "$v" == *temp_* ]] && return 0 + return 1 +} + +read_env() { + local file="$1" + local key="$2" + [[ -f "$file" ]] || { echo ""; return; } + grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^"//;s/"$//' || true +} + +echo "=== Solace Treasury — Environment Audit ===" +echo "" + +# Frontend +FE="$ROOT/frontend/.env.local" +echo "## Frontend ($FE)" +for key in NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID NEXT_PUBLIC_CHAIN138_RPC_URL NEXT_PUBLIC_TREASURY_WALLET_ADDRESS NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS NEXT_PUBLIC_API_URL; do + val=$(read_env "$FE" "$key") + if is_placeholder "$val"; then + if [[ "$key" == "NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID" ]]; then + warn "$key optional (Injected/MetaMask work without it)" + else + fail "$key missing or placeholder" + fi + else + ok "$key set" + fi +done + +# Backend +BE="$ROOT/backend/.env" +echo "" +echo "## Backend ($BE)" +for key in DATABASE_URL RPC_URL CHAIN_ID CONTRACT_ADDRESS FACTORY_ADDRESS; do + val=$(read_env "$BE" "$key") + if is_placeholder "$val"; then + fail "$key missing or placeholder" + else + ok "$key set" + fi +done + +IDX="$ROOT/backend/.env.indexer" +echo "" +echo "## Indexer ($IDX)" +if [[ -f "$IDX" ]]; then + for key in CONTRACT_ADDRESS START_BLOCK RPC_URL; do + val=$(read_env "$IDX" "$key") + if is_placeholder "$val"; then + warn "$key missing in .env.indexer (falls back to backend/.env)" + else + ok "$key set" + fi + done +else + warn ".env.indexer not found" +fi + +# Contracts +CE="$ROOT/contracts/.env" +echo "" +echo "## Contracts ($CE)" +for key in CHAIN138_RPC_URL PRIVATE_KEY; do + val=$(read_env "$CE" "$key") + if is_placeholder "$val"; then + fail "$key missing or placeholder" + else + ok "$key set" + fi +done + +# Address alignment +DEPLOY="$ROOT/contracts/deployments/chain138.json" +if [[ -f "$DEPLOY" && -f "$BE" ]]; then + echo "" + echo "## Deployment alignment" + DEPLOY_TREASURY=$(jq -r '.contracts.TreasuryWallet' "$DEPLOY" | tr '[:upper:]' '[:lower:]') + ENV_TREASURY=$(read_env "$BE" CONTRACT_ADDRESS | tr '[:upper:]' '[:lower:]') + if [[ -n "$DEPLOY_TREASURY" && "$DEPLOY_TREASURY" == "$ENV_TREASURY" ]]; then + ok "CONTRACT_ADDRESS matches chain138.json" + else + warn "CONTRACT_ADDRESS drift — run ./scripts/sync-env-from-deployment.sh" + fi +fi + +# Connectivity +echo "" +echo "## Connectivity" +RPC=$(read_env "$BE" RPC_URL) +RPC="${RPC:-http://192.168.11.211:8545}" +if curl -sf -m 3 -X POST "$RPC" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' | grep -q '"0x8a"'; then + ok "Chain 138 RPC reachable ($RPC)" +else + fail "Chain 138 RPC unreachable ($RPC)" +fi + +DB=$(read_env "$BE" DATABASE_URL) +if [[ -n "$DB" ]]; then + if command -v psql >/dev/null && psql "$DB" -c "SELECT 1;" >/dev/null 2>&1; then + ok "PostgreSQL reachable" + else + warn "PostgreSQL not reachable (use local Docker or Proxmox CT 3002)" + fi +fi + +API=$(read_env "$FE" NEXT_PUBLIC_API_URL) +API="${API:-http://localhost:3001}" +if curl -sf -m 3 "$API/health" | grep -q '"status"'; then + ok "Backend API reachable ($API)" +else + warn "Backend API not reachable ($API) — run pnpm run dev" +fi + +if [[ -f "$DEPLOY" ]]; then + ok "Chain138 deployment record present" +else + warn "No contracts/deployments/chain138.json — run deploy:chain138" +fi + +echo "" +echo "Run ./scripts/sync-env-from-deployment.sh after deploy to propagate addresses." diff --git a/scripts/check-setup.sh b/scripts/check-setup.sh index 1bfc356..a40732a 100755 --- a/scripts/check-setup.sh +++ b/scripts/check-setup.sh @@ -1,39 +1,33 @@ -#!/bin/bash - +#!/usr/bin/env bash # Setup verification script -# Checks if all required dependencies and configurations are in place - set -e +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + echo "🔍 Checking Solace Treasury DApp setup..." echo "" -# Check Node.js if command -v node &> /dev/null; then - NODE_VERSION=$(node --version) - echo "✅ Node.js: $NODE_VERSION" + echo "✅ Node.js: $(node --version)" else echo "❌ Node.js not found. Please install Node.js >= 18.0.0" exit 1 fi -# Check pnpm if command -v pnpm &> /dev/null; then - PNPM_VERSION=$(pnpm --version) - echo "✅ pnpm: $PNPM_VERSION" + echo "✅ pnpm: $(pnpm --version)" else echo "❌ pnpm not found. Install with: npm install -g pnpm" exit 1 fi -# Check PostgreSQL if command -v psql &> /dev/null; then echo "✅ PostgreSQL: $(psql --version | head -n1)" else echo "⚠️ PostgreSQL not found (required for database)" fi -# Check if dependencies are installed if [ -d "node_modules" ]; then echo "✅ Dependencies installed" else @@ -41,62 +35,58 @@ else exit 1 fi -# Check contracts compilation if [ -d "contracts/artifacts" ]; then echo "✅ Contracts compiled" else echo "⚠️ Contracts not compiled. Run: cd contracts && pnpm run compile" fi -# Check environment files echo "" echo "📋 Environment files:" -if [ -f "frontend/.env.local" ] || [ -f "frontend/.env" ]; then - echo "✅ Frontend env file exists" -else - echo "⚠️ Frontend .env.local not found (create from template)" -fi +for f in frontend/.env.local backend/.env backend/.env.indexer contracts/.env; do + if [ -f "$f" ]; then + echo "✅ $f exists" + else + echo "⚠️ $f not found" + fi +done -if [ -f "backend/.env" ]; then - echo "✅ Backend env file exists" -else - echo "⚠️ Backend .env not found (create from template)" -fi - -if [ -f "contracts/.env" ]; then - echo "✅ Contracts env file exists" -else - echo "⚠️ Contracts .env not found (create from template)" -fi - -# Check database migrations if [ -d "backend/drizzle" ] && [ "$(ls -A backend/drizzle/*.sql 2>/dev/null)" ]; then - echo "✅ Database migrations generated" + echo "✅ Database migrations present" else - echo "⚠️ Database migrations not generated. Run: cd backend && pnpm run db:generate" + echo "⚠️ Database migrations missing. Run: cd backend && pnpm run db:generate" fi -# Check if database is accessible (if DATABASE_URL is set) if [ -f "backend/.env" ]; then + set -a + # shellcheck disable=SC1091 source backend/.env - if [ -n "$DATABASE_URL" ]; then - if command -v psql &> /dev/null; then - if psql "$DATABASE_URL" -c "SELECT 1;" &> /dev/null; then - echo "✅ Database connection successful" - else - echo "⚠️ Database connection failed (check DATABASE_URL)" - fi + set +a + if [ -n "${DATABASE_URL:-}" ] && command -v psql &> /dev/null; then + if psql "$DATABASE_URL" -c "SELECT 1;" &> /dev/null; then + echo "✅ Database connection successful" + else + echo "⚠️ Database connection failed (check DATABASE_URL or docker start solace-postgres)" fi fi fi +if [ -f "contracts/deployments/chain138.json" ]; then + echo "✅ Chain 138 deployment record present" +else + echo "⚠️ No chain138 deployment — run: cd contracts && pnpm run deploy:chain138" +fi + +echo "" +echo "Running audit-env..." +bash scripts/audit-env.sh || true + echo "" echo "✨ Setup check complete!" echo "" echo "Next steps:" -echo "1. Create .env files if missing" -echo "2. Set up database: cd backend && pnpm run db:migrate" -echo "3. Deploy contracts: cd contracts && pnpm run deploy:sepolia" -echo "4. Start dev servers: pnpm run dev" - +echo "1. cd backend && pnpm run db:migrate && pnpm run db:seed" +echo "2. pnpm run sync-env (after deploy)" +echo "3. pnpm run dev (frontend :3000, backend :3001)" +echo "4. cd backend && pnpm run indexer:start" diff --git a/scripts/fund-treasury-canary-chain138.sh b/scripts/fund-treasury-canary-chain138.sh new file mode 100755 index 0000000..70c1fe6 --- /dev/null +++ b/scripts/fund-treasury-canary-chain138.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Fund Solace treasury multisig with a small canary on Chain 138 (ETH + cUSDT + cUSDC). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROXMOX_ROOT="${PROXMOX_ROOT:-$(cd "$REPO_ROOT/../proxmox" 2>/dev/null && pwd || echo "")}" + +if [[ -n "$PROXMOX_ROOT" && -f "$PROXMOX_ROOT/scripts/lib/load-project-env.sh" ]]; then + # shellcheck source=/dev/null + source "$PROXMOX_ROOT/scripts/lib/load-project-env.sh" +elif [[ -f "$REPO_ROOT/../smom-dbis-138/scripts/load-env.sh" ]]; then + # shellcheck source=/dev/null + source "$REPO_ROOT/../smom-dbis-138/scripts/load-env.sh" +fi + +RPC_URL="${RPC_URL_138:-http://192.168.11.211:8545}" +TREASURY="${TREASURY_WALLET:-${NEXT_PUBLIC_TREASURY_WALLET_ADDRESS:-0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C}}" +C_USDT="${C_USDT_CHAIN138:-0x93E66202A11B1772E55407B32B44e5Cd8eda7f22}" +C_USDC="${C_USDC_CHAIN138:-0xf22258f57794CC8E06237084b353Ab30fFfa640b}" + +CANARY_ETH="${CANARY_ETH:-0.01}" +CANARY_STABLE="${CANARY_STABLE:-10}" + +CANARY_ETH_WEI="$(python3 - <&2 + exit 1 +fi + +FROM="$(cast wallet address --private-key "$PRIVATE_KEY")" + +echo "=== Solace treasury canary fund (Chain 138) ===" +echo "RPC: $RPC_URL" +echo "From: $FROM" +echo "Treasury: $TREASURY" +echo "Amounts: ${CANARY_ETH} ETH, ${CANARY_STABLE} cUSDT, ${CANARY_STABLE} cUSDC" +echo + +run_cast() { + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] cast $*" + else + cast "$@" + fi +} + +echo "→ Native ETH" +run_cast send --private-key "$PRIVATE_KEY" --rpc-url "$RPC_URL" --value "$CANARY_ETH_WEI" "$TREASURY" + +echo "→ cUSDT transfer" +run_cast send --private-key "$PRIVATE_KEY" --rpc-url "$RPC_URL" "$C_USDT" \ + "transfer(address,uint256)" "$TREASURY" "$CANARY_STABLE_RAW" + +echo "→ cUSDC transfer" +run_cast send --private-key "$PRIVATE_KEY" --rpc-url "$RPC_URL" "$C_USDC" \ + "transfer(address,uint256)" "$TREASURY" "$CANARY_STABLE_RAW" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry run complete." + exit 0 +fi + +echo +echo "=== Post-fund balances ===" +cast balance "$TREASURY" --rpc-url "$RPC_URL" | xargs -I{} echo "ETH wei: {}" +cast call "$C_USDT" "balanceOf(address)(uint256)" "$TREASURY" --rpc-url "$RPC_URL" +cast call "$C_USDC" "balanceOf(address)(uint256)" "$TREASURY" --rpc-url "$RPC_URL" +echo "Done." diff --git a/scripts/proxmox-finish-deploy.sh b/scripts/proxmox-finish-deploy.sh new file mode 100755 index 0000000..5f10a2f --- /dev/null +++ b/scripts/proxmox-finish-deploy.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Push env files, migrate, seed, and start Solace services on Proxmox CTs. +set -euo pipefail + +PROXMOX_HOST="${PROXMOX_HOST:-192.168.11.11}" +PROXMOX_USER="${PROXMOX_USER:-root}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEPLOY="$ROOT/deployment/proxmox" + +VMID_FRONTEND="${VMID_FRONTEND:-3000}" +VMID_BACKEND="${VMID_BACKEND:-3001}" +VMID_INDEXER="${VMID_INDEXER:-3003}" + +echo "=== Solace Proxmox finish deploy ===" + +"$ROOT/scripts/proxmox-prepare-env.sh" + +echo "Pushing env and unit files..." +scp "$ROOT/frontend/.env.production" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-frontend.env.production" +scp "$ROOT/backend/.env" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-backend.env" +scp "$ROOT/backend/.env.indexer" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-indexer.env.indexer" +scp "$DEPLOY/templates/nginx.conf" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-nginx.conf" +scp "$DEPLOY/templates/solace-treasury-locations.conf" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-nginx-locations.conf" +scp "$DEPLOY/templates/solace-frontend.service" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-frontend.service" +scp "$DEPLOY/templates/solace-backend.service" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-backend.service" +scp "$DEPLOY/templates/solace-indexer.service" "$PROXMOX_USER@$PROXMOX_HOST:/tmp/solace-indexer.service" + +ssh "$PROXMOX_USER@$PROXMOX_HOST" bash -s \ + "$VMID_FRONTEND" "$VMID_BACKEND" "$VMID_INDEXER" <<'REMOTE' +set -euo pipefail +VMID_FRONTEND=$1 +VMID_BACKEND=$2 +VMID_INDEXER=$3 + +pct push "$VMID_FRONTEND" /tmp/solace-frontend.env.production /opt/solace-frontend/.env.production +pct push "$VMID_FRONTEND" /tmp/solace-frontend.service /etc/systemd/system/solace-frontend.service +pct push "$VMID_BACKEND" /tmp/solace-backend.env /opt/solace-backend/.env +pct push "$VMID_INDEXER" /tmp/solace-indexer.env.indexer /opt/solace-indexer/.env.indexer +pct push "$VMID_BACKEND" /tmp/solace-backend.service /etc/systemd/system/solace-backend.service +pct push "$VMID_INDEXER" /tmp/solace-indexer.service /etc/systemd/system/solace-indexer.service +pct push "$VMID_FRONTEND" /tmp/solace-nginx.conf /etc/nginx/sites-available/solace-treasury +pct push "$VMID_FRONTEND" /tmp/solace-nginx-locations.conf /etc/nginx/snippets/solace-treasury-locations.conf + +pct exec "$VMID_BACKEND" -- systemctl daemon-reload +pct exec "$VMID_INDEXER" -- systemctl daemon-reload +pct exec "$VMID_FRONTEND" -- bash -c 'mkdir -p /etc/nginx/snippets /etc/nginx/ssl; ln -sf /etc/nginx/sites-available/solace-treasury /etc/nginx/sites-enabled/solace-treasury; rm -f /etc/nginx/sites-enabled/default; test -f /etc/nginx/ssl/cert.pem || openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/key.pem -out /etc/nginx/ssl/cert.pem -subj /CN=solace-treasury.local; nginx -t; systemctl enable nginx solace-frontend' + +echo "Running migrations..." +pct exec "$VMID_BACKEND" -- bash -c 'cd /opt/solace-backend && pnpm run db:migrate' +pct exec "$VMID_BACKEND" -- bash -c 'cd /opt/solace-backend && pnpm run db:seed || true' + +echo "Starting services..." +pct exec "$VMID_BACKEND" -- systemctl enable solace-backend +pct exec "$VMID_INDEXER" -- systemctl enable solace-indexer +pct exec "$VMID_BACKEND" -- systemctl restart solace-backend +pct exec "$VMID_INDEXER" -- systemctl restart solace-indexer +pct exec "$VMID_FRONTEND" -- systemctl restart solace-frontend +pct exec "$VMID_FRONTEND" -- systemctl restart nginx + +sleep 4 +echo "=== Service status ===" +pct exec "$VMID_FRONTEND" -- systemctl is-active solace-frontend nginx || true +pct exec "$VMID_BACKEND" -- systemctl is-active solace-backend || true +pct exec "$VMID_INDEXER" -- systemctl is-active solace-indexer || true +REMOTE + +echo "Smoke checks..." +curl -sf "http://192.168.11.61:3001/health" && echo " backend OK" || echo " backend FAIL" +curl -sf "http://192.168.11.60/api/config" | grep -q chainId && echo " nginx /api OK" || echo " nginx /api FAIL" +curl -sf -o /dev/null -w "frontend HTTP %{http_code}\n" "http://192.168.11.60/" || true +curl -sfk -o /dev/null -w "frontend HTTPS %{http_code}\n" "https://192.168.11.60/" || true + +echo "Done." diff --git a/scripts/proxmox-prepare-env.sh b/scripts/proxmox-prepare-env.sh new file mode 100755 index 0000000..dbef57a --- /dev/null +++ b/scripts/proxmox-prepare-env.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Prepare backend/frontend env files for Proxmox CT IPs (192.168.11.60–63). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DB_HOST="${DATABASE_IP:-192.168.11.62}" +DB_USER="${DATABASE_USER:-solace_user}" +DB_NAME="${DATABASE_NAME:-solace_treasury}" +DB_PASS="${DATABASE_PASSWORD:?Set DATABASE_PASSWORD}" + +DB_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/${DB_NAME}" + +set_env() { + local file="$1" key="$2" value="$3" + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + else + echo "${key}=${value}" >> "$file" + fi +} + +[[ -f "$ROOT/contracts/deployments/chain138.json" ]] && "$ROOT/scripts/sync-env-from-deployment.sh" + +for f in "$ROOT/backend/.env" "$ROOT/backend/.env.indexer"; do + [[ -f "$f" ]] || continue + set_env "$f" DATABASE_URL "$DB_URL" + set_env "$f" NODE_ENV production +done + +# Same-origin API when nginx on CT 3000 proxies /api/ (public NPMplus or LAN HTTPS) +if [[ "${SOLACE_API_SAME_ORIGIN:-1}" == "1" ]]; then + set_env "$ROOT/frontend/.env.production" NEXT_PUBLIC_API_URL "" +else + set_env "$ROOT/frontend/.env.production" NEXT_PUBLIC_API_URL "${SOLACE_PUBLIC_API_URL:-http://192.168.11.61:3001}" +fi +set_env "$ROOT/frontend/.env.production" NEXT_PUBLIC_CHAIN138_RPC_URL "${CHAIN138_PUBLIC_RPC_URL:-https://rpc-http-pub.d-bis.org}" +set_env "$ROOT/frontend/.env.production" NEXT_PUBLIC_CHAIN138_WS_URL "" +set_env "$ROOT/frontend/.env.production" NEXT_PUBLIC_CHAIN138_EXPLORER_URL "${CHAIN138_PUBLIC_EXPLORER_URL:-https://explorer.d-bis.org}" +set_env "$ROOT/frontend/.env.production" NODE_ENV production + +for f in "$ROOT/backend/.env" "$ROOT/backend/.env.indexer"; do + [[ -f "$f" ]] || continue + set_env "$f" CLIENT_RPC_URL "${CHAIN138_PUBLIC_RPC_URL:-https://rpc-http-pub.d-bis.org}" +done + +echo "Prepared production env (DB host ${DB_HOST}, API same-origin=${SOLACE_API_SAME_ORIGIN:-1}, public RPC=${CHAIN138_PUBLIC_RPC_URL:-https://rpc-http-pub.d-bis.org})" diff --git a/scripts/proxmox-recreate-cts.sh b/scripts/proxmox-recreate-cts.sh new file mode 100644 index 0000000..23425be --- /dev/null +++ b/scripts/proxmox-recreate-cts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Destroy and recreate Solace CTs 3000-3003 on the Proxmox node where they live. +set -euo pipefail + +PROXMOX_HOST="${PROXMOX_HOST:-192.168.11.11}" +PROXMOX_USER="${PROXMOX_USER:-root}" + +echo "Recreating Solace CTs on $PROXMOX_HOST (3000-3003)..." + +ssh "$PROXMOX_USER@$PROXMOX_HOST" bash -s <<'EOF' +set -euo pipefail +for vmid in 3003 3002 3001 3000; do + if pct status "$vmid" &>/dev/null; then + echo "Stopping and destroying CT $vmid..." + pct stop "$vmid" || true + sleep 2 + pct destroy "$vmid" || true + fi +done +pct list | grep -E '300[0-3]' || echo "All Solace CTs removed." +EOF + +echo "Done. Run deploy-remote.sh next." diff --git a/scripts/proxmox-resume-deploy.sh b/scripts/proxmox-resume-deploy.sh new file mode 100755 index 0000000..0676ae1 --- /dev/null +++ b/scripts/proxmox-resume-deploy.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Resume Solace Proxmox deploy after partial run (sync code, rebuild, frontend, finish). +set -euo pipefail + +PROXMOX_HOST="${PROXMOX_HOST:-192.168.11.11}" +PROXMOX_USER="${PROXMOX_USER:-root}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEPLOYMENT_DIR="/tmp/solace-dapp-deployment" +DATABASE_PASSWORD="${DATABASE_PASSWORD:?Set DATABASE_PASSWORD}" + +export DATABASE_PASSWORD +"$ROOT/scripts/proxmox-prepare-env.sh" + +echo "Syncing deployment bundle to $PROXMOX_HOST..." +ssh "$PROXMOX_USER@$PROXMOX_HOST" "mkdir -p $DEPLOYMENT_DIR/project $DEPLOYMENT_DIR/config" +rsync -az "$ROOT/deployment/proxmox/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/" +rsync -az --delete --exclude node_modules --exclude .next --exclude dist \ + "$ROOT/backend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/backend/" +rsync -az --delete --exclude node_modules --exclude .next \ + "$ROOT/frontend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/frontend/" + +ssh "$PROXMOX_USER@$PROXMOX_HOST" "cat > $DEPLOYMENT_DIR/config/dapp.conf </dev/null || true" + cd "$PROJECT_ROOT" + tar czf - backend | pct exec "$vmid" -- bash -c "cd /opt && tar xzf - && mv backend/* $dir/ && mv backend/.* $dir/ 2>/dev/null || true && rmdir backend 2>/dev/null || true" + pct exec "$vmid" -- bash -c "cd /opt/$dir && pnpm install --no-frozen-lockfile" +} + +rebuild_backend 3001 solace-backend +rebuild_backend 3003 solace-indexer +REMOTE + +echo "Deploying frontend (3000) + nginx..." +ssh "$PROXMOX_USER@$PROXMOX_HOST" "cd $DEPLOYMENT_DIR && DEPLOY_DATABASE=false DEPLOY_BACKEND=false DEPLOY_INDEXER=false DEPLOY_FRONTEND=true DEPLOY_NGINX=true ./deploy-dapp.sh" + +"$ROOT/scripts/proxmox-finish-deploy.sh" +echo "Resume deploy complete." diff --git a/scripts/setup-chain138.sh b/scripts/setup-chain138.sh index bbad654..bdf9eec 100755 --- a/scripts/setup-chain138.sh +++ b/scripts/setup-chain138.sh @@ -13,8 +13,8 @@ echo "==========================================" echo "" # Chain 138 RPC endpoints -RPC_URL="${CHAIN138_RPC_URL:-http://192.168.11.250:8545}" -WS_URL="${CHAIN138_WS_URL:-ws://192.168.11.250:8546}" +RPC_URL="${CHAIN138_RPC_URL:-http://192.168.11.211:8545}" +WS_URL="${CHAIN138_WS_URL:-ws://192.168.11.211:8546}" echo "Chain 138 RPC URL: $RPC_URL" echo "Chain 138 WS URL: $WS_URL" @@ -26,6 +26,7 @@ if [[ -f "$DEPLOYMENT_FILE" ]]; then echo "Found deployment file: $DEPLOYMENT_FILE" TREASURY_ADDRESS=$(jq -r '.contracts.TreasuryWallet' "$DEPLOYMENT_FILE" 2>/dev/null || echo "") FACTORY_ADDRESS=$(jq -r '.contracts.SubAccountFactory' "$DEPLOYMENT_FILE" 2>/dev/null || echo "") + DEPLOY_BLOCK=$(jq -r '.deployBlock // 0' "$DEPLOYMENT_FILE" 2>/dev/null || echo "0") if [[ -n "$TREASURY_ADDRESS" && "$TREASURY_ADDRESS" != "null" ]]; then echo "Treasury Wallet Address: $TREASURY_ADDRESS" @@ -83,7 +84,7 @@ CHAIN_ID=138 CONTRACT_ADDRESS=${TREASURY_ADDRESS:-} # Indexer Configuration -START_BLOCK=0 +START_BLOCK=${DEPLOY_BLOCK:-0} EOF echo "" diff --git a/scripts/start-local-stack.sh b/scripts/start-local-stack.sh new file mode 100755 index 0000000..b6dc62b --- /dev/null +++ b/scripts/start-local-stack.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Start local dev stack: Postgres (Docker), migrate, seed, dev servers. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if ! docker ps --format '{{.Names}}' | grep -q '^solace-postgres$'; then + if docker ps -a --format '{{.Names}}' | grep -q '^solace-postgres$'; then + echo "Starting existing solace-postgres container..." + docker start solace-postgres + else + echo "Creating solace-postgres container..." + docker run -d --name solace-postgres \ + -e POSTGRES_USER=solace_user \ + -e POSTGRES_PASSWORD=SolaceTreasury2024! \ + -e POSTGRES_DB=solace_treasury \ + -p 5432:5432 \ + postgres:16-alpine + sleep 3 + fi +fi + +echo "Running migrations and seed..." +(cd backend && pnpm run db:migrate && pnpm run db:seed) + +echo "Starting dev servers (frontend :3000, backend :3001)..." +pnpm run dev diff --git a/scripts/sync-env-from-deployment.sh b/scripts/sync-env-from-deployment.sh new file mode 100755 index 0000000..5b93d7b --- /dev/null +++ b/scripts/sync-env-from-deployment.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Sync contract addresses from contracts/deployments/chain138.json into app env files. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEPLOY="$ROOT/contracts/deployments/chain138.json" + +if [[ ! -f "$DEPLOY" ]]; then + echo "Missing $DEPLOY — run: cd contracts && pnpm run deploy:chain138" >&2 + exit 1 +fi + +TREASURY=$(jq -r '.contracts.TreasuryWallet' "$DEPLOY") +FACTORY=$(jq -r '.contracts.SubAccountFactory' "$DEPLOY") +DEPLOY_BLOCK=$(jq -r '.deployBlock // empty' "$DEPLOY") + +if [[ -z "$TREASURY" || "$TREASURY" == "null" ]]; then + echo "TreasuryWallet missing in deployment JSON" >&2 + exit 1 +fi + +set_env() { + local file="$1" + local key="$2" + local value="$3" + if [[ ! -f "$file" ]]; then + touch "$file" + fi + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + else + echo "${key}=${value}" >> "$file" + fi +} + +PUBLIC_RPC="${CHAIN138_PUBLIC_RPC_URL:-https://rpc-http-pub.d-bis.org}" +LAN_RPC="${CHAIN138_RPC_URL:-http://192.168.11.211:8545}" +LAN_WS="${CHAIN138_WS_URL:-ws://192.168.11.211:8546}" + +for f in "$ROOT/frontend/.env.local" "$ROOT/frontend/.env.production"; do + [[ -f "$f" ]] || continue + set_env "$f" NEXT_PUBLIC_TREASURY_WALLET_ADDRESS "$TREASURY" + set_env "$f" NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS "$FACTORY" + set_env "$f" NEXT_PUBLIC_CHAIN138_RPC_URL "$PUBLIC_RPC" + set_env "$f" NEXT_PUBLIC_CHAIN138_WS_URL "" + set_env "$f" NEXT_PUBLIC_CHAIN138_EXPLORER_URL "${CHAIN138_PUBLIC_EXPLORER_URL:-https://explorer.d-bis.org}" + set_env "$f" NEXT_PUBLIC_CHAIN_ID "138" +done + +for f in "$ROOT/backend/.env" "$ROOT/backend/.env.indexer"; do + [[ -f "$f" ]] || continue + set_env "$f" CONTRACT_ADDRESS "$TREASURY" + set_env "$f" FACTORY_ADDRESS "$FACTORY" + set_env "$f" RPC_URL "$LAN_RPC" + set_env "$f" CLIENT_RPC_URL "$PUBLIC_RPC" + set_env "$f" CHAIN_ID "138" + if [[ -n "$DEPLOY_BLOCK" && "$DEPLOY_BLOCK" != "null" ]]; then + set_env "$f" START_BLOCK "$DEPLOY_BLOCK" + fi +done + +for f in "$ROOT/backend/.env.indexer"; do + [[ -f "$f" ]] || continue + set_env "$f" FACTORY_ADDRESS "$FACTORY" +done + +echo "Synced deployment addresses:" +echo " TreasuryWallet: $TREASURY" +echo " SubAccountFactory: $FACTORY" diff --git a/scripts/verify-full-stack.sh b/scripts/verify-full-stack.sh new file mode 100755 index 0000000..326e8c3 --- /dev/null +++ b/scripts/verify-full-stack.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# End-to-end smoke verification for local dev stack. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +API="${NEXT_PUBLIC_API_URL:-http://localhost:3001}" +FE="${FRONTEND_URL:-http://localhost:3000}" +TREASURY="${NEXT_PUBLIC_TREASURY_WALLET_ADDRESS:-0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C}" + +echo "=== Solace Full-Stack Verification ===" +echo "" + +bash scripts/audit-env.sh +echo "" + +echo "## Type checks" +(cd frontend && pnpm exec tsc --noEmit) +echo "✅ Frontend types" + +echo "" +echo "## HTTP smoke" +curl -sf "$API/health" | grep -q '"status"' && echo "✅ GET /health" +curl -sf "$API/api/config" | grep -q '"chainId"' && echo "✅ GET /api/config" +curl -sf "$API/api/treasury?wallet=$TREASURY" | grep -q '"treasury"' && echo "✅ GET /api/treasury" +curl -sf -o /dev/null -w '' "$FE/" && echo "✅ Frontend $FE" +curl -sf -o /dev/null -w '' "$FE/settings" && echo "✅ Frontend /settings" +curl -sf -o /dev/null -w '' "$FE/approvals" && echo "✅ Frontend /approvals" +curl -sf -o /dev/null -w '' "$FE/receive" && echo "✅ Frontend /receive" + +if command -v pnpm >/dev/null && [[ -d frontend/node_modules/@playwright/test ]]; then + echo "" + echo "## E2E (optional)" + if (cd frontend && PW_SKIP_WEBSERVER=1 pnpm run test:e2e 2>&1); then + echo "✅ Playwright smoke tests" + else + echo "⚠️ Playwright tests failed (dev server may be compiling — retry with pnpm run test:e2e)" + fi +fi + +if [[ -f contracts/deployments/chain138.json ]]; then + echo "✅ chain138 deployment JSON" +fi + +echo "" +echo "=== All checks passed ==="