chore: consolidate local WIP (repo cleanup 20260707)
All checks were successful
CI / lint-and-test (push) Successful in 9m52s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-07-07 09:41:38 -07:00
parent c94eb595f8
commit a03417be98
121 changed files with 4253 additions and 1750 deletions

View File

@@ -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 **30003003** 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.

View File

@@ -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**

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 30003003 @ `.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
```

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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=

View File

@@ -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=

View File

@@ -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=

View 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");

View File

@@ -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
}
]
}

View File

@@ -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"
}
}

View File

@@ -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
View 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;
}

View File

@@ -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,

View File

@@ -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;
},
};

View 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",
});
}

View File

@@ -1,3 +1,4 @@
import "../load-env";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

View File

@@ -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
View 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);
});

View File

@@ -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 };

View File

@@ -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
View 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
View 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;
}
}
}

View File

@@ -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

View 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"
}
}

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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,

View 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);
});

View File

@@ -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) |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View 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/)"

View File

@@ -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 "=========================================="

View File

@@ -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;
}

View 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

View 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

View 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

View 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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View File

@@ -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=

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,9 @@
import { AppShell } from "@/components/layout/AppShell";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { Dashboard } from "@/components/dashboard/Dashboard";
export default function Home() {
return <Dashboard />;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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({

View File

@@ -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>
);
}

View File

@@ -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}>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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}</>;
}

View 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
View 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();
}

View File

@@ -0,0 +1,2 @@
/** Defi Oracle Meta Mainnet — treasury DApp target chain. */
export const CHAIN138_ID = 138 as const;

View 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,
};
}

View 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
View 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
View 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;
}

View 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
View 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;

View 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;

View 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;

View File

@@ -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