chore: consolidate local WIP (repo cleanup 20260707)
All checks were successful
CI / lint-and-test (push) Successful in 9m52s
All checks were successful
CI / lint-and-test (push) Successful in 9m52s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=<your_private_key_with_balance>
|
||||
|
||||
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=<deployed_address>
|
||||
NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=<deployed_address>
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=<your_project_id>
|
||||
```
|
||||
|
||||
**Backend** (`backend/.env`):
|
||||
```env
|
||||
CONTRACT_ADDRESS=<treasury_wallet_address>
|
||||
```
|
||||
|
||||
**Indexer** (`backend/.env.indexer`):
|
||||
```env
|
||||
CONTRACT_ADDRESS=<treasury_wallet_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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
20
README.md
20
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
|
||||
|
||||
|
||||
43
STATUS.md
Normal file
43
STATUS.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
11
backend/drizzle/0001_indexer_state.sql
Normal file
11
backend/drizzle/0001_indexer_state.sql
Normal file
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) : "",
|
||||
];
|
||||
|
||||
160
backend/src/api/ledger.ts
Normal file
160
backend/src/api/ledger.ts
Normal file
@@ -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<T>(path: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export async function fetchTreasuryLedger(wallet: string): Promise<LedgerEntry[]> {
|
||||
const normalized = wallet.toLowerCase();
|
||||
const entries: LedgerEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
29
backend/src/db/bootstrap.ts
Normal file
29
backend/src/db/bootstrap.ts
Normal file
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../load-env";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
18
backend/src/db/seed.ts
Normal file
18
backend/src/db/seed.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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<typeof createPublicClient>;
|
||||
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<bigint | null> {
|
||||
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);
|
||||
|
||||
13
backend/src/lib/memo.ts
Normal file
13
backend/src/lib/memo.ts
Normal file
@@ -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;
|
||||
}
|
||||
28
backend/src/load-env.ts
Normal file
28
backend/src/load-env.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
11
contracts/deployments/chain138.json
Normal file
11
contracts/deployments/chain138.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"network": "chain138",
|
||||
"chainId": 138,
|
||||
"deployedAt": "2026-05-24T04:44:02.383Z",
|
||||
"deployer": "0x4A666F96fC8764181194447A7dFdb7d471b301C8",
|
||||
"deployBlock": 5601240,
|
||||
"contracts": {
|
||||
"SubAccountFactory": "0x7fbd6714960e01502263478FBa332eBaE47883fb",
|
||||
"TreasuryWallet": "0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
59
contracts/scripts/verify-chain138.ts
Normal file
59
contracts/scripts/verify-chain138.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<deployed_address>
|
||||
NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=<deployed_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=<deployed_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=<deployed_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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
43
deployment/proxmox/deploy-nginx.sh
Executable file
43
deployment/proxmox/deploy-nginx.sh
Executable file
@@ -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/)"
|
||||
@@ -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 <<EOF
|
||||
$(cat "$SCRIPT_DIR/config/dapp.conf")
|
||||
$(grep -v '^DATABASE_PASSWORD=' "$SCRIPT_DIR/config/dapp.conf")
|
||||
DATABASE_PASSWORD=$DATABASE_PASSWORD
|
||||
PROJECT_ROOT=$DEPLOYMENT_DIR/project
|
||||
PROXMOX_STORAGE=thin1
|
||||
CONTAINER_UNPRIVILEGED=0
|
||||
CONTAINER_HOSTNAME=
|
||||
EOF
|
||||
"
|
||||
|
||||
echo "Running deployment on Proxmox host..."
|
||||
ssh "$PROXMOX_USER@$PROXMOX_HOST" "cd $DEPLOYMENT_DIR && chmod +x *.sh && sudo ./deploy-dapp.sh"
|
||||
ssh "$PROXMOX_USER@$PROXMOX_HOST" "cd $DEPLOYMENT_DIR && chmod +x *.sh && export DATABASE_PASSWORD='${DATABASE_PASSWORD}' DEPLOY_NGINX=true && ./deploy-dapp.sh"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
@@ -1,127 +1,52 @@
|
||||
# Nginx configuration for Solace Treasury DApp
|
||||
# This file should be placed at /etc/nginx/sites-available/solace-treasury
|
||||
# and symlinked to /etc/nginx/sites-enabled/
|
||||
# /etc/nginx/sites-available/solace-treasury
|
||||
|
||||
upstream frontend {
|
||||
server 192.168.11.60:3000;
|
||||
# Add more frontend instances for load balancing:
|
||||
# server 192.168.11.65:3000;
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server 192.168.11.61:3001;
|
||||
# Add more backend instances for load balancing:
|
||||
# server 192.168.11.66:3001;
|
||||
}
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=frontend_limit:10m rate=30r/s;
|
||||
|
||||
# HTTP — NPMplus edge (TLS at NPM) and LAN; no redirect to HTTPS on this listener
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# For Let's Encrypt verification
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
include /etc/nginx/snippets/solace-treasury-locations.conf;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
# HTTPS — direct LAN access (self-signed cert in /etc/nginx/ssl/)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL configuration
|
||||
# Update these paths after obtaining certificates
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# SSL settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/solace-treasury-access.log;
|
||||
error_log /var/log/nginx/solace-treasury-error.log;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=frontend_limit:10m rate=30r/s;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
limit_req zone=frontend_limit burst=20 nodelay;
|
||||
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://backend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS headers (if not handled by backend)
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
||||
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Static files (if served by Nginx)
|
||||
location /static/ {
|
||||
alias /opt/solace-frontend/.next/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
include /etc/nginx/snippets/solace-treasury-locations.conf;
|
||||
}
|
||||
|
||||
|
||||
18
deployment/proxmox/templates/solace-backend.service
Normal file
18
deployment/proxmox/templates/solace-backend.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Solace Treasury Backend API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/solace-backend
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/opt/solace-backend/.env
|
||||
ExecStart=/usr/bin/npx tsx /opt/solace-backend/src/index.ts
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
deployment/proxmox/templates/solace-frontend.service
Normal file
18
deployment/proxmox/templates/solace-frontend.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Solace Treasury Frontend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/solace-frontend
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/opt/solace-frontend/.env.production
|
||||
ExecStart=/usr/bin/npx next start -p 3000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
deployment/proxmox/templates/solace-indexer.service
Normal file
18
deployment/proxmox/templates/solace-indexer.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Solace Treasury Event Indexer
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/solace-indexer
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/opt/solace-indexer/.env.indexer
|
||||
ExecStart=/usr/bin/npx tsx /opt/solace-indexer/src/indexer/indexer.ts
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
43
deployment/proxmox/templates/solace-treasury-locations.conf
Normal file
43
deployment/proxmox/templates/solace-treasury-locations.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
# Shared locations for Solace Treasury (included from port 80 and 443 server blocks)
|
||||
|
||||
location / {
|
||||
limit_req zone=frontend_limit burst=20 nodelay;
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Preserve /api prefix (backend routes are /api/*)
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /opt/solace-frontend/.next/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
# Copy this file to .env.local for local development
|
||||
|
||||
# Chain 138 Configuration
|
||||
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
|
||||
|
||||
# Sepolia Testnet (for testing)
|
||||
@@ -13,8 +13,9 @@ NEXT_PUBLIC_SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/your_api_key
|
||||
NEXT_PUBLIC_TREASURY_WALLET_ADDRESS=
|
||||
NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=
|
||||
|
||||
# WalletConnect
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id
|
||||
# WalletConnect — free project ID at https://cloud.walletconnect.com
|
||||
# Leave empty to disable WalletConnect (Injected + MetaMask still work)
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
|
||||
# Backend API (local development)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Chain 138 Configuration
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.250:8545
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.250:8546
|
||||
# Chain 138 — public HTTPS RPC for browser clients (treasury.d-bis.org)
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
# Leave WS empty: wagmi uses HTTPS JSON-RPC (avoids mixed-content from ws:// LAN URLs)
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=
|
||||
NEXT_PUBLIC_CHAIN138_EXPLORER_URL=https://explorer.d-bis.org
|
||||
NEXT_PUBLIC_CHAIN_ID=138
|
||||
|
||||
# Contract Addresses (update after deployment)
|
||||
NEXT_PUBLIC_TREASURY_WALLET_ADDRESS=
|
||||
NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=
|
||||
NEXT_PUBLIC_TREASURY_WALLET_ADDRESS=0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C
|
||||
NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=0x7fbd6714960e01502263478FBa332eBaE47883fb
|
||||
|
||||
# WalletConnect
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=c70e1ad6e85095e75b1aac8a2793f24a
|
||||
|
||||
# Backend API
|
||||
NEXT_PUBLIC_API_URL=http://192.168.11.61:3001
|
||||
# Same-origin API via nginx /api/ proxy
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NODE_ENV=production
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Chain 138 Configuration
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.250:8545
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.250:8546
|
||||
# Chain 138 — browser clients use public HTTPS RPC; backend/indexer use LAN RPC_URL
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=
|
||||
NEXT_PUBLIC_CHAIN138_EXPLORER_URL=https://explorer.d-bis.org
|
||||
NEXT_PUBLIC_CHAIN_ID=138
|
||||
|
||||
# Contract Addresses (update after deployment)
|
||||
@@ -10,5 +11,5 @@ NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=
|
||||
# WalletConnect
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id
|
||||
|
||||
# Backend API
|
||||
NEXT_PUBLIC_API_URL=http://192.168.11.61:3001
|
||||
# Empty = same-origin /api/ via nginx; set only for direct backend access in dev
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Chain 138 Configuration
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=http://192.168.11.250:8545
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=ws://192.168.11.250:8546
|
||||
# Chain 138 — public HTTPS RPC for browser; backend uses LAN RPC_URL
|
||||
NEXT_PUBLIC_CHAIN138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
NEXT_PUBLIC_CHAIN138_WS_URL=
|
||||
NEXT_PUBLIC_CHAIN138_EXPLORER_URL=https://explorer.d-bis.org
|
||||
NEXT_PUBLIC_CHAIN_ID=138
|
||||
|
||||
# Contract Addresses (update after deployment)
|
||||
@@ -10,5 +11,5 @@ NEXT_PUBLIC_SUB_ACCOUNT_FACTORY_ADDRESS=
|
||||
# WalletConnect
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id
|
||||
|
||||
# Backend API
|
||||
NEXT_PUBLIC_API_URL=http://192.168.11.61:3001
|
||||
# Empty = same-origin /api/ via nginx
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
246
frontend/app/(dashboard)/activity/page.tsx
Normal file
246
frontend/app/(dashboard)/activity/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { WalletGate } from "@/components/web3/WalletGate";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { formatAddress } from "@/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
import { useTreasury } from "@/lib/hooks/useTreasury";
|
||||
import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
|
||||
import {
|
||||
exportTransactionsCsv,
|
||||
fetchLedger,
|
||||
fetchLedgerByWallet,
|
||||
fetchTransactions,
|
||||
type LedgerEntry,
|
||||
type TransactionRecord,
|
||||
} from "@/lib/api/client";
|
||||
import { decodeMemoData } from "@/lib/memo";
|
||||
import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens";
|
||||
import { CHAIN138_PUBLIC } from "@/lib/web3/chain138-public";
|
||||
|
||||
type ActivityFilter = "all" | "deposits" | "pending" | "executed";
|
||||
|
||||
export default function ActivityPage() {
|
||||
return (
|
||||
<WalletGate
|
||||
title="Transaction History"
|
||||
description="Connect your wallet to browse treasury activity and export reports."
|
||||
>
|
||||
<ActivityContent />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityContent() {
|
||||
const { treasuryId, loading: treasuryLoading } = useTreasury();
|
||||
const [filter, setFilter] = useState<ActivityFilter>("all");
|
||||
const [proposals, setProposals] = useState<TransactionRecord[]>([]);
|
||||
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PageHeader
|
||||
title="Transaction History"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={!treasuryId}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6">
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
{(["all", "deposits", "pending", "executed"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setFilter(status)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||
filter === status
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{status === "all"
|
||||
? "All"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{treasuryLoading || loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading transactions...</div>
|
||||
) : isEmpty ? (
|
||||
<div className="text-center py-12 text-gray-400">No transactions found</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{showDeposits &&
|
||||
deposits.map((entry) => (
|
||||
<LedgerRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
{showProposals &&
|
||||
filteredProposals.map((tx) => (
|
||||
<ProposalRow key={tx.id} tx={tx} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Type</p>
|
||||
<span className="inline-block px-3 py-1 rounded text-sm font-semibold bg-emerald-900/30 text-emerald-400">
|
||||
{entry.kind === "deposit" ? "Deposit" : "Transfer out"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">From</p>
|
||||
<p className="font-mono text-sm">{formatAddress(entry.from)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Amount</p>
|
||||
<p className="font-semibold">{amountLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Status</p>
|
||||
<span className="inline-block px-3 py-1 rounded text-sm font-semibold bg-green-900/30 text-green-400">
|
||||
confirmed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-700 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
{format(new Date(entry.timestamp), "MMM dd, yyyy HH:mm:ss")} · block{" "}
|
||||
{entry.blockNumber}
|
||||
</p>
|
||||
<a
|
||||
href={`${CHAIN138_PUBLIC.explorer}/tx/${entry.txHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
View on explorer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalRow({ tx }: { tx: TransactionRecord }) {
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Proposal ID</p>
|
||||
<p className="font-mono">#{tx.proposalId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">To</p>
|
||||
<p className="font-mono text-sm">{formatAddress(tx.to)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Amount</p>
|
||||
<p className="font-semibold">
|
||||
{formatTokenAmount(tx.value, getTokenByAddress(tx.token))}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Status</p>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded text-sm font-semibold ${
|
||||
tx.status === "executed"
|
||||
? "bg-green-900/30 text-green-400"
|
||||
: tx.status === "pending"
|
||||
? "bg-yellow-900/30 text-yellow-400"
|
||||
: "bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-700 space-y-1">
|
||||
<p className="text-sm text-gray-400">
|
||||
Created: {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm:ss")}
|
||||
</p>
|
||||
{decodeMemoData(tx.data) && (
|
||||
<p className="text-sm text-gray-300">Memo: {decodeMemoData(tx.data)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WalletGate
|
||||
title="Pending Approvals"
|
||||
description="Connect your wallet to review and sign multisig treasury proposals."
|
||||
>
|
||||
<ApprovalsContent />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalsContent() {
|
||||
const { treasuryId, loading: treasuryLoading } = useTreasury();
|
||||
const { writeContract } = useWriteContract();
|
||||
const [proposals, setProposals] = useState<TransactionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// TODO: Fetch pending proposals from contract/backend
|
||||
const [proposals] = useState<Proposal[]>([]);
|
||||
const { data: threshold } = useReadContract({
|
||||
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
|
||||
abi: TREASURY_WALLET_ABI,
|
||||
functionName: "threshold",
|
||||
});
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Pending Approvals</h1>
|
||||
<WalletConnect />
|
||||
</header>
|
||||
const requiredThreshold = threshold ? Number(threshold) : 1;
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-8">
|
||||
{proposals.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
No pending transactions requiring approval
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{proposals.map((proposal) => (
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<PageHeader title="Pending Approvals" />
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 sm:p-8">
|
||||
{treasuryLoading || loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading proposals...</div>
|
||||
) : proposals.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
No pending transactions requiring approval
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{proposals.map((proposal) => {
|
||||
const approvalCount = proposal.approvalCount ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={proposal.id}
|
||||
className="bg-gray-800 rounded-lg p-6 border border-gray-700"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-start">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Proposal #{proposal.id}
|
||||
Proposal #{proposal.proposalId}
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-gray-400">
|
||||
<div>
|
||||
@@ -86,25 +102,27 @@ export default function ApprovalsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Amount:</span>{" "}
|
||||
{formatEther(proposal.value)} ETH
|
||||
{formatTokenAmount(proposal.value, getTokenByAddress(proposal.token))}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Approvals:</span>{" "}
|
||||
{proposal.approvalCount} / {proposal.threshold}
|
||||
{approvalCount} / {requiredThreshold}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{proposal.approvalCount >= proposal.threshold ? (
|
||||
{approvalCount >= requiredThreshold ? (
|
||||
<button
|
||||
onClick={() => handleExecute(proposal.id)}
|
||||
type="button"
|
||||
onClick={() => handleExecute(proposal.proposalId)}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleApprove(proposal.id)}
|
||||
type="button"
|
||||
onClick={() => handleApprove(proposal.proposalId)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Approve
|
||||
@@ -113,12 +131,11 @@ export default function ApprovalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
frontend/app/(dashboard)/layout.tsx
Normal file
9
frontend/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
7
frontend/app/(dashboard)/page.tsx
Normal file
7
frontend/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||
|
||||
export default function Home() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
67
frontend/app/(dashboard)/receive/page.tsx
Normal file
67
frontend/app/(dashboard)/receive/page.tsx
Normal file
@@ -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 (
|
||||
<WalletGate
|
||||
title="Receive Funds"
|
||||
description="Connect your wallet to view the treasury deposit address and QR code."
|
||||
>
|
||||
<ReceiveContent address={treasuryAddress} />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiveContent({ address }: { address: string }) {
|
||||
if (!address) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center text-gray-400 py-12">
|
||||
Treasury wallet address not configured.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<PageHeader title="Receive Funds" />
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 sm:p-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">Treasury Deposit Address</h3>
|
||||
<div className="bg-gray-800 rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<span className="font-mono text-sm break-all">{address}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(address)}
|
||||
className="shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-2">{formatAddress(address)} on Chain 138</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<QRCodeSVG value={address} size={256} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">Network Warning</p>
|
||||
<p className="text-sm text-yellow-300/70">
|
||||
Send funds to this address on <strong>Solace Chain 138</strong> (Chain ID: 138).
|
||||
Sending on the wrong network may result in permanent loss.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
frontend/app/(dashboard)/send/page.tsx
Normal file
171
frontend/app/(dashboard)/send/page.tsx
Normal file
@@ -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 (
|
||||
<WalletGate
|
||||
title="Send Payment"
|
||||
description="Connect your wallet to propose outbound treasury payments."
|
||||
>
|
||||
<SendForm />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<PageHeader title="Send Payment" />
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 sm:p-8 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Token</label>
|
||||
<select
|
||||
value={selectedToken.symbol}
|
||||
onChange={(e) => {
|
||||
const token = CHAIN138_TOKENS.find((t) => t.symbol === e.target.value);
|
||||
if (token) setSelectedToken(token);
|
||||
}}
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
{CHAIN138_TOKENS.map((token) => (
|
||||
<option key={token.symbol} value={token.symbol}>
|
||||
{token.name} ({token.symbol})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Recipient Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full bg-gray-800 rounded-lg p-3 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Amount ({selectedToken.symbol})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
{balanceLabel && (
|
||||
<p className="text-sm text-gray-400 mt-1">Treasury balance: {balanceLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Memo (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Payment reference..."
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !recipient || !amount}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
{loading ? "Processing..." : "Send Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<WalletGate
|
||||
title="Treasury Settings"
|
||||
description="Connect your wallet to manage multisig signers and approval thresholds."
|
||||
>
|
||||
<SettingsContent />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddSigner = async () => {
|
||||
if (!isAddress(newSigner) || !CONTRACT_ADDRESSES.TreasuryWallet) return;
|
||||
@@ -78,14 +80,10 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Treasury Settings</h1>
|
||||
<WalletConnect />
|
||||
</header>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<PageHeader title="Treasury Settings" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Current Configuration */}
|
||||
<div className="bg-gray-900 rounded-xl p-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">Current Configuration</h2>
|
||||
@@ -163,6 +161,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-Accounts */}
|
||||
<SubAccountsSettings />
|
||||
|
||||
{/* Change Threshold */}
|
||||
<div className="bg-gray-900 rounded-xl p-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">Change Threshold</h2>
|
||||
@@ -187,7 +188,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
159
frontend/app/(dashboard)/transfer/page.tsx
Normal file
159
frontend/app/(dashboard)/transfer/page.tsx
Normal file
@@ -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 (
|
||||
<WalletGate
|
||||
title="Internal Transfer"
|
||||
description="Connect your wallet to move funds between treasury accounts."
|
||||
>
|
||||
<TransferForm />
|
||||
</WalletGate>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<PageHeader title="Internal Transfer" />
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 sm:p-8 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">From Account</label>
|
||||
<select
|
||||
value={fromAccount}
|
||||
onChange={(e) => setFromAccount(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<option value="main">Main Treasury</option>
|
||||
{subAccounts.map((account) => (
|
||||
<option key={account} value={account}>
|
||||
{account.slice(0, 10)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">To Account</label>
|
||||
<select
|
||||
value={toAccount}
|
||||
onChange={(e) => setToAccount(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<option value="">Select destination...</option>
|
||||
{fromAccount !== "main" && address && (
|
||||
<option value={CONTRACT_ADDRESSES.TreasuryWallet}>Main Treasury</option>
|
||||
)}
|
||||
{subAccounts
|
||||
.filter((acc) => acc !== fromAccount)
|
||||
.map((account) => (
|
||||
<option key={account} value={account}>
|
||||
{account.slice(0, 10)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Amount ({balance?.symbol || "ETH"})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
{balance && (
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Treasury balance: {balance.formatted} {balance.symbol}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTransfer}
|
||||
disabled={loading || !toAccount || !amount}
|
||||
className="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
{loading ? "Processing..." : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Transaction History</h1>
|
||||
<div className="flex gap-4 items-center">
|
||||
<WalletConnect />
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
{(["all", "pending", "executed"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
filter === status
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Transaction List */}
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
No transactions found
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="bg-gray-800 rounded-lg p-6 border border-gray-700"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Proposal ID</div>
|
||||
<div className="font-mono">#{tx.proposalId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">To</div>
|
||||
<div className="font-mono text-sm">{formatAddress(tx.to)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Amount</div>
|
||||
<div className="font-semibold">{formatEther(BigInt(tx.value))} ETH</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">Status</div>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded text-sm font-semibold ${
|
||||
tx.status === "executed"
|
||||
? "bg-green-900/30 text-green-400"
|
||||
: tx.status === "pending"
|
||||
? "bg-yellow-900/30 text-yellow-400"
|
||||
: "bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="text-sm text-gray-400">
|
||||
Created: {format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm:ss")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-gray-900 border-b border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-8 py-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Solace Treasury Management</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Navigation />
|
||||
<main className="p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Dashboard />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<WagmiProvider config={config}>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const networkName =
|
||||
chainId === 1
|
||||
? "Ethereum Mainnet"
|
||||
: chainId === 11155111
|
||||
? "Sepolia Testnet"
|
||||
: chainId === 138
|
||||
? "Solace Chain 138"
|
||||
: "Unknown Network";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Receive Funds</h1>
|
||||
<WalletConnect />
|
||||
</header>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-8 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Deposit Address</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-4 flex items-center justify-between">
|
||||
<span className="font-mono text-sm break-all">{address}</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(address)}
|
||||
className="ml-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<QRCodeSVG value={address} size={256} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">Network Warning</p>
|
||||
<p className="text-sm text-yellow-300/70">
|
||||
Make sure you are sending funds on <strong>{networkName}</strong> (Chain ID: {chainId}).
|
||||
Sending funds on the wrong network may result in permanent loss.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Send Payment</h1>
|
||||
<WalletConnect />
|
||||
</header>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-8 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Recipient Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full bg-gray-800 rounded-lg p-3 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Amount ({balance?.symbol || "ETH"})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
{balance && (
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Available: {balance.formatted} {balance.symbol}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Memo (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Payment reference..."
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !recipient || !amount}
|
||||
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
{loading ? "Processing..." : "Send Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Internal Transfer</h1>
|
||||
<WalletConnect />
|
||||
</header>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-8 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">From Account</label>
|
||||
<select
|
||||
value={fromAccount}
|
||||
onChange={(e) => setFromAccount(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<option value="main">Main Treasury</option>
|
||||
{subAccounts.map((account) => (
|
||||
<option key={account} value={account}>
|
||||
{account.slice(0, 10)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">To Account</label>
|
||||
<select
|
||||
value={toAccount}
|
||||
onChange={(e) => setToAccount(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<option value="">Select destination...</option>
|
||||
{fromAccount !== "main" && <option value={address}>Main Treasury</option>}
|
||||
{subAccounts
|
||||
.filter((acc) => acc !== fromAccount)
|
||||
.map((account) => (
|
||||
<option key={account} value={account}>
|
||||
{account.slice(0, 10)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Amount ({balance?.symbol || "ETH"})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
className="w-full bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
{balance && (
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Available: {balance.formatted} {balance.symbol}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleTransfer}
|
||||
disabled={loading || !toAccount || !amount}
|
||||
className="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
|
||||
>
|
||||
{loading ? "Processing..." : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="bg-gray-900 rounded-xl p-8 h-64 flex items-center justify-center">
|
||||
<div className="text-gray-400">Loading balance...</div>
|
||||
<p className="text-gray-400">Loading treasury balance…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const balanceValue = balance?.value || BigInt(0);
|
||||
const formattedBalance = formatBalance(balanceValue);
|
||||
if (!treasuryAddress) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl p-8 h-64 flex items-center justify-center">
|
||||
<p className="text-gray-400">Treasury wallet not configured</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | null>((sum, v) => {
|
||||
if (v == null) return sum;
|
||||
return (sum ?? 0) + v;
|
||||
}, null) ?? 0;
|
||||
|
||||
const formattedNative = nativeBalance?.formatted ?? "0";
|
||||
|
||||
return (
|
||||
<div ref={displayRef} className="relative bg-gray-900 rounded-xl p-8 overflow-hidden border border-gray-800">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-purple-500/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(59,130,246,0.1),transparent_50%)]" />
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-2xl font-semibold mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
Total Balance
|
||||
</h2>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="text-5xl font-bold mb-2 bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
||||
{formattedBalance}
|
||||
</div>
|
||||
<div className="text-gray-400">{balance?.symbol || "ETH"}</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
Treasury Balance
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono">
|
||||
Multisig · {formatAddress(treasuryAddress)} · Chain {CHAIN138_ID}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-64 h-64">
|
||||
<Canvas camera={{ position: [0, 0, 5] }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.5} />
|
||||
<pointLight position={[-10, -10, -10]} intensity={0.5} color="#8b5cf6" />
|
||||
<mesh>
|
||||
<torusGeometry args={[1, 0.3, 16, 100]} />
|
||||
<meshStandardMaterial
|
||||
color="#3b82f6"
|
||||
metalness={0.8}
|
||||
roughness={0.2}
|
||||
emissive="#1e40af"
|
||||
emissiveIntensity={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
<OrbitControls enableZoom={false} autoRotate autoRotateSpeed={2} />
|
||||
</Canvas>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Estimated total</p>
|
||||
<p className="text-xl font-bold text-white tabular-nums">{formatUsd(totalUsd)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-5xl font-bold mb-1 bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
||||
{formattedNative}
|
||||
</p>
|
||||
<p className="text-gray-400">{nativeBalance?.symbol ?? "ETH"}</p>
|
||||
</div>
|
||||
<p className="text-lg text-emerald-400 tabular-nums pb-1">{formatUsd(ethUsdVal)}</p>
|
||||
</div>
|
||||
{cusdtRaw !== undefined && (
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="text-gray-400">
|
||||
{formatTokenAmount(cusdtRaw.toString(), erc20Tokens[0])}
|
||||
</span>
|
||||
<span className="text-emerald-400 tabular-nums">{formatUsd(cusdtUsd)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cusdcRaw !== undefined && (
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="text-gray-400">
|
||||
{formatTokenAmount(cusdcRaw.toString(), erc20Tokens[1])}
|
||||
</span>
|
||||
<span className="text-emerald-400 tabular-nums">{formatUsd(cusdcUsd)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-64 h-64 hidden sm:flex items-center justify-center">
|
||||
<div
|
||||
className="relative w-40 h-40 rounded-full border-2 border-blue-500/40 shadow-[0_0_40px_rgba(59,130,246,0.25)] animate-[spin_24s_linear_infinite]"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="absolute inset-3 rounded-full border border-purple-500/30" />
|
||||
<div className="absolute inset-8 rounded-full bg-gradient-to-br from-blue-500/20 to-purple-500/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ export function Dashboard() {
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<h2 className="text-2xl mb-4">Please connect your wallet to continue</h2>
|
||||
<p className="text-gray-400">
|
||||
<div className="max-w-2xl mx-auto text-center py-12 sm:py-20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3">
|
||||
Please connect your wallet to continue
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-md mx-auto">
|
||||
Connect your Web3 wallet to access the treasury management dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Transaction[]>(() => [], []); // Placeholder
|
||||
const { treasuryId } = useTreasury();
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Recent Activity</h2>
|
||||
<div className="bg-gray-900 rounded-xl p-6">
|
||||
{transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No recent transactions
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">No recent transactions</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="space-y-4">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-sm font-mono text-gray-400">
|
||||
#{tx.proposalId}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
tx.status === "executed"
|
||||
? "bg-green-900/30 text-green-400"
|
||||
: tx.status === "pending"
|
||||
? "bg-yellow-900/30 text-yellow-400"
|
||||
: "bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">To:</span> {formatAddress(tx.to)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">{tx.value} ETH</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.kind === "ledger" ? (
|
||||
<LedgerActivityRow key={item.entry.id} entry={item.entry} />
|
||||
) : (
|
||||
<ProposalActivityRow key={item.tx.id} tx={item.tx} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerActivityRow({ entry }: { entry: LedgerEntry }) {
|
||||
const token = getTokenByAddress(entry.token);
|
||||
const symbol = entry.tokenSymbol ?? token?.symbol ?? "ETH";
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-sm font-mono text-gray-400">Deposit</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-semibold bg-emerald-900/30 text-emerald-400">
|
||||
confirmed
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">From:</span> {formatAddress(entry.from)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{format(new Date(entry.timestamp), "MMM dd, yyyy HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">
|
||||
{formatTokenAmount(
|
||||
entry.value,
|
||||
token ?? {
|
||||
symbol,
|
||||
decimals: symbol === "ETH" ? 18 : 6,
|
||||
name: symbol,
|
||||
address: "native",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalActivityRow({ tx }: { tx: TransactionRecord }) {
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-sm font-mono text-gray-400">#{tx.proposalId}</span>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
tx.status === "executed"
|
||||
? "bg-green-900/30 text-green-400"
|
||||
: tx.status === "pending"
|
||||
? "bg-yellow-900/30 text-yellow-400"
|
||||
: "bg-red-900/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">To:</span> {formatAddress(tx.to)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{format(new Date(tx.createdAt), "MMM dd, yyyy HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">
|
||||
{formatTokenAmount(tx.value, getTokenByAddress(tx.token))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
frontend/components/layout/AppShell.tsx
Normal file
21
frontend/components/layout/AppShell.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="bg-gray-900 border-b border-gray-800 shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-8 py-4 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">Solace Treasury Management</h1>
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</header>
|
||||
<Navigation />
|
||||
<main className="flex-1 p-4 sm:p-8">
|
||||
<div className="max-w-7xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"px-4 py-3 sm:py-4 text-sm font-medium transition-colors border-b-2 whitespace-nowrap",
|
||||
isActive
|
||||
? "border-blue-500 text-blue-400 bg-blue-950/30 sm:bg-transparent"
|
||||
: "border-transparent text-gray-400 hover:text-gray-200 hover:border-gray-700 hover:bg-gray-800/50 sm:hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const closeMobile = () => setMobileOpen(false);
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-900 border-b border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex items-center gap-8">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
<nav
|
||||
className="bg-gray-900 border-b border-gray-800 shrink-0"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-8">
|
||||
{/* Mobile: menu toggle + horizontal scroll fallback on sm */}
|
||||
<div className="flex items-center justify-between sm:hidden py-2">
|
||||
<span className="text-sm text-gray-400">
|
||||
{navItems.find((item) => item.href === pathname)?.label ?? "Menu"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-200 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-menu"
|
||||
>
|
||||
{mobileOpen ? "Close" : "Menu"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden sm:flex items-center gap-2 lg:gap-4 overflow-x-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
isActive={pathname === item.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
id="mobile-nav-menu"
|
||||
className="sm:hidden flex flex-col border-t border-gray-800"
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"px-4 py-4 text-sm font-medium transition-colors border-b-2",
|
||||
isActive
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-gray-400 hover:text-gray-200 hover:border-gray-700"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
label={item.label}
|
||||
isActive={pathname === item.href}
|
||||
onNavigate={closeMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
13
frontend/components/layout/PageHeader.tsx
Normal file
13
frontend/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center mb-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold">{title}</h2>
|
||||
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
124
frontend/components/settings/SubAccountsSettings.tsx
Normal file
124
frontend/components/settings/SubAccountsSettings.tsx
Normal file
@@ -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<SubAccountRecord[]>([]);
|
||||
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 (
|
||||
<div className="bg-gray-900 rounded-xl p-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">Sub-Accounts</h2>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Create child treasury wallets that inherit the main multisig configuration.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
className="flex-1 bg-gray-800 rounded-lg p-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={loading || !treasuryId}
|
||||
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Sub-Account"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-600 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">Registered accounts</h3>
|
||||
{subAccounts.length === 0 && chainList.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No sub-accounts yet</p>
|
||||
) : (
|
||||
[...new Set([
|
||||
...subAccounts.map((a) => a.address),
|
||||
...chainList.map((a) => a.toLowerCase()),
|
||||
])].map((address) => (
|
||||
<div
|
||||
key={address}
|
||||
className="bg-gray-800 rounded-lg p-3 font-mono text-sm flex justify-between items-center"
|
||||
>
|
||||
<span>{formatAddress(address)}</span>
|
||||
{subAccounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{subAccounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<THREE.Points>(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 (
|
||||
<group rotation={[0, 0, Math.PI / 4]}>
|
||||
<Points ref={ref} positions={positions} stride={3} frustumCulled={false}>
|
||||
<PointMaterial
|
||||
transparent
|
||||
color="#3b82f6"
|
||||
size={0.005}
|
||||
sizeAttenuation={true}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</Points>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="fixed inset-0 -z-10 opacity-30 pointer-events-none">
|
||||
<Canvas camera={{ position: [0, 0, 1] }}>
|
||||
<ParticleField />
|
||||
</Canvas>
|
||||
<div
|
||||
className="fixed inset-0 -z-10 pointer-events-none overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-blue-950/40 via-gray-950 to-gray-950 opacity-30" />
|
||||
{enabled && (
|
||||
<div className="absolute inset-0 opacity-20 animate-[particle-drift_120s_linear_infinite] bg-[radial-gradient(circle_at_20%_30%,rgba(59,130,246,0.35)_0%,transparent_45%),radial-gradient(circle_at_80%_70%,rgba(99,102,241,0.25)_0%,transparent_40%),radial-gradient(circle_at_50%_50%,rgba(59,130,246,0.15)_0%,transparent_55%)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
frontend/components/web3/WalletBalancePanel.tsx
Normal file
132
frontend/components/web3/WalletBalancePanel.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-3 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">{label}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{loading ? "Loading…" : amount}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-400 tabular-nums">
|
||||
{loading ? "…" : formatUsd(usd)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | null>((sum, v) => {
|
||||
if (v == null) return sum;
|
||||
return (sum ?? 0) + v;
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full sm:w-80">
|
||||
<div className="mb-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">Connected wallet</p>
|
||||
<p className="font-mono text-sm text-white mt-0.5">{formatAddress(address)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Chain {CHAIN138_ID} balances</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-950/80 border border-gray-800 px-3">
|
||||
<BalanceRow
|
||||
label="ETH"
|
||||
amount={ethRaw !== undefined ? `${nativeBalance?.formatted ?? "0"} ETH` : "0 ETH"}
|
||||
usd={ethUsdVal}
|
||||
loading={loading}
|
||||
/>
|
||||
<BalanceRow
|
||||
label="cUSDT"
|
||||
amount={
|
||||
cusdtRaw !== undefined
|
||||
? formatTokenAmount(cusdtRaw.toString(), erc20Tokens[0])
|
||||
: "0 cUSDT"
|
||||
}
|
||||
usd={cusdtUsd}
|
||||
loading={loading}
|
||||
/>
|
||||
<BalanceRow
|
||||
label="cUSDC"
|
||||
amount={
|
||||
cusdcRaw !== undefined
|
||||
? formatTokenAmount(cusdcRaw.toString(), erc20Tokens[1])
|
||||
: "0 cUSDC"
|
||||
}
|
||||
usd={cusdcUsd}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-800">
|
||||
<span className="text-xs text-gray-500">Estimated total</span>
|
||||
<span className="text-base font-bold text-white tabular-nums">
|
||||
{loading ? "…" : formatUsd(totalUsd)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="flex-1 px-3 py-2 text-xs bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{onDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
className="flex-1 px-3 py-2 text-xs bg-red-600/90 hover:bg-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-400">Connected:</span>{" "}
|
||||
<span className="font-mono">{formatAddress(address)}</span>
|
||||
</div>
|
||||
<div ref={menuRef} className="relative w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => disconnect()}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
className="flex flex-wrap items-center gap-2 text-sm rounded-lg px-3 py-2 bg-gray-800/80 hover:bg-gray-800 border border-gray-700 transition-colors w-full sm:w-auto"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Disconnect
|
||||
<span className="text-gray-400">Connected:</span>
|
||||
<span className="font-mono text-white">{formatAddress(address)}</span>
|
||||
<span className="text-gray-500 text-xs" aria-hidden>
|
||||
{menuOpen ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Wallet balances"
|
||||
className="absolute right-0 mt-2 bg-gray-900 border border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden"
|
||||
>
|
||||
<WalletBalancePanel
|
||||
address={address}
|
||||
onDisconnect={() => {
|
||||
disconnect();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{connectors.map((connector) => (
|
||||
<button
|
||||
key={connector.uid}
|
||||
onClick={() => connect({ connector })}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
<div ref={menuRef} className="relative w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={isPending || availableConnectors.length === 0}
|
||||
className="w-full sm:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors text-sm font-medium"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{isPending ? "Connecting…" : "Connect Wallet"}
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 mt-2 w-full sm:w-56 bg-gray-900 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden"
|
||||
>
|
||||
Connect {connector.name}
|
||||
</button>
|
||||
))}
|
||||
{availableConnectors.map((connector) => (
|
||||
<button
|
||||
key={connector.uid}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
connect({ connector });
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-800 transition-colors border-b border-gray-800 last:border-b-0"
|
||||
>
|
||||
{connector.name}
|
||||
</button>
|
||||
))}
|
||||
{!walletConnectConfigured && (
|
||||
<p className="px-4 py-2 text-xs text-gray-500 border-t border-gray-800">
|
||||
WalletConnect disabled — set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
33
frontend/components/web3/WalletGate.tsx
Normal file
33
frontend/components/web3/WalletGate.tsx
Normal file
@@ -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 (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center py-12 sm:py-20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3">{title}</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-md mx-auto">
|
||||
{description ??
|
||||
"Connect your Web3 wallet to access this treasury feature."}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
32
frontend/e2e/smoke.spec.ts
Normal file
32
frontend/e2e/smoke.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
133
frontend/lib/api/client.ts
Normal file
133
frontend/lib/api/client.ts
Normal file
@@ -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<T>(path: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export async function fetchTreasury(wallet: string): Promise<TreasuryRecord | null> {
|
||||
const data = await apiFetch<{ treasury: TreasuryRecord | null }>(
|
||||
`/api/treasury?wallet=${encodeURIComponent(wallet)}`
|
||||
);
|
||||
return data.treasury;
|
||||
}
|
||||
|
||||
export async function fetchTransactions(
|
||||
treasuryId: string,
|
||||
status?: string
|
||||
): Promise<TransactionRecord[]> {
|
||||
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<TransactionRecord[]> {
|
||||
const data = await apiFetch<{ proposals: TransactionRecord[] }>(
|
||||
`/api/proposals/${treasuryId}?status=pending&includeApprovals=1`
|
||||
);
|
||||
return data.proposals;
|
||||
}
|
||||
|
||||
export async function fetchSubAccounts(treasuryId: string): Promise<SubAccountRecord[]> {
|
||||
const data = await apiFetch<{ subAccounts: SubAccountRecord[] }>(
|
||||
`/api/treasury/${treasuryId}/sub-accounts`
|
||||
);
|
||||
return data.subAccounts;
|
||||
}
|
||||
|
||||
export async function fetchLedger(treasuryId: string): Promise<LedgerEntry[]> {
|
||||
const data = await apiFetch<{ ledger: LedgerEntry[] }>(
|
||||
`/api/treasury/${treasuryId}/ledger`
|
||||
);
|
||||
return data.ledger;
|
||||
}
|
||||
|
||||
export async function fetchLedgerByWallet(wallet: string): Promise<LedgerEntry[]> {
|
||||
const data = await apiFetch<{ ledger: LedgerEntry[] }>(
|
||||
`/api/ledger?wallet=${encodeURIComponent(wallet)}`
|
||||
);
|
||||
return data.ledger;
|
||||
}
|
||||
|
||||
export async function exportTransactionsCsv(treasuryId: string): Promise<string> {
|
||||
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();
|
||||
}
|
||||
2
frontend/lib/constants/chain138.ts
Normal file
2
frontend/lib/constants/chain138.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Defi Oracle Meta Mainnet — treasury DApp target chain. */
|
||||
export const CHAIN138_ID = 138 as const;
|
||||
55
frontend/lib/hooks/useTokenBalances.ts
Normal file
55
frontend/lib/hooks/useTokenBalances.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
34
frontend/lib/hooks/useTreasury.ts
Normal file
34
frontend/lib/hooks/useTreasury.ts
Normal file
@@ -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<TreasuryRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
20
frontend/lib/memo.ts
Normal file
20
frontend/lib/memo.ts
Normal file
@@ -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;
|
||||
}
|
||||
68
frontend/lib/prices.ts
Normal file
68
frontend/lib/prices.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
const STABLE_USD = 1;
|
||||
|
||||
async function fetchEthUsd(): Promise<number> {
|
||||
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;
|
||||
}
|
||||
8
frontend/lib/stubs/async-storage.js
Normal file
8
frontend/lib/stubs/async-storage.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const asyncStorage = {
|
||||
getItem: async () => null,
|
||||
setItem: async () => {},
|
||||
removeItem: async () => {},
|
||||
};
|
||||
|
||||
module.exports = asyncStorage;
|
||||
module.exports.default = asyncStorage;
|
||||
59
frontend/lib/tokens.ts
Normal file
59
frontend/lib/tokens.ts
Normal file
@@ -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;
|
||||
6
frontend/lib/web3/chain138-lan.dev.ts
Normal file
6
frontend/lib/web3/chain138-lan.dev.ts
Normal file
@@ -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;
|
||||
6
frontend/lib/web3/chain138-public.ts
Normal file
6
frontend/lib/web3/chain138-public.ts
Normal file
@@ -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;
|
||||
@@ -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<typeof getConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user