diff --git a/.env.example b/.env.example index d038f5b..6c10224 100644 --- a/.env.example +++ b/.env.example @@ -89,7 +89,7 @@ NEXT_PUBLIC_POOLED_LENDING_CONTRACT_ID= NEXT_PUBLIC_GOVERNANCE_CONTRACT_ID= # N-of-M multisig gating rare, high-impact admin config changes (issue #73): # whitelisting collateral assets, fee tables, linking governance/oracle, and -# moving insurance-fund balances. See contracts/MULTISIG_ADMIN.md. +# moving insurance-fund balances. See docs/contracts/multisig-admin.md. NEXT_PUBLIC_MULTISIG_ADMIN_CONTRACT_ID= # TLEND governance token and its distribution contracts (issue #107). Optional — @@ -98,24 +98,6 @@ NEXT_PUBLIC_TLEND_TOKEN_CONTRACT_ID= NEXT_PUBLIC_TLEND_VESTING_CONTRACT_ID= NEXT_PUBLIC_TLEND_AIRDROP_CONTRACT_ID= -# Soroban event indexer read models -# Point these at a Mercury, Ensorcel, or custom subgraph/indexer deployment. -# During migration the app tries the indexer first and falls back to Supabase. -# Set TRUSTLEND_INDEXER_READ_MODE=required only after the indexer is fully backfilled. -TRUSTLEND_INDEXER_READ_MODE=fallback -TRUSTLEND_INDEXER_GRAPHQL_URL= -TRUSTLEND_INDEXER_REST_URL= -TRUSTLEND_INDEXER_API_KEY= - -# Optional: override the default GraphQL documents if your indexer schema uses -# different collection/filter names. The app falls back to REST when only -# TRUSTLEND_INDEXER_REST_URL is configured. -TRUSTLEND_INDEXER_BORROWER_LOANS_QUERY= -TRUSTLEND_INDEXER_LENDER_LOANS_QUERY= -TRUSTLEND_INDEXER_ADMIN_LOANS_QUERY= -TRUSTLEND_INDEXER_REPUTATION_EVENTS_QUERY= -TRUSTLEND_INDEXER_ESCROW_EVENTS_QUERY= - # ── Admin Stellar Address ───────────────────────────────────────────────────── # This is the public G... address of the account that has administrative # privileges on the contracts (e.g., freezing accounts, triggering payouts). @@ -303,7 +285,7 @@ ORACLE_DISCORD_WEBHOOK_URL= # Backup" GitHub Actions workflow (.github/workflows/db-backup.yml). # # In CI these are repository *secrets*, not values in this file. Restore -# instructions and bucket setup live in DISASTER_RECOVERY.md. +# instructions and bucket setup live in docs/disaster-recovery.md. # # Direct Postgres connection string. Use the DIRECT connection (port 5432), not # the pooled/pgbouncer one — pg_dump needs session-level features the pooler @@ -318,7 +300,7 @@ BACKUP_ENCRYPTION_KEY= # # Destination bucket name, without the s3:// prefix. Should live in a different # account or at least a different region from the database, and have versioning -# plus a restrictive bucket policy. See DISASTER_RECOVERY.md. +# plus a restrictive bucket policy. See docs/disaster-recovery.md. S3_BUCKET= # # Optional. Key prefix within the bucket (default: backups). diff --git a/.github/workflows/db-backup.yml b/.github/workflows/db-backup.yml index 0b2144e..87fddb8 100644 --- a/.github/workflows/db-backup.yml +++ b/.github/workflows/db-backup.yml @@ -83,7 +83,7 @@ jobs: `Run: ${runUrl}`, '', 'The database is running without a fresh off-site backup until this is fixed.', - 'See DISASTER_RECOVERY.md for the runbook.', + 'See docs/disaster-recovery.md for the runbook.', ].join('\n'); // Reuse an existing open report rather than filing one per night. diff --git a/.gitignore b/.gitignore index ece2c5e..3a813a4 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,6 @@ lib/contracts/generated/ # Per-network deployment state used by `npm run deploy:testnet -- --resume` contracts/.deployments/ -/docs # playwright playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 571519f..d1c42c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rejection, and pushed on-chain. Fallback chain is live → cached → on-chain TWAP → refuse to publish. The liquidation keeper now values positions with the live price instead of a hardcoded constant. See - [ORACLE_PRICE_FEEDS.md](ORACLE_PRICE_FEEDS.md). + [docs/oracle-price-feeds.md](docs/oracle-price-feeds.md). - Referral programme (#266): every user gets a unique invite link, and when an invited friend's first loan is funded the referrer's bonus is transferred automatically by the new `referral_rewards` Soroban contract during `activate_loan`. Includes a referral dashboard, attribution APIs, and - `sql/09_referral_program.sql`. See [REFERRAL_PROGRAM.md](REFERRAL_PROGRAM.md). + `sql/09_referral_program.sql`. See [docs/referral-program.md](docs/referral-program.md). - Borrowing user guide and FAQ at `/docs/borrowing`, covering the step-by-step borrowing process, how the liquidation threshold is calculated, Health Factor bands, and 15 frequently asked questions. Linked from the borrower dashboard diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 64656ca..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-alpine - -# Set working directory -WORKDIR /app - -# Install system dependencies that might be needed for native node modules -RUN apk add --no-cache libc6-compat python3 make g++ - -# Copy package files -COPY package.json package-lock.json* ./ - -# Install dependencies -RUN npm install - -# Copy the rest of the application -COPY . . - -# Expose Next.js default port -EXPOSE 3000 - -# Start the application in development mode -CMD ["npm", "run", "dev"] diff --git a/OPTIMIZATION_SUMMARY.md b/OPTIMIZATION_SUMMARY.md deleted file mode 100644 index fe5ae8d..0000000 --- a/OPTIMIZATION_SUMMARY.md +++ /dev/null @@ -1,226 +0,0 @@ -# Issue #39: Pool Query Performance Optimization - Summary - -## What Was Done - -Optimized Supabase database query performance for large pool lists by consolidating waterfall queries, implementing explicit column selection, and adding database indexes. - -## Files Created - -### 1. **lib/db/pools.ts** (530 lines) -Core optimization module with reusable, typed functions for all pool queries. - -**Functions**: -- `fetchPools()` - Fetch paginated pools with filters and sorting -- `fetchPoolById()` - Fetch single pool by ID -- `fetchActivePoolsWithLiquidity()` - Fetch active pools for auto-matching -- `fetchAdminDashboardPools()` - Fetch pools + loans for admin dashboard - -**Features**: -- ✓ Explicit column selection (no `SELECT *`) -- ✓ Type-safe Pool interface -- ✓ Pagination support -- ✓ Error handling with descriptive messages -- ✓ Index recommendations in comments - -### 2. **sql/04_pool_performance_rpc.sql** (130 lines) -Database migration with RPC functions for complex queries. - -**Functions**: -- `get_lending_pools_paginated()` - SQL RPC for paginated pool fetching -- `get_active_pools_with_liquidity()` - SQL RPC for auto-matching queries - -**Features**: -- ✓ Index recommendations -- ✓ Security-definer for proper authorization -- ✓ Atomic operations -- ✓ Comments with usage examples - -### 3. **lib/db/pools.test.ts** (500+ lines) -Comprehensive test suite verifying optimization benefits. - -**Tests**: -- ✓ Query count verification (1 query per function) -- ✓ Data shape validation -- ✓ Pagination correctness -- ✓ Filtering by status -- ✓ Error handling -- ✓ Profile relation cardinality -- ✓ Performance metrics - -### 4. **POOL_OPTIMIZATION.md** (400+ lines) -Complete documentation of optimization work, performance improvements, and implementation guide. - -## Files Modified - -### 1. **app/api/pools/route.ts** -- Replaced direct queries with `fetchPools()` -- Added pagination metadata to response -- Enhanced query parameter support - -**Before**: 2 sequential queries -**After**: 1 optimized query - -### 2. **app/dashboard/admin/pools/page.tsx** -- Replaced waterfall queries with `fetchAdminDashboardPools()` -- Queries now execute in parallel - -**Before**: 2 sequential queries -**After**: 2 parallel queries - -### 3. **app/actions/admin-pools.ts** -- Updated `approveLoan()` to use `fetchPoolById()` -- Updated `runAutoMatch()` to use `fetchActivePoolsWithLiquidity()` -- Improved performance of auto-matching logic - -**Before**: 2 + N sequential queries -**After**: 2 + batched updates - -## Performance Improvements - -### Query Count Reduction -- **Admin dashboard**: 2 queries → 1 parallel batch (50% faster) -- **Pool list**: 2-3 queries → 1 query (60-66% fewer queries) -- **Auto-match**: ~4 queries → 2-3 queries (25-50% reduction) - -### Network Round-Trips -- **Before**: Multiple round-trips (waterfall) -- **After**: Parallel queries reduce latency by ~50% - -### Database Load -- **Explicit columns**: 40-60% less data transferred -- **Indexed queries**: 50-200ms faster on large datasets -- **Pre-sorted results**: Eliminates client-side sorting overhead - -### Estimated Improvements (Large Datasets) -| Operation | Before | After | Gain | -|-----------|--------|-------|------| -| Fetch 10k pools (paginated) | ~500ms | ~50ms | 90% faster | -| Admin dashboard load | ~400ms | ~250ms | 37% faster | -| Auto-match 100 loans | ~1500ms | ~600ms | 60% faster | - -## Backward Compatibility - -✓ All changes are backward compatible -- Existing API responses still work -- New fields are optional -- Component interfaces unchanged -- No database schema changes required - -## Next Steps (Admin Action) - -1. **Apply Recommended Indexes** in Supabase: - ```sql - CREATE INDEX IF NOT EXISTS idx_lending_pools_status_available - ON public.lending_pools (status, available_liquidity DESC); - - CREATE INDEX IF NOT EXISTS idx_lending_pools_created_at_desc - ON public.lending_pools (created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_lending_pools_available_liquidity_desc - ON public.lending_pools (available_liquidity DESC); - ``` - -2. **Apply RPC Migration**: - - Run `sql/04_pool_performance_rpc.sql` in Supabase SQL Editor - - Verifies new RPC functions are created - -3. **Test in Staging**: - - Admin dashboard pool loading - - Auto-matching with pending loans - - Pagination with parameters - -4. **Monitor Performance**: - - Database query logs - - Network waterfall in browser DevTools - - Verify query time improvements - -## Testing - -All code passes TypeScript compilation checks ✓ - -Run tests: -```bash -npm test -- pools.test.ts -``` - -## Code Quality - -- ✓ Full TypeScript types -- ✓ Comprehensive JSDoc comments -- ✓ Index recommendations documented -- ✓ Error handling with descriptive messages -- ✓ Performance notes in comments -- ✓ BEFORE/AFTER comparisons in code - -## Key Learnings - -1. **Explicit Column Selection**: Using specific columns instead of `SELECT *` reduces data transfer by 40-60% - -2. **Parallel Queries**: Even with 2 queries, running them in parallel saves ~50% latency - -3. **Pre-sorted Database Results**: Sorting at DB level and pre-sorting results eliminates client-side overhead - -4. **Index Strategy**: Composite indexes on (status, available_liquidity) enable index-only scans for common queries - -5. **Type Safety**: Centralized query functions make type handling consistent and testable - -## Example Usage - -### Fetch Active Pools (Auto-Match) -```typescript -import { fetchActivePoolsWithLiquidity } from '@/lib/db/pools'; - -const activePools = await fetchActivePoolsWithLiquidity(supabase, 0); -// Pre-sorted by available_liquidity DESC, single query -``` - -### Fetch Paginated Pools -```typescript -import { fetchPools } from '@/lib/db/pools'; - -const result = await fetchPools(supabase, { - status: 'active', - limit: 20, - offset: 0, - orderBy: 'created_at', - orderDirection: 'desc' -}); - -// Returns: { pools, totalCount, estimatedTotalCount, hasMore } -``` - -### Admin Dashboard -```typescript -import { fetchAdminDashboardPools } from '@/lib/db/pools'; - -const { pools, pendingLoans } = await fetchAdminDashboardPools(supabase); -// Parallel queries for both data -``` - -## Files Changed Summary - -``` -Created: -+ lib/db/pools.ts (optimized query module) -+ sql/04_pool_performance_rpc.sql (RPC migration) -+ lib/db/pools.test.ts (comprehensive tests) -+ POOL_OPTIMIZATION.md (detailed documentation) -+ OPTIMIZATION_SUMMARY.md (this file) - -Modified: -~ app/api/pools/route.ts (use fetchPools) -~ app/dashboard/admin/pools/page.tsx (use fetchAdminDashboardPools) -~ app/actions/admin-pools.ts (use optimized functions) -``` - -## References - -- Issue: #39 "Optimize Supabase database query performance for large pool lists" -- Related: N+1 query problem, Supabase best practices, database indexing -- Documentation: See POOL_OPTIMIZATION.md for detailed guide - ---- - -**Status**: ✅ Complete and ready for testing - -**Quality**: All files pass TypeScript checks, comprehensive tests included, documentation complete diff --git a/POOL_OPTIMIZATION.md b/POOL_OPTIMIZATION.md deleted file mode 100644 index 53c75de..0000000 --- a/POOL_OPTIMIZATION.md +++ /dev/null @@ -1,374 +0,0 @@ -# Pool Query Performance Optimization - Issue #39 - -## Overview - -This document describes the optimization work done to improve Supabase database query performance for large pool lists in TrustLend. - -**Issue**: Waterfall queries when fetching pools data causing N+1 query problems and excessive network round-trips. - -**Solution**: Consolidated queries, optimized with indexes, and created reusable query functions. - ---- - -## Changes Made - -### 1. Created Optimized Query Module: `lib/db/pools.ts` - -**Purpose**: Central module for all pool database operations with consistent patterns and performance optimizations. - -**Key Functions**: - -#### `fetchPools(supabase, options)` -- Fetches paginated pools with optional filtering -- **Before**: Multiple sequential queries for filters, sorting, pagination -- **After**: Single query with all filters applied -- **Features**: - - Explicit column selection (no `SELECT *`) - - Status filtering support - - Pagination with limit/offset - - Customizable ordering (created_at or available_liquidity) - - Row count metadata for UI pagination - -```typescript -const result = await fetchPools(supabase, { - status: 'active', - limit: 20, - offset: 0, - orderBy: 'created_at', - orderDirection: 'desc' -}); -// Returns: { pools, totalCount, estimatedTotalCount, hasMore } -``` - -#### `fetchPoolById(supabase, poolId)` -- Fetch single pool by ID -- **Performance**: Direct lookup with explicit columns -- **Use cases**: Pool detail views, pre-approval checks - -```typescript -const pool = await fetchPoolById(supabase, 'pool-uuid'); -// Returns: Pool | null -``` - -#### `fetchActivePoolsWithLiquidity(supabase, minimumLiquidity)` -- Optimized query for auto-matching operations -- **Before**: Fetch all active pools, filter in application -- **After**: DB-level filtering with optimized index -- **Index used**: `idx_lending_pools_status` (or composite `idx_lending_pools_status_available`) - -```typescript -const pools = await fetchActivePoolsWithLiquidity(supabase, 0); -// Returns: Pool[] pre-sorted by available_liquidity DESC -``` - -#### `fetchAdminDashboardPools(supabase)` -- Fetch pools + pending loans for admin dashboard -- **Optimization**: Parallel queries instead of sequential -- **Before**: 2 waterfall queries -- **After**: 2 parallel queries (faster) - -```typescript -const { pools, pendingLoans } = await fetchAdminDashboardPools(supabase); -``` - -### 2. Created RPC Migration: `sql/04_pool_performance_rpc.sql` - -**Purpose**: Add database-level functions for complex queries with atomic operations. - -**Functions**: - -#### `get_lending_pools_paginated(...)` -SQL RPC function for fetching paginated pools with filters. Can be called directly if RPC endpoint is exposed. - -```sql -SELECT * FROM public.get_lending_pools_paginated( - status_filter := 'active', - page_limit := 20, - page_offset := 0, - order_by_col := 'created_at', - order_asc := false -); -``` - -**Parameters**: -- `status_filter`: Optional pool status filter -- `page_limit`: Number of results per page -- `page_offset`: Pagination offset -- `order_by_col`: Sort column ('created_at', 'available_liquidity', 'total_liquidity') -- `order_asc`: Sort direction - -#### `get_active_pools_with_liquidity(min_liquidity)` -SQL RPC for fetching active pools with minimum liquidity threshold. - -### 3. Recommended Indexes - -**Critical Indexes for Optimal Performance**: - -```sql --- 1. Composite index for active pools filtering -CREATE INDEX IF NOT EXISTS idx_lending_pools_status_available -ON public.lending_pools (status, available_liquidity DESC); - --- 2. Index for default sort order (created_at) -CREATE INDEX IF NOT EXISTS idx_lending_pools_created_at_desc -ON public.lending_pools (created_at DESC); - --- 3. Index for alternative sort (available liquidity) -CREATE INDEX IF NOT EXISTS idx_lending_pools_available_liquidity_desc -ON public.lending_pools (available_liquidity DESC); -``` - -**Current Status**: -- ✓ `idx_lending_pools_status` exists in schema -- ⚠️ Additional indexes recommended for production (see below) - -### 4. Updated Routes & Actions - -#### `app/api/pools/route.ts` -- Now uses `fetchPools()` function -- Supports pagination parameters: `limit`, `offset` -- Supports filtering: `status` -- Supports custom ordering: `orderBy`, `orderDirection` -- Response includes pagination metadata - -**Before**: -```json -{ - "success": true, - "pools": [...] -} -``` - -**After**: -```json -{ - "success": true, - "pools": [...], - "pagination": { - "limit": 10, - "offset": 0, - "hasMore": true, - "estimatedTotal": 145 - } -} -``` - -#### `app/dashboard/admin/pools/page.tsx` -- Replaced waterfall queries with `fetchAdminDashboardPools()` -- Queries now execute in parallel -- Cleaner error handling - -#### `app/actions/admin-pools.ts` -- `approveLoan()`: Uses `fetchPoolById()` instead of direct query -- `runAutoMatch()`: Uses `fetchActivePoolsWithLiquidity()` for optimal pool selection - -**Before** (runAutoMatch): -1. Fetch pending loans -2. Fetch active pools -3. Loop through loans -4. For each loan, update loan and pool (N queries) - -**After** (runAutoMatch): -1. Fetch pending loans (1 query) -2. Fetch active pools (1 query, pre-sorted by liquidity) -3. Loop through loans with local state tracking -4. Batch updates in parallel - -### 5. Added Comprehensive Tests: `lib/db/pools.test.ts` - -**Test Coverage**: -- ✓ Only 1 query executed per function (not N) -- ✓ Correct data shape returned -- ✓ Pagination works correctly -- ✓ Filtering by status works -- ✓ Error handling works -- ✓ Relation cardinality handling (profiles array/object) - -**Run Tests**: -```bash -npm test -- pools.test.ts -``` - ---- - -## Performance Improvements - -### Query Count Reduction - -| Operation | Before | After | Improvement | -|-----------|--------|-------|-------------| -| fetchPools | 2-3 queries | 1 query | 60-66% fewer queries | -| fetchPoolById | 1 query | 1 query | No change (already optimal) | -| fetchActivePoolsWithLiquidity | 1 query (no filter) | 1 query (with filter) | -50% filtering overhead | -| fetchAdminDashboardPools | 2 sequential | 2 parallel | ~50% faster (parallelization) | -| runAutoMatch | 2 + N queries | 2 + N/2 queries (batched) | Variable improvement | - -### Network Round-Trip Reduction - -**For typical admin dashboard load**: -- **Before**: 2 sequential queries (2 round-trips minimum) -- **After**: 2 parallel queries (1 round-trip) -- **Savings**: 50% reduction in round-trip time - -### Database Load Reduction - -**For large pool lists** (10k+ pools): -- **Explicit column selection**: 40-60% less data transferred -- **Index usage**: 50-200ms faster queries on unindexed columns -- **Filtered queries**: Reduced full table scans - -### Estimated Improvements on Large Datasets - -| Scenario | Before | After | Improvement | -|----------|--------|-------|-------------| -| Fetch 10k pools, 10 at a time | ~500ms | ~50ms | **90% faster** | -| Admin dashboard (pools + loans) | ~400ms sequential | ~250ms parallel | **37% faster** | -| Auto-match 100 loans to 20 pools | ~1500ms | ~600ms | **60% faster** | - ---- - -## Implementation Checklist - -### Completed ✓ -- [x] Created `lib/db/pools.ts` with optimized functions -- [x] Created `sql/04_pool_performance_rpc.sql` migration -- [x] Updated `app/api/pools/route.ts` to use new functions -- [x] Updated `app/dashboard/admin/pools/page.tsx` -- [x] Updated `app/actions/admin-pools.ts` -- [x] Added comprehensive tests in `lib/db/pools.test.ts` -- [x] Added performance documentation - -### Next Steps (Manual Admin Action Required) - -1. **Apply Recommended Indexes in Supabase**: - ```sql - -- Run in Supabase SQL Editor - CREATE INDEX IF NOT EXISTS idx_lending_pools_status_available - ON public.lending_pools (status, available_liquidity DESC); - - CREATE INDEX IF NOT EXISTS idx_lending_pools_created_at_desc - ON public.lending_pools (created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_lending_pools_available_liquidity_desc - ON public.lending_pools (available_liquidity DESC); - ``` - -2. **Apply RPC Migration**: - - Run `sql/04_pool_performance_rpc.sql` in Supabase SQL Editor - - Verifies functions are created: `get_lending_pools_paginated`, `get_active_pools_with_liquidity` - -3. **Test in Staging**: - - Verify admin dashboard loads pools correctly - - Test auto-matching with pending loans - - Test pagination with `limit` and `offset` parameters - -4. **Monitor Performance**: - - Check database query logs for query optimization - - Monitor network waterfall in browser DevTools - - Verify estimated query time improvements - ---- - -## Migration Guide for Other Queries - -To apply this pattern to other database operations: - -1. **Analyze the current query pattern**: - - Identify waterfall queries (sequential awaits) - - Count round-trips to database - - Check for `SELECT *` usage - -2. **Create optimized function**: - ```typescript - export async function fetchMyData( - supabase: SupabaseClient, - filters: { ... } - ) { - let query = supabase - .from("my_table") - .select("col1, col2, col3") // Explicit columns, no SELECT * - .order("col1", { ascending: false }); - - if (filters.status) { - query = query.eq("status", filters.status); - } - - const { data, error } = await query; - // Handle error, transform data - } - ``` - -3. **Add tests**: - - Verify only 1 query is made - - Verify correct data shape - - Verify error handling - -4. **Update calling code**: - - Replace waterfall queries - - Use new function consistently - ---- - -## Backward Compatibility - -All changes are backward compatible: -- Existing API routes still work with old response format -- New pagination fields are optional and don't break existing code -- Component interfaces unchanged (props accept same Pool type) - ---- - -## Debugging Guide - -### Query Not Optimizing as Expected - -**Check**: -1. Are you awaiting every query? (Sequential = not parallel) -2. Is `SELECT *` used? (Change to explicit columns) -3. Are filters applied at database level? (Not in application) -4. Is the correct index being used? (Check query plan in Supabase) - -**Verify in Supabase**: -```sql -EXPLAIN ANALYZE -SELECT id, name, status, apr_bps, total_liquidity, available_liquidity -FROM public.lending_pools -WHERE status = 'active' -ORDER BY created_at DESC -LIMIT 10; -``` - -### Performance Issues After Migration - -**Possible Causes**: -1. **Missing indexes**: Run recommended index creation queries -2. **Network latency**: Check browser network tab for slow round-trips -3. **Large payload**: Verify explicit column selection (no SELECT *) -4. **Suboptimal query plan**: Check EXPLAIN output - -**Debugging Steps**: -```typescript -// Add timing logs -const start = performance.now(); -const result = await fetchPools(supabase); -console.log(`Query took ${performance.now() - start}ms`); -``` - ---- - -## References - -- **Supabase Documentation**: https://supabase.com/docs/reference/javascript/select -- **PostgreSQL Query Performance**: https://www.postgresql.org/docs/current/using-explain.html -- **Index Strategies**: https://use-the-index-luke.com/ -- **N+1 Query Problem**: https://www.sqlinjection.net/table-in-from-clause/ - ---- - -## Questions or Issues? - -For questions about this optimization: -1. Check `POOL_OPTIMIZATION.md` (this file) -2. Review comments in `lib/db/pools.ts` and migration file -3. Run tests: `npm test -- pools.test.ts` -4. Check browser DevTools network tab for query count diff --git a/QUICK_START_POOL_QUERIES.md b/QUICK_START_POOL_QUERIES.md deleted file mode 100644 index df231fc..0000000 --- a/QUICK_START_POOL_QUERIES.md +++ /dev/null @@ -1,310 +0,0 @@ -# Quick Start: Pool Query Optimization - -## TL;DR - -Use `lib/db/pools.ts` functions instead of direct Supabase queries for all pool operations. - -## Function Reference - -### Fetch Paginated Pools -```typescript -import { fetchPools } from '@/lib/db/pools'; - -// With filters and pagination -const result = await fetchPools(supabase, { - status: 'active', - limit: 20, - offset: 0, - orderBy: 'created_at', - orderDirection: 'desc' -}); - -console.log(result.pools); // Pool[] -console.log(result.hasMore); // boolean -console.log(result.estimatedTotalCount); // number -``` - -### Fetch Single Pool -```typescript -import { fetchPoolById } from '@/lib/db/pools'; - -const pool = await fetchPoolById(supabase, 'pool-uuid'); -if (pool) { - console.log(pool.name); -} -``` - -### Fetch Active Pools (Auto-Matching) -```typescript -import { fetchActivePoolsWithLiquidity } from '@/lib/db/pools'; - -// Get pools sorted by available liquidity -const pools = await fetchActivePoolsWithLiquidity(supabase, 0); -// Optional: minimum liquidity threshold -const richPools = await fetchActivePoolsWithLiquidity(supabase, 50000); -``` - -### Fetch Admin Dashboard Data -```typescript -import { fetchAdminDashboardPools } from '@/lib/db/pools'; - -const { pools, pendingLoans } = await fetchAdminDashboardPools(supabase); -``` - -## API Endpoint - -GET `/api/pools` - -**Query Parameters**: -- `status`: 'active' | 'paused' | 'closed' (optional) -- `limit`: 1-100 (default: 10) -- `offset`: number (default: 0) -- `orderBy`: 'created_at' | 'available_liquidity' (default: 'created_at') -- `orderDirection`: 'asc' | 'desc' (default: 'desc') - -**Response**: -```json -{ - "success": true, - "pools": [ - { - "id": "uuid", - "name": "Pool Name", - "description": "...", - "status": "active", - "apr_bps": 1500, - "total_liquidity": 100000, - "available_liquidity": 50000, - "total_borrowed": 50000, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - ], - "pagination": { - "limit": 10, - "offset": 0, - "hasMore": true, - "estimatedTotal": 45 - } -} -``` - -## Do's and Don'ts - -### ✅ DO -```typescript -// Use the optimized functions -const pools = await fetchPools(supabase); - -// Explicit columns in custom queries -.select("id, name, status, apr_bps") - -// Parallel queries -await Promise.all([ - fetchPools(supabase), - fetchAdminDashboardPools(supabase) -]) -``` - -### ❌ DON'T -```typescript -// Don't use SELECT * -.select("*") - -// Don't make sequential/waterfall queries -const pools = await fetchPools(supabase); -const loans = await fetchLoans(supabase); // Wait, should be parallel - -// Don't bypass the module -supabase.from("lending_pools").select("*")... -``` - -## Common Patterns - -### List All Active Pools -```typescript -const { pools } = await fetchPools(supabase, { - status: 'active', - limit: 100 -}); -``` - -### Pagination -```typescript -const page1 = await fetchPools(supabase, { limit: 20, offset: 0 }); -const page2 = await fetchPools(supabase, { limit: 20, offset: 20 }); -``` - -### Sort by Liquidity -```typescript -const pools = await fetchPools(supabase, { - orderBy: 'available_liquidity', - orderDirection: 'desc' -}); -``` - -### Auto-Matching -```typescript -const activePools = await fetchActivePoolsWithLiquidity(supabase, 0); -// Pre-sorted by available_liquidity DESC -``` - -## Performance Checklist - -- [ ] Using `lib/db/pools.ts` functions? -- [ ] No `SELECT *` in queries? -- [ ] Parallel queries where applicable? -- [ ] Pagination applied for large lists? -- [ ] Explicit column selection? - -## Debugging - -### Check Query Count -```typescript -// Browser DevTools → Network tab -// Should see 1-2 requests for pool operations -// NOT multiple sequential requests -``` - -### Verify Query Performance -```typescript -const start = performance.now(); -const result = await fetchPools(supabase); -console.log(`Query took ${performance.now() - start}ms`); -// Should be <100ms for typical queries -``` - -### Test Pagination -```typescript -const result = await fetchPools(supabase, { limit: 10, offset: 0 }); -console.log(result.hasMore); // true if more results available -console.log(result.pools.length); // 10 (or less if last page) -``` - -## Type Reference - -```typescript -interface Pool { - id: string; - name: string; - description: string | null; - status: 'active' | 'paused' | 'closed'; - apr_bps: number; - total_liquidity: number; - available_liquidity: number; - total_borrowed: number; - created_at: string; - updated_at: string; -} - -interface PoolFetchOptions { - status?: 'active' | 'paused' | 'closed'; - limit?: number; - offset?: number; - orderBy?: 'created_at' | 'available_liquidity'; - orderDirection?: 'asc' | 'desc'; -} - -interface PoolFetchResult { - pools: Pool[]; - totalCount: number; - estimatedTotalCount: number; - hasMore: boolean; -} -``` - -## Need Help? - -1. Check `lib/db/pools.ts` for detailed JSDoc comments -2. Read `POOL_OPTIMIZATION.md` for deep dive -3. Run tests: `npm test -- pools.test.ts` -4. Check browser DevTools network tab - -## Examples - -### React Component -```typescript -'use client'; -import { useEffect, useState } from 'react'; -import { getServiceRoleClient } from '@/lib/supabase/server'; -import { fetchPools } from '@/lib/db/pools'; - -export function PoolList() { - const [pools, setPools] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const load = async () => { - try { - const supabase = getServiceRoleClient(); - if (!supabase) return; - - const result = await fetchPools(supabase, { - status: 'active', - limit: 10 - }); - setPools(result.pools); - } catch (err) { - console.error('Failed to load pools:', err); - } finally { - setLoading(false); - } - }; - - load(); - }, []); - - if (loading) return
Loading...
; - return ( -
- {pools.map(pool => ( -
{pool.name}
- ))} -
- ); -} -``` - -### API Route -```typescript -import { fetchPools } from '@/lib/db/pools'; -import { getServiceRoleClient } from '@/lib/supabase/server'; - -export async function GET(request) { - const supabase = getServiceRoleClient(); - if (!supabase) { - return Response.json({ error: 'Service unavailable' }, { status: 500 }); - } - - const { searchParams } = new URL(request.url); - const result = await fetchPools(supabase, { - status: searchParams.get('status'), - limit: parseInt(searchParams.get('limit') || '10'), - offset: parseInt(searchParams.get('offset') || '0') - }); - - return Response.json(result); -} -``` - -### Server Action -```typescript -'use server'; -import { fetchPoolById } from '@/lib/db/pools'; -import { getServerSupabaseClient } from '@/lib/supabase/server'; - -export async function approvePoolFunding(poolId: string) { - const supabase = await getServerSupabaseClient(); - if (!supabase) throw new Error('Service unavailable'); - - const pool = await fetchPoolById(supabase, poolId); - if (!pool) throw new Error('Pool not found'); - if (pool.available_liquidity <= 0) throw new Error('Insufficient liquidity'); - - // Approve funding... -} -``` - ---- - -For questions, see `POOL_OPTIMIZATION.md` or run tests. diff --git a/README.md b/README.md index d3c291c..28a9bcd 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@

Live Production | Video Demo | - Roadmap | - Getting Started Guide | + Roadmap | + Getting Started Guide | Contributing Guidelines

@@ -55,23 +55,7 @@ TrustLend is designed as a foundational layer for decentralized, inclusive credi 4. **Institutional Underwriting:** Enabling institutional liquidity providers to plug proprietary risk models into TrustLend's smart contracts to automatically fund specific borrower profiles. 5. **Global Fiat On/Off Ramps:** Deepening integration with Stellar anchors to allow seamless fiat borrowing and repayment in local currencies worldwide. -*We welcome open-source contributors to help us build this vision! Check out our [Roadmap](ROADMAP.md) for upcoming milestones.* - ---- - -## 📸 Platform Sneak Peek - -### Borrower & Lender Dashboards -

- Borrower Dashboard - Lender Marketplace -

- -### Admin Controls & Verification -

- Admin Overview - KYC Verification -

+*We welcome open-source contributors to help us build this vision! Check out our [Roadmap](docs/roadmap.md) for upcoming milestones.* --- @@ -84,7 +68,6 @@ flowchart TB %% ── Style definitions ──────────────────────────────────────────────────── classDef client fill:#3b82f6,color:#fff,stroke:#2563eb,stroke-width:2px classDef backend fill:#8b5cf6,color:#fff,stroke:#7c3aed,stroke-width:2px - classDef indexer fill:#06b6d4,color:#fff,stroke:#0891b2,stroke-width:2px classDef automation fill:#f59e0b,color:#1e293b,stroke:#d97706,stroke-width:2px classDef chain fill:#10b981,color:#fff,stroke:#059669,stroke-width:2px classDef external fill:#64748b,color:#fff,stroke:#475569,stroke-width:2px @@ -108,15 +91,6 @@ flowchart TB EM[("📧 Email Service
Resend · Payment notices")] end - %% ── Indexer Layer (Cyan) ──────────────────────────────────────────────── - subgraph Indexer["🔍 Indexer Layer (SubQuery)"] - direction TB - SQ[("📥 SubQuery Soroban Indexer
project.yaml")] - GR[("🗃️ GraphQL API
schema.graphql")] - RS[("📡 REST API
Read-model fallback")] - HM[("📊 Horizon Sync Health
indexer_health table")] - end - %% ── Automation Layer (Amber) ───────────────────────────────────────────── subgraph Automation["⏰ Automation Layer (Cron / Vercel)"] direction TB @@ -169,16 +143,6 @@ flowchart TB SA --> EM SA --> WH - %% ── Indexer ────────────────────────────────────────────────────────────── - SQ -->|"captures events from"| SR - SQ --> GR - SQ --> RS - GR -->|"GraphQL read-model"| SA - RS -->|"REST read-model"| SA - HZ -->|"ledger stream"| HM - HM -->|"persists sync state"| SB - HZ -.->|"also feeds direct reads"| SB - %% ── Automation ─────────────────────────────────────────────────────────── PD -->|"queries due loans"| SB PD --> WH @@ -271,9 +235,8 @@ flowchart LR | **Frontend** | Next.js 16, React 19, TypeScript, Tailwind CSS 4, Framer Motion | | **Backend & DB** | Supabase (Auth, Postgres RLS, Storage) | | **Blockchain** | Stellar Testnet, Soroban RPC, Horizon API | -| **Wallet** | Freighter Wallet, xBull, Albedo, WalletConnect v2 for mobile wallets (`@stellar/freighter-api`, `@creit.tech/stellar-wallets-kit`) | +| **Wallet** | Freighter Wallet, xBull, Albedo, WalletConnect v2 for mobile wallets (`@creit.tech/stellar-wallets-kit`) | | **Smart Contracts** | Rust (Soroban, `wasm32v1-none`) — 8 contracts deployed | -| **Indexer** | SubQuery (`@subql/node-stellar`, `@subql/query`) — GraphQL + REST | | **Cache** | Upstash Redis | | **Automation** | Vercel Cron Jobs | | **Email** | Resend | @@ -283,7 +246,7 @@ flowchart LR ## ⚙️ Getting Started (Local Development) -> 📖 **New contributors should start with the [Getting Started Guide](GETTING_STARTED.md)** for a thorough walkthrough covering Soroban CLI setup, contract compilation, database setup, and the full test suite. +> 📖 **New contributors should start with the [Getting Started Guide](docs/getting-started.md)** for a thorough walkthrough covering Soroban CLI setup, contract compilation, database setup, and the full test suite. ### Quick Start @@ -304,7 +267,7 @@ docker-compose up ``` ### Need more detail? -See the [complete setup guide →](GETTING_STARTED.md) +See the [complete setup guide →](docs/getting-started.md) --- @@ -561,7 +524,7 @@ If you discover a security vulnerability within TrustLend, please refer to our [ The PostgreSQL database is dumped, encrypted with AES-256 and uploaded to Amazon S3 every night at 00:00 UTC by the [Automated DB Backup](.github/workflows/db-backup.yml) workflow. Restore steps, bucket/IAM setup and the quarterly restore drill are -documented in [DISASTER_RECOVERY.md](DISASTER_RECOVERY.md). +documented in [docs/disaster-recovery.md](docs/disaster-recovery.md). --- diff --git a/SOROBAN_INDEXER_MIGRATION.md b/SOROBAN_INDEXER_MIGRATION.md deleted file mode 100644 index c9a7039..0000000 --- a/SOROBAN_INDEXER_MIGRATION.md +++ /dev/null @@ -1,119 +0,0 @@ -# Soroban Event Indexer Migration - -TrustLend can read dashboard loan, reputation, and escrow state from a Soroban -event indexer while keeping Supabase as the migration fallback. - -## Contracts To Index - -Configure Mercury, Ensorcel, or a custom Soroban indexer to subscribe to these -contract IDs: - -- `NEXT_PUBLIC_LENDING_CONTRACT_ID` -- `NEXT_PUBLIC_REPUTATION_CONTRACT_ID` -- `NEXT_PUBLIC_ESCROW_CONTRACT_ID` - -## Event Topics - -The contracts emit the following event topics for read-model indexing: - -### Lending - -- `(loan, request)` with `(loan_id, borrower, amount, duration_days, interest_rate_bps, total_due, due_at)` -- `(loan, approved)` with `(loan_id, lender, escrow_id)` -- `(loan, revoked)` with `loan_id` -- `(loan, active)` with `loan_id` -- `(loan, payment)` with `(loan_id, amount, remaining_due, status)` -- `(loan, default)` with `loan_id` - -### Reputation - -- `(oracle, set)` with `oracle` -- `(oracle, score)` with `(borrower, credit_score, boost_bps)` -- `(rep, event)` with `(borrower, event, delta, new_score)` -- `(rep, totals)` with `(borrower, borrowed_delta, repaid_delta)` -- `(rep, freeze)` with `(borrower, is_frozen)` - -### Escrow - -- `(escrow, deposit)` with `(lender, loan_id, amount)` -- `(escrow, withdraw)` with `(lender, loan_id, amount)` -- `(escrow, transfer)` with `(escrow_id, loan_id, borrower, amount)` - -Amounts emitted by Soroban contracts are in stroops. The dashboard converts -indexed loan amounts back to XLM for table display. - -## Expected Read Models - -The app's server-side adapter is in `lib/indexer/read-model.ts`. It supports -GraphQL or REST and normalizes common snake_case and camelCase field names. - -Recommended collections: - -- `loans` -- `reputationEvents` -- `escrowEvents` - -Recommended loan fields: - -- `id` or `loanId` -- `borrowerId` and/or `borrowerAddress` -- `lenderId` and/or `lenderAddress` -- `status` -- `principalAmount` or `amount` -- `repaidAmount` -- `aprBps` or `interestRateBps` -- `durationDays` -- `dueAt` -- `createdAt` -- `escrowId` - -Recommended reputation event fields: - -- `borrowerId` and/or `borrowerAddress` -- `eventType` -- `pointsDelta` -- `scoreAfter` -- `createdAt` - -Recommended escrow event fields: - -- `loanId` -- `lenderAddress` -- `borrowerAddress` -- `amount` -- `eventType` -- `txHash` -- `createdAt` - -## Runtime Configuration - -Use `.env` values: - -```env -TRUSTLEND_INDEXER_READ_MODE=fallback -TRUSTLEND_INDEXER_GRAPHQL_URL=https://your-indexer/graphql -TRUSTLEND_INDEXER_REST_URL= -TRUSTLEND_INDEXER_API_KEY= -``` - -`fallback` mode tries the indexer first and falls back to Supabase if the -indexer is unavailable or still backfilling. Use `required` only after the -subgraph is fully caught up. Use `disabled` to force the old Supabase reads. - -If your GraphQL schema differs from the default collection names or filter -syntax, set any of these variables with a full GraphQL document: - -- `TRUSTLEND_INDEXER_BORROWER_LOANS_QUERY` -- `TRUSTLEND_INDEXER_LENDER_LOANS_QUERY` -- `TRUSTLEND_INDEXER_ADMIN_LOANS_QUERY` -- `TRUSTLEND_INDEXER_REPUTATION_EVENTS_QUERY` -- `TRUSTLEND_INDEXER_ESCROW_EVENTS_QUERY` - -REST deployments should expose: - -- `GET /loans` -- `GET /reputation-events` -- `GET /escrow-events` - -The adapter passes filters such as `borrowerId`, `borrowerAddress`, -`lenderId`, `lenderAddress`, and `limit` as query parameters. diff --git a/__tests__/lib/indexer/read-model.test.ts b/__tests__/lib/indexer/read-model.test.ts deleted file mode 100644 index 45fd418..0000000 --- a/__tests__/lib/indexer/read-model.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PaginationOptions } from "@/lib/indexer/read-model"; - -// We'll test the helper functions directly and the reader functions with mocked fetch -const ORIGINAL_ENV = process.env; - -describe("PaginationOptions interface", () => { - it("accepts limit only", () => { - const opts: PaginationOptions = { limit: 10 }; - expect(opts.limit).toBe(10); - expect(opts.offset).toBeUndefined(); - expect(opts.after).toBeUndefined(); - }); - - it("accepts limit with offset", () => { - const opts: PaginationOptions = { limit: 20, offset: 40 }; - expect(opts.limit).toBe(20); - expect(opts.offset).toBe(40); - }); - - it("accepts limit with cursor", () => { - const opts: PaginationOptions = { limit: 15, after: "YXJyYXljb25uZWN0aW9uOjI=" }; - expect(opts.limit).toBe(15); - expect(opts.after).toBe("YXJyYXljb25uZWN0aW9uOjI="); - }); - - it("accepts all three params", () => { - const opts: PaginationOptions = { limit: 5, offset: 10, after: "cursor123" }; - expect(opts.limit).toBe(5); - expect(opts.offset).toBe(10); - expect(opts.after).toBe("cursor123"); - }); - - it("allows null after (explicit no-cursor)", () => { - const opts: PaginationOptions = { limit: 25, after: null }; - expect(opts.after).toBeNull(); - }); - - it("allows zero offset", () => { - const opts: PaginationOptions = { limit: 50, offset: 0 }; - expect(opts.offset).toBe(0); - }); -}); - -describe("buildPaginationVariables", () => { - beforeEach(() => { - vi.resetModules(); - }); - - it("returns defaults when no options provided", async () => { - const { buildPaginationVariables } = await import("@/lib/indexer/read-model"); - const vars = buildPaginationVariables(); - expect(vars).toEqual({ limit: 50, offset: 0, after: null }); - }); - - it("returns defaults for empty options", async () => { - const { buildPaginationVariables } = await import("@/lib/indexer/read-model"); - const vars = buildPaginationVariables({}); - expect(vars).toEqual({ limit: 50, offset: 0, after: null }); - }); - - it("merges provided values with defaults", async () => { - const { buildPaginationVariables } = await import("@/lib/indexer/read-model"); - const vars = buildPaginationVariables({ limit: 10, offset: 20 }); - expect(vars).toEqual({ limit: 10, offset: 20, after: null }); - }); - - it("includes cursor when provided", async () => { - const { buildPaginationVariables } = await import("@/lib/indexer/read-model"); - const vars = buildPaginationVariables({ limit: 5, after: "cursor123" }); - expect(vars).toEqual({ limit: 5, offset: 0, after: "cursor123" }); - }); -}); - -describe("buildRestPaginationParams", () => { - beforeEach(() => { - vi.resetModules(); - }); - - it("returns defaults when no options provided", async () => { - const { buildRestPaginationParams } = await import("@/lib/indexer/read-model"); - const params = buildRestPaginationParams(); - expect(params).toEqual({ limit: 50, offset: 0, after: null }); - }); - - it("returns provided pagination values", async () => { - const { buildRestPaginationParams } = await import("@/lib/indexer/read-model"); - const params = buildRestPaginationParams({ limit: 25, offset: 50, after: null }); - expect(params).toEqual({ limit: 25, offset: 50, after: null }); - }); -}); - -describe("getIndexedAdminReadModel with pagination", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - process.env = ORIGINAL_ENV; - }); - - it("passes limit as first arg to the resolver", async () => { - // Set up env to avoid early return - process.env = { ...ORIGINAL_ENV }; - - // We'll test that the exported function accepts the old (number) signature - const { getIndexedAdminReadModel, buildPaginationVariables } = await import("@/lib/indexer/read-model"); - - // Verify the internals work by testing the helper directly - const vars = buildPaginationVariables({ limit: 500 }); - expect(vars.limit).toBe(500); - expect(vars.offset).toBe(0); - - // The function itself returns [] when no indexer URL is configured - const result = await getIndexedAdminReadModel(500); - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("accepts optional PaginationOptions as second arg", async () => { - // This verifies the overloaded signature compiles and runs - const { getIndexedAdminReadModel, buildPaginationVariables } = await import("@/lib/indexer/read-model"); - - const vars = buildPaginationVariables({ limit: 100, offset: 200 }); - expect(vars).toEqual({ limit: 100, offset: 200, after: null }); - - const result = await getIndexedAdminReadModel(undefined, { limit: 100, offset: 200 }); - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("backwards-compatible: calling with just limit returns empty when unconfigured", async () => { - // Explicitly no env set - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { getIndexedAdminReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedAdminReadModel(500); - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); -}); - -describe("GraphQL queries contain pagination args", () => { - beforeEach(() => { - vi.resetModules(); - }); - - it("BORROWER_LOANS_QUERY includes offset and after variables", async () => { - const mod = await import("@/lib/indexer/read-model"); - // Access the private query string — we'll verify by checking buildPaginationVariables - const vars = mod.buildPaginationVariables({ limit: 10, offset: 5, after: "cursor" }); - expect(vars).toHaveProperty("limit", 10); - expect(vars).toHaveProperty("offset", 5); - expect(vars).toHaveProperty("after", "cursor"); - }); - - it("buildRestPaginationParams includes limit, offset, and after", async () => { - const mod = await import("@/lib/indexer/read-model"); - const params = mod.buildRestPaginationParams({ limit: 25, offset: 50, after: "next-cursor" }); - expect(params.limit).toBe(25); - expect(params.offset).toBe(50); - expect(params.after).toBe("next-cursor"); - }); -}); - -describe("reader functions accept pagination options (integration)", () => { - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - process.env = ORIGINAL_ENV; - }); - - it("skips indexer call when mode is disabled (borrower)", async () => { - process.env = { ...ORIGINAL_ENV, TRUSTLEND_INDEXER_READ_MODE: "disabled" }; - - const { getIndexedBorrowerReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedBorrowerReadModel({ - userId: "u1", - walletAddress: "GABC", - limit: 10, - offset: 20, - }); - - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("returns empty when no indexer URL is configured (borrower)", async () => { - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { getIndexedBorrowerReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedBorrowerReadModel({ - userId: "u1", - walletAddress: "GABC", - limit: 25, - offset: 0, - }); - - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("returns empty when no indexer URL is configured (lender)", async () => { - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { getIndexedLenderReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedLenderReadModel({ - userId: "u1", - walletAddress: "GABC", - limit: 30, - offset: 15, - }); - - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("returns empty when no indexer URL is configured (admin)", async () => { - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { getIndexedAdminReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedAdminReadModel(500); - - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); - - it("passes cursor-based pagination when after is provided", async () => { - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { buildPaginationVariables } = await import("@/lib/indexer/read-model"); - const vars = buildPaginationVariables({ limit: 20, after: "cursor-value" }); - - expect(vars).toEqual({ limit: 20, offset: 0, after: "cursor-value" }); - }); - - it("accepts PaginationOptions as second arg to getIndexedAdminReadModel", async () => { - process.env = { ...ORIGINAL_ENV }; - delete process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - delete process.env.TRUSTLEND_INDEXER_REST_URL; - - const { getIndexedAdminReadModel } = await import("@/lib/indexer/read-model"); - const result = await getIndexedAdminReadModel(undefined, { limit: 100, offset: 200 }); - - expect(result).toEqual({ loans: [], reputationEvents: [], escrowEvents: [] }); - }); -}); diff --git a/app/actions/admin-pools.ts b/app/actions/admin-pools.ts index 0ac786e..9985cb7 100644 --- a/app/actions/admin-pools.ts +++ b/app/actions/admin-pools.ts @@ -323,10 +323,6 @@ export async function runAutoMatch(): Promise<{ }; } } -tanceof Error ? err.message : "Auto-match failed", - }; - } -} // ── Set pool borrow cap ────────────────────────────────────────────────────── /** diff --git a/app/dashboard/admin/page.tsx b/app/dashboard/admin/page.tsx index bb3ddc6..1d1aa49 100644 --- a/app/dashboard/admin/page.tsx +++ b/app/dashboard/admin/page.tsx @@ -7,11 +7,6 @@ import { presentAdminMetrics, } from "@/lib/dashboard/metrics"; import { getServiceRoleClient } from "@/lib/supabase/server"; -import { - getIndexedAdminReadModel, - isIndexerConfigured, - isIndexerRequired, -} from "@/lib/indexer/read-model"; import Link from "next/link"; import { formatCurrency } from "@/lib/utils/formatting"; @@ -70,22 +65,7 @@ export default async function AdminDashboardPage() { const profiles = profilesRes.data ?? []; const dbLoans = loansRes.data ?? []; - let indexedLoans: typeof dbLoans | null = null; - if (isIndexerConfigured()) { - try { - const indexed = await getIndexedAdminReadModel(10); - indexedLoans = indexed.loans.map((loan) => ({ - id: loan.id, - borrower_id: loan.borrowerId ?? loan.borrowerAddress ?? "", - status: loan.status === "pending" ? "requested" : loan.status, - principal_amount: loan.principalAmount / 10000000, - requested_at: loan.requestedAt ?? loan.createdAt, - })) as typeof dbLoans; - } catch (error) { - if (isIndexerRequired()) throw error; - } - } - const loans = indexedLoans?.length ? indexedLoans : dbLoans; + const loans = dbLoans; const repayments = repaymentsRes.data ?? []; const ledgerRows = ledgerRes.data ?? []; const fraudSignals = fraudRes.data ?? []; diff --git a/app/dashboard/admin/pools/page.tsx b/app/dashboard/admin/pools/page.tsx index fef7ed5..ac655ac 100644 --- a/app/dashboard/admin/pools/page.tsx +++ b/app/dashboard/admin/pools/page.tsx @@ -38,6 +38,7 @@ export default async function AdminPoolsPage() { apr_bps: Number(p.apr_bps ?? 0), total_liquidity: Number(p.total_liquidity ?? 0), available_liquidity: Number(p.available_liquidity ?? 0), + borrow_cap: p.borrow_cap ?? null, })); const pendingLoans = rawLoans.map((l) => ({ diff --git a/app/dashboard/borrower/page.tsx b/app/dashboard/borrower/page.tsx index cfd6e02..d21bb7c 100644 --- a/app/dashboard/borrower/page.tsx +++ b/app/dashboard/borrower/page.tsx @@ -15,11 +15,6 @@ import { WithdrawToFiatButton } from "@/components/dashboard/WithdrawToFiatButto import { borrowerNavLinks } from "@/lib/dashboard/borrower-links"; import { getFundingProgress } from "@/lib/loans/funding"; import { HealthFactorGauge } from "@/components/dashboard/HealthFactorGauge"; -import { - getIndexedBorrowerReadModel, - isIndexerConfigured, - isIndexerRequired, -} from "@/lib/indexer/read-model"; import { formatCurrency } from "@/lib/utils/formatting"; // ── Inline SVG illustrations ─────────────────────────────────────────────── @@ -49,7 +44,7 @@ function EmptyLoansIllustration() { export default async function BorrowerDashboardPage() { const { user } = await requireAuthenticatedUser("borrower"); const walletAddress = String(user.user_metadata?.wallet_address ?? "") || null; - const metrics = await getBorrowerDashboardMetrics(user.id, walletAddress); + const metrics = await getBorrowerDashboardMetrics(user.id); const supabase = await getServerSupabaseClient(); const srClient = getServiceRoleClient(); @@ -72,29 +67,7 @@ export default async function BorrowerDashboardPage() { const profile = profileRes.data; const dbLoans = loansRes.data ?? []; - let indexedLoans: typeof dbLoans | null = null; - if (isIndexerConfigured() && walletAddress) { - try { - const indexed = await getIndexedBorrowerReadModel({ - userId: user.id, - walletAddress, - limit: 20, - }); - indexedLoans = indexed.loans.map((loan) => ({ - id: loan.id, - status: loan.status === "pending" ? "requested" : loan.status, - principal_amount: loan.principalAmount / 10000000, - repaid_amount: loan.repaidAmount / 10000000, - apr_bps: loan.aprBps, - duration_days: loan.durationDays, - due_at: loan.dueAt, - created_at: loan.createdAt, - })) as typeof dbLoans; - } catch (error) { - if (isIndexerRequired()) throw error; - } - } - const loans = indexedLoans?.length ? indexedLoans : dbLoans; + const loans = dbLoans; // Stellar TX lookups const loanIds = loans.map((l) => String(l.id)); diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 52d93ca..2e102c6 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,4 +1,4 @@ -import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary" +import { GlobalErrorBoundary } from "@/components/dashboard/GlobalErrorBoundary" import { RpcHealthProvider } from "@/components/RpcHealthProvider" import { ReferralCapture } from "@/components/dashboard/ReferralCapture" diff --git a/app/dashboard/lender/page.tsx b/app/dashboard/lender/page.tsx index 3c890dd..322fc16 100644 --- a/app/dashboard/lender/page.tsx +++ b/app/dashboard/lender/page.tsx @@ -11,18 +11,13 @@ import { } from "@/lib/supabase/server"; import { formatTokenBalance } from "@/lib/utils/formatting"; import { lenderNavLinks } from "@/lib/dashboard/lender-links"; -import { - getIndexedLenderReadModel, - isIndexerConfigured, - isIndexerRequired, -} from "@/lib/indexer/read-model"; import Link from "next/link"; export default async function LenderHomePage() { const { user } = await requireAuthenticatedUser("lender"); const walletAddress = String(user.user_metadata?.wallet_address ?? "") || null; - const metrics = await getLenderDashboardMetrics(user.id, walletAddress); + const metrics = await getLenderDashboardMetrics(user.id); const supabase = await getServerSupabaseClient(); const srClient = getServiceRoleClient(); @@ -81,39 +76,9 @@ export default async function LenderHomePage() { const openLoanCount = openLoanCountRes.count ?? 0; const isKycVerified = profile?.kyc_status === "verified"; - let indexedLoans: Array> = []; - let indexedP2pInvestments: typeof dbP2pInvestments = []; - if (isIndexerConfigured() && walletAddress) { - try { - const indexed = await getIndexedLenderReadModel({ - userId: user.id, - walletAddress, - limit: 20, - }); - const escrowTxByLoanId = new Map( - indexed.escrowEvents.map((event) => [event.loanId, event.txHash ?? ""]), - ); - indexedLoans = indexed.loans.map((loan) => ({ - id: loan.id, - status: loan.status, - repaid_amount: loan.repaidAmount / 10000000, - principal_amount: loan.principalAmount / 10000000, - })); - indexedP2pInvestments = indexed.loans.map((loan) => ({ - id: `indexed-${loan.id}`, - ref_id: loan.id, - amount: loan.principalAmount / 10000000, - status: "confirmed", - metadata: JSON.stringify({ txHash: escrowTxByLoanId.get(loan.id) ?? "" }), - created_at: loan.createdAt, - })) as typeof dbP2pInvestments; - } catch (error) { - if (isIndexerRequired()) throw error; - } - } - const p2pInvestments = indexedP2pInvestments.length ? indexedP2pInvestments : dbP2pInvestments; - const allLoansArray = indexedLoans.length ? indexedLoans : allLoansRes.data ?? []; + const p2pInvestments = dbP2pInvestments; + const allLoansArray = allLoansRes.data ?? []; const loanMap = Object.fromEntries( allLoansArray.map((l) => [String(l.id), l]), ); diff --git a/assets/admin/admin-loanmonitor.png b/assets/admin/admin-loanmonitor.png deleted file mode 100644 index e26b7a1..0000000 Binary files a/assets/admin/admin-loanmonitor.png and /dev/null differ diff --git a/assets/admin/admin-overview.png b/assets/admin/admin-overview.png deleted file mode 100644 index 1d0c029..0000000 Binary files a/assets/admin/admin-overview.png and /dev/null differ diff --git a/assets/admin/admin-poolmanagement.png b/assets/admin/admin-poolmanagement.png deleted file mode 100644 index 5dea129..0000000 Binary files a/assets/admin/admin-poolmanagement.png and /dev/null differ diff --git a/assets/admin/admin-securtyflags.png b/assets/admin/admin-securtyflags.png deleted file mode 100644 index fd82f1b..0000000 Binary files a/assets/admin/admin-securtyflags.png and /dev/null differ diff --git a/assets/admin/admin-treasury.png b/assets/admin/admin-treasury.png deleted file mode 100644 index a912024..0000000 Binary files a/assets/admin/admin-treasury.png and /dev/null differ diff --git a/assets/admin/admin-users&segments.png b/assets/admin/admin-users&segments.png deleted file mode 100644 index 585f9c5..0000000 Binary files a/assets/admin/admin-users&segments.png and /dev/null differ diff --git a/assets/admin/adminKYC verify.png b/assets/admin/adminKYC verify.png deleted file mode 100644 index 632c1c2..0000000 Binary files a/assets/admin/adminKYC verify.png and /dev/null differ diff --git a/assets/auth.png b/assets/auth.png deleted file mode 100644 index 8ada1b7..0000000 Binary files a/assets/auth.png and /dev/null differ diff --git a/assets/borrower/applyLoan-tab.png b/assets/borrower/applyLoan-tab.png deleted file mode 100644 index 73899e0..0000000 Binary files a/assets/borrower/applyLoan-tab.png and /dev/null differ diff --git a/assets/borrower/home-tab.png b/assets/borrower/home-tab.png deleted file mode 100644 index 9136d9e..0000000 Binary files a/assets/borrower/home-tab.png and /dev/null differ diff --git a/assets/borrower/profile-tab.png b/assets/borrower/profile-tab.png deleted file mode 100644 index b7205de..0000000 Binary files a/assets/borrower/profile-tab.png and /dev/null differ diff --git a/assets/borrower/repay-tab.png b/assets/borrower/repay-tab.png deleted file mode 100644 index bae274d..0000000 Binary files a/assets/borrower/repay-tab.png and /dev/null differ diff --git a/assets/borrower/task-tab.png b/assets/borrower/task-tab.png deleted file mode 100644 index b742191..0000000 Binary files a/assets/borrower/task-tab.png and /dev/null differ diff --git a/assets/lender/history-tab.png b/assets/lender/history-tab.png deleted file mode 100644 index 1bb4533..0000000 Binary files a/assets/lender/history-tab.png and /dev/null differ diff --git a/assets/lender/home.png b/assets/lender/home.png deleted file mode 100644 index f0037be..0000000 Binary files a/assets/lender/home.png and /dev/null differ diff --git a/assets/lender/loan-marketplace.png b/assets/lender/loan-marketplace.png deleted file mode 100644 index 1b89353..0000000 Binary files a/assets/lender/loan-marketplace.png and /dev/null differ diff --git a/assets/lender/pool-investment.png b/assets/lender/pool-investment.png deleted file mode 100644 index 70f4771..0000000 Binary files a/assets/lender/pool-investment.png and /dev/null differ diff --git a/assets/main-dashboard.png b/assets/main-dashboard.png deleted file mode 100644 index 59c8f00..0000000 Binary files a/assets/main-dashboard.png and /dev/null differ diff --git a/assets/mobile/auth-mobile.png b/assets/mobile/auth-mobile.png deleted file mode 100644 index ecdabd5..0000000 Binary files a/assets/mobile/auth-mobile.png and /dev/null differ diff --git a/assets/mobile/landing-mobile-view.png b/assets/mobile/landing-mobile-view.png deleted file mode 100644 index a897b01..0000000 Binary files a/assets/mobile/landing-mobile-view.png and /dev/null differ diff --git a/assets/test/contracts-test.png b/assets/test/contracts-test.png deleted file mode 100644 index 1575ab0..0000000 Binary files a/assets/test/contracts-test.png and /dev/null differ diff --git a/assets/test/e2e-test.png b/assets/test/e2e-test.png deleted file mode 100644 index 1c36ee5..0000000 Binary files a/assets/test/e2e-test.png and /dev/null differ diff --git a/components/ErrorFallback.tsx b/components/ErrorFallback.tsx deleted file mode 100644 index a3215f5..0000000 --- a/components/ErrorFallback.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client' - -import { Button } from '@/components/ui/button' - -interface ErrorFallbackProps { - error?: Error - resetError?: () => void -} - -export function ErrorFallback({ error, resetError }: ErrorFallbackProps) { - return ( -
-
-
- - -

Something went wrong

-

- An unexpected error occurred while rendering this page. Our team has been notified. -

- - {error && ( -

- {error.name || 'Error'}: {error.message} -

- )} - - {resetError && ( - - )} -
-
- ) -} diff --git a/components/GlobalErrorBoundary.tsx b/components/GlobalErrorBoundary.tsx deleted file mode 100644 index 1afc56b..0000000 --- a/components/GlobalErrorBoundary.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { Component, type ErrorInfo, type ReactNode } from 'react' -import { ErrorFallback } from './ErrorFallback' - -interface GlobalErrorBoundaryProps { - children: ReactNode - fallback?: ReactNode -} - -interface GlobalErrorBoundaryState { - hasError: boolean - error: Error | null -} - -export class GlobalErrorBoundary extends Component< - GlobalErrorBoundaryProps, - GlobalErrorBoundaryState -> { - constructor(props: GlobalErrorBoundaryProps) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error: Error): GlobalErrorBoundaryState { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - console.error('[GlobalErrorBoundary] Caught error:', error, errorInfo) - } - - handleReset = (): void => { - this.setState({ hasError: false, error: null }) - } - - render(): ReactNode { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback - } - - return ( - - ) - } - - return this.props.children - } -} diff --git a/components/auth/AuthAccessButton.tsx b/components/auth/AuthAccessButton.tsx deleted file mode 100644 index 647383b..0000000 --- a/components/auth/AuthAccessButton.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { X } from "lucide-react"; -import { FocusTrap } from "@/components/ui/FocusTrap"; -import { type UserRole } from "@/lib/auth/roles"; -import { StellarSignInButton } from "@/components/auth/StellarSignInButton"; - -interface AuthAccessButtonProps { - className?: string; - buttonLabel?: string; -} - -export function AuthAccessButton({ className, buttonLabel = "Sign in" }: AuthAccessButtonProps) { - const [open, setOpen] = useState(false); - const [role, setRole] = useState("borrower"); - const modalRef = useRef(null); - const closeButtonRef = useRef(null); - - const closeModal = () => { - setOpen(false); - }; - - // Close on Escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - closeModal(); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [open]); - - // Return focus to trigger button when modal closes - useEffect(() => { - if (!open) { - const trigger = document.querySelector(".auth-trigger-text")?.closest("button") as HTMLElement; - trigger?.focus(); - } - }, [open]); - - return ( - <> - - - {open ? ( -
- -
event.stopPropagation()} - ref={modalRef} - > - - -

Auth setup

-

Choose role and sign in

-

- Dialog for signing in to TrustLend as a borrower or lender -

- -
- - -
- -
- -
- -

- Your role will be securely locked to your wallet on first sign-in. -

-
-
-
- ) : null} - - ); -} diff --git a/components/dashboard/AvailablePools.tsx b/components/dashboard/AvailablePools.tsx deleted file mode 100644 index 32ff692..0000000 --- a/components/dashboard/AvailablePools.tsx +++ /dev/null @@ -1,443 +0,0 @@ -"use client"; - -/** - * AvailablePools – Client Component - * - * Fetches available lending pools and the lender's positions from the - * Supabase browser client *after* the page shell has rendered, so the - * user immediately sees a polished skeleton instead of a blank screen. - * - * isLoading state conditionally renders while the - * data is in-flight, and then transitions to the real pool cards via - * Framer Motion once loaded. - */ - -import { useEffect, useState, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { getBrowserSupabaseClient } from "@/lib/supabase/client"; -import { formatTokenBalance, formatCurrency } from "@/lib/utils/formatting"; -import { TermTooltip } from "@/components/ui/TermTooltip"; -import type { GlossaryTermKey } from "@/lib/glossary/terms"; -import { PoolCardSkeleton } from "./PoolCardSkeleton"; - -// ──────────────────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────────────────── - -interface Pool { - id: string; - name: string; - status: string; - apr_bps: number; - total_liquidity: number; - available_liquidity: number; -} - -interface Position { - id: string; - pool_id: string; - status: string; - principal_amount: number; - earned_interest: number; -} - -interface AvailablePoolsProps { - /** - * Optional server-side pre-fetched pools. - * If supplied the component skips the client-side fetch and goes - * straight to the rendered state (no skeleton flicker). - */ - initialPools?: Pool[]; - /** Pre-fetched positions passed from the server component. */ - initialPositions?: Position[]; -} - -// ──────────────────────────────────────────────────────────────────────────── -// Status badge -// ──────────────────────────────────────────────────────────────────────────── - -function StatusBadge({ status }: { status: string }) { - const isActive = status.toLowerCase() === "active"; - return ( - - {status} - - ); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Individual pool card -// ──────────────────────────────────────────────────────────────────────────── - -function PoolCard({ - pool, - myPosition, - index, -}: { - pool: Pool; - myPosition?: Position; - index: number; -}) { - const apr = (Number(pool.apr_bps ?? 0) / 100).toFixed(2); - const totalSize = formatTokenBalance(Number(pool.total_liquidity ?? 0)); - const available = formatTokenBalance(Number(pool.available_liquidity ?? 0)); - - return ( - - {/* Decorative gradient orb for invested pools */} - {myPosition && ( -
- )} - - {/* Header */} -
-
-
- 🏦 -
-
-

- {pool.name} -

-

- {String(pool.id).slice(0, 8)} -

-
-
- -
- - {/* Divider */} -
- - {/* Stats */} -
- {([ - { label: "APR", value: `${apr}%`, accent: "#22cf9d", term: "APR" }, - { label: "Total Size", value: totalSize, accent: undefined, term: undefined }, - { label: "Available", value: available, accent: undefined, term: undefined }, - ] as Array<{ - label: string; - value: string; - accent?: string; - term?: GlossaryTermKey; - }>).map(({ label, value, accent, term }) => ( -
-

- {label} - {term && } -

-

- {value} -

-
- ))} -
- - {/* Footer: my stake */} -
-
-

- My Stake -

- {myPosition ? ( -

- {formatCurrency(Number(myPosition.principal_amount ?? 0))} ✅ -

- ) : ( -

- )} -
- - {/* APR badge pill */} -
- {apr}% APR -
-
- - ); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Error state -// ──────────────────────────────────────────────────────────────────────────── - -function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { - return ( - -

- ⚠️ {message} -

- -
- ); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Main component -// ──────────────────────────────────────────────────────────────────────────── - -export function AvailablePools({ initialPools, initialPositions }: AvailablePoolsProps) { - const [pools, setPools] = useState(initialPools ?? []); - const [positions, setPositions] = useState(initialPositions ?? []); - - // isLoading is true until we have confirmed data (or an error) - const [isLoading, setIsLoading] = useState( - !initialPools || initialPools.length === 0 - ); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const supabase = getBrowserSupabaseClient(); - if (!supabase) throw new Error("Supabase client not initialised — check env vars."); - - const [poolsRes, positionsRes] = await Promise.all([ - supabase - .from("lending_pools") - .select("id, name, status, apr_bps, total_liquidity, available_liquidity") - .order("created_at", { ascending: false }) - .limit(8), - supabase - .from("pool_positions") - .select("id, pool_id, status, principal_amount, earned_interest") - .order("opened_at", { ascending: true }), - ]); - - if (poolsRes.error) throw new Error(poolsRes.error.message); - if (positionsRes.error) throw new Error(positionsRes.error.message); - - setPools(poolsRes.data ?? []); - setPositions(positionsRes.data ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load pools."); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - // If server already provided pools, skip the client fetch - if (initialPools && initialPools.length > 0) { - setIsLoading(false); - return; - } - void fetchData(); - }, [initialPools, fetchData]); - - // ── Conditionally render skeleton while isLoading is true ────────────── - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - if (pools.length === 0) { - return ( - -

No lending pools yet

-

- No lending pools have been created yet. Check back soon. -

-
- ); - } - - // ── Render pool cards once loaded ────────────────────────────────────── - return ( - - - {/* Section header */} -
-

- Available Lending Pools -

- - positions.find((pos) => String(pos.pool_id) === String(p.id)) - ) - ? "rgba(34,207,157,0.12)" - : "rgba(126,47,208,0.1)", - color: pools.some((p) => - positions.find((pos) => String(pos.pool_id) === String(p.id)) - ) - ? "#22cf9d" - : "#7e2fd0", - borderRadius: "9999px", - padding: "0.2rem 0.65rem", - fontSize: "0.73rem", - fontWeight: 700, - }} - > - {pools.length} pool{pools.length !== 1 ? "s" : ""} - -
- - {/* Pool cards grid */} -
- {pools.map((pool, i) => { - const myPosition = positions.find( - (pos) => String(pos.pool_id) === String(pool.id) - ); - return ( - - ); - })} -
-
-
- ); -} diff --git a/components/dashboard/FinanceChart.tsx b/components/dashboard/FinanceChart.tsx deleted file mode 100644 index 6f921ee..0000000 --- a/components/dashboard/FinanceChart.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { BarChartSkeleton } from "@/components/dashboard/ChartSkeleton"; - -interface FinanceChartPoint { - label: string; - valueA: number; - valueB: number; -} - -interface FinanceChartProps { - title: string; - legendA: string; - legendB: string; - points: FinanceChartPoint[]; - /** Show animated skeleton placeholder when true. */ - loading?: boolean; -} - -export function FinanceChart({ title, legendA, legendB, points, loading = false }: FinanceChartProps) { - // ── Loading state: show skeleton ──────────────────────────────────────────── - if (loading) { - return ; - } - - const maxValue = Math.max(1, ...points.flatMap((point) => [point.valueA, point.valueB])); - - return ( -
-
-

{title}

-
- {legendA} - {legendB} -
-
-
- {points.map((point) => ( -
-
- - -
-

{point.label}

-
- ))} -
-
- ); -} diff --git a/components/dashboard/RoleDashboardScreen.tsx b/components/dashboard/RoleDashboardScreen.tsx deleted file mode 100644 index 93c0e6f..0000000 --- a/components/dashboard/RoleDashboardScreen.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { getBrowserSupabaseClient } from "@/lib/supabase/client"; -import { - getDashboardPath, - isUserRole, - normalizeUserRole, - type UserRole, -} from "@/lib/auth/roles"; - -interface RoleMetric { - label: string; - value: string; -} - -interface RoleDashboardScreenProps { - expectedRole: UserRole; - heading: string; - description: string; - metrics: RoleMetric[]; - primaryHref: string; - primaryLabel: string; - secondaryHref: string; - secondaryLabel: string; -} - -export function RoleDashboardScreen({ - expectedRole, - heading, - description, - metrics, - primaryHref, - primaryLabel, - secondaryHref, - secondaryLabel, -}: RoleDashboardScreenProps) { - const router = useRouter(); - const [ready, setReady] = useState(false); - const [email, setEmail] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - - const ensureRoleAccess = async () => { - const supabase = getBrowserSupabaseClient(); - if (!supabase) { - if (!cancelled) { - setError("Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in your environment."); - } - return; - } - - const { data } = await supabase.auth.getSession(); - const session = data.session; - - if (!session) { - router.replace("/"); - return; - } - - const metadataRole = session.user.user_metadata?.account_type; - const roleFromUser = normalizeUserRole(metadataRole); - - if (isUserRole(metadataRole) && roleFromUser !== expectedRole) { - router.replace(getDashboardPath(roleFromUser)); - return; - } - - if (!isUserRole(metadataRole)) { - const { error: updateError } = await supabase.auth.updateUser({ - data: { - ...session.user.user_metadata, - account_type: expectedRole, - }, - }); - - if (updateError) { - if (!cancelled) { - setError(updateError.message); - } - return; - } - } - - if (!cancelled) { - setEmail(session.user.email ?? null); - setReady(true); - } - }; - - void ensureRoleAccess(); - - return () => { - cancelled = true; - }; - }, [expectedRole, router]); - - const handleSignOut = async () => { - const supabase = getBrowserSupabaseClient(); - if (!supabase) { - return; - } - - await supabase.auth.signOut(); - router.replace("/"); - }; - - if (error) { - return ( -
-

{error}

-
- ); - } - - if (!ready) { - return ( -
-

Loading your {expectedRole} workspace...

-
- ); - } - - return ( -
-
-
-
-

{expectedRole} dashboard

-

{heading}

-

{description}

-
- -
- -

Signed in as: {email ?? "Unknown"}

- -
- {metrics.map((item) => ( -
-

{item.value}

-

{item.label}

-
- ))} -
- -
- - {primaryLabel} - - - {secondaryLabel} - -
-
-
- ); -} diff --git a/components/dashboard/SorobanProfileCard.tsx b/components/dashboard/SorobanProfileCard.tsx deleted file mode 100644 index 7a85639..0000000 --- a/components/dashboard/SorobanProfileCard.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import { useEffect, useCallback, useState } from "react"; -import { Loader2, ShieldCheck, AlertCircle, Rocket } from "lucide-react"; -import { ReputationContract } from "@/lib/contracts"; -import { useTransactionSimulation } from "@/lib/stellar/use-transaction-simulation"; -import { ConfirmTransactionModal } from "@/components/ui/ConfirmTransactionModal"; - -interface SorobanProfileCardProps { - walletAddress: string | null; -} - -const REPUTATION_CONTRACT_ID = - process.env.NEXT_PUBLIC_REPUTATION_CONTRACT_ID ?? ""; - -export function SorobanProfileCard({ walletAddress }: SorobanProfileCardProps) { - const [profileExists, setProfileExists] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const tx = useTransactionSimulation(); - - const checkProfile = useCallback(async () => { - if (!walletAddress) return; - try { - const exists = await ReputationContract.hasProfile(walletAddress, walletAddress); - setProfileExists(exists); - } catch (err) { - console.error("[TrustLend] Failed to check on-chain profile:", err); - } - }, [walletAddress]); - - useEffect(() => { - if (walletAddress) { - checkProfile(); - } - }, [walletAddress, checkProfile]); - - const handleInitialize = async () => { - if (!walletAddress) return; - tx.preview({ - label: "Initialize On-Chain Profile", - contractId: REPUTATION_CONTRACT_ID, - method: "init_borrower", - args: [walletAddress], - callerAddress: walletAddress, - details: { - Address: walletAddress, - Action: "Create borrower reputation profile", - }, - }); - }; - - const handleConfirmInit = async () => { - if (!walletAddress) return; - setLoading(true); - setError(null); - try { - await tx.confirm(async () => { - await ReputationContract.initBorrowerProfile(walletAddress); - }); - setProfileExists(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to initialize profile"); - } finally { - setLoading(false); - } - }; - - if (!walletAddress) return null; - - if (profileExists === true) { - return ( -
-
-
- -
-
-

Stellar Profile Active

-

- Your borrower reputation is now being tracked on the Stellar network. Repay loans on time to build your score. -

-
-
-
- ); - } - - return ( - <> -
-
-
- -
-
-

Initialize On-Chain Profile

-

- Your wallet is connected, but your reputation profile hasn't been created on Stellar yet. - This is required to apply for micro-loans. -

- - {error && ( -
- - {error} -
- )} - - -
-
-
- - {/* Simulation preview modal */} - - - ); -} diff --git a/components/dashboard/WalletSetupCard.tsx b/components/dashboard/WalletSetupCard.tsx deleted file mode 100644 index 34f4a8d..0000000 --- a/components/dashboard/WalletSetupCard.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { STELLAR_TESTNET, formatWalletAddress } from "@/lib/stellar/testnet"; - -interface WalletSetupCardProps { - walletAddress: string | null; -} - -export function WalletSetupCard({ walletAddress }: WalletSetupCardProps) { - const shortAddress = formatWalletAddress(walletAddress); - - return ( -
-

Wallet Setup

-

Stellar Testnet and test XLM

-

- Use Freighter on the Stellar testnet, fund the account with Friendbot, and keep this wallet ready for contract testing. -

- -
-
- Network -

{STELLAR_TESTNET.networkName}

-
-
- Horizon -

Testnet API

-
-
- Wallet -

{shortAddress}

-
-
- -
    -
  • Install Freighter and create a new testnet account.
  • -
  • Fund the account with Friendbot test XLM.
  • -
  • Save the public key in your profile once wallet sync is wired.
  • -
  • Use this same wallet for contract deploys and loan testing.
  • -
- - - -

- Network passphrase: {STELLAR_TESTNET.networkPassphrase} -

-

- Horizon endpoint: {STELLAR_TESTNET.horizonUrl} -

-
- ); -} \ No newline at end of file diff --git a/components/landing/SectionTitle.tsx b/components/landing/SectionTitle.tsx deleted file mode 100644 index 4bbb8f1..0000000 --- a/components/landing/SectionTitle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface SectionTitleProps { - kicker: string; - title: string; - description: string; -} - -export function SectionTitle({ kicker, title, description }: SectionTitleProps) { - return ( -
-

{kicker}

-

- {title} -

-

{description}

-
- ); -} diff --git a/components/ui/table.tsx b/components/ui/table.tsx deleted file mode 100644 index 2425105..0000000 --- a/components/ui/table.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { clsx } from "clsx"; -import { type HTMLAttributes, type TableHTMLAttributes, type ThHTMLAttributes, type TdHTMLAttributes } from "react"; - -export function TableWrap({ className, ...props }: HTMLAttributes) { - return
; -} - -export function Table({ className, ...props }: TableHTMLAttributes) { - return ; -} - -export function TableHead({ className, ...props }: HTMLAttributes) { - return ; -} - -export function TableBody({ className, ...props }: HTMLAttributes) { - return ; -} - -export function TableTh({ className, ...props }: ThHTMLAttributes) { - return
; -} - -export function TableTd({ className, ...props }: TdHTMLAttributes) { - return ; -} diff --git a/contracts/multisig_admin/src/lib.rs b/contracts/multisig_admin/src/lib.rs index 768081d..7961a8b 100644 --- a/contracts/multisig_admin/src/lib.rs +++ b/contracts/multisig_admin/src/lib.rs @@ -10,7 +10,7 @@ //! activation, payment recording, default marking, escrow disbursement //! confirmation) — those must stay single-signer/backend-automatable, or the //! platform's existing cron jobs and liquidation keeper stop functioning. -//! See `contracts/MULTISIG_ADMIN.md` for the full rationale. +//! See `docs/contracts/multisig-admin.md` for the full rationale. //! //! Flow: `propose` (any registered signer) → `approve` (N distinct signers, //! asynchronously, over separate transactions) → `execute` (permissionless diff --git a/docker-compose.subquery.yml b/docker-compose.subquery.yml deleted file mode 100644 index b148b7a..0000000 --- a/docker-compose.subquery.yml +++ /dev/null @@ -1,126 +0,0 @@ -version: "3.8" - -# ────────────────────────────────────────────────────────────────────────────── -# TrustLend — SubQuery Soroban Indexer Docker Compose -# ────────────────────────────────────────────────────────────────────────────── -# Spins up three services: -# 1. postgres — Persistent store for indexed chain data -# 2. subquery-node — SubQuery Soroban indexer (listens to Stellar events) -# 3. graphql-engine — GraphQL API over the indexed data -# -# Usage: -# docker compose -f docker-compose.subquery.yml up -d -# -# The GraphQL playground is available at http://localhost:3001/graphql -# after the indexer finishes its initial sync. -# -# Environment variables (create a .env.subquery file or export): -# SUBQUERY_DB_PASSWORD — PostgreSQL password (default: postgres) -# SUBQUERY_DB_USER — PostgreSQL user (default: postgres) -# SUBQUERY_DB_NAME — PostgreSQL database (default: subquery) -# SUBQUERY_NETWORK_ENDPOINT — Soroban RPC endpoint (default: testnet) -# SUBQUERY_START_BLOCK — Start block height (default: 12345678) -# SUBQUERY_BATCH_SIZE — Batch size for fetching (default: 100) -# ────────────────────────────────────────────────────────────────────────────── - -x-env-defaults: &env-defaults - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: ${SUBQUERY_DB_USER:-postgres} - DB_PASS: ${SUBQUERY_DB_PASSWORD:-postgres} - DB_DATABASE: ${SUBQUERY_DB_NAME:-subquery} - -services: - # ── PostgreSQL ──────────────────────────────────────────────────────────── - postgres: - image: postgres:16-alpine - container_name: trustlend-subquery-db - restart: unless-stopped - ports: - # Host port 5433 avoids conflict with any local PostgreSQL on 5432. - - "127.0.0.1:5433:5432" - environment: - POSTGRES_PASSWORD: ${SUBQUERY_DB_PASSWORD:-postgres} - POSTGRES_DB: ${SUBQUERY_DB_NAME:-subquery} - volumes: - - subquery-postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${SUBQUERY_DB_USER:-postgres} -d ${SUBQUERY_DB_NAME:-subquery}"] - interval: 5s - timeout: 5s - retries: 5 - - # ── SubQuery Node (Indexer) ─────────────────────────────────────────────── - subquery-node: - build: - context: . - dockerfile: subquery/Dockerfile - container_name: trustlend-subquery-node - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - environment: - <<: *env-defaults - DB_SCHEMA: trustlend - # Network configuration - CHAIN_ID: ${SUBQUERY_CHAIN_ID:-Test SDF Network ; September 2015} - NETWORK_ENDPOINT: ${SUBQUERY_NETWORK_ENDPOINT:-https://soroban-testnet.stellar.org} - # Optional: set a specific API key if the RPC endpoint requires one - # SOROBAN_RPC_API_KEY: ${SUBQUERY_RPC_API_KEY:-} - volumes: - # Mount the project root so the node can read project.yaml and schema.graphql - - ./project.yaml:/app/project.yaml:ro - - ./schema.graphql:/app/schema.graphql:ro - # Mount the built mappings - - subquery-dist:/app/dist - command: - - "-f=/app" - - "--db-schema=trustlend" - - "--db-host=postgres" - - "--db-port=5432" - - "--db-user=${SUBQUERY_DB_USER:-postgres}" - - "--db-pass=${SUBQUERY_DB_PASSWORD:-postgres}" - - "--db-database=${SUBQUERY_DB_NAME:-subquery}" - - "--batch-size=${SUBQUERY_BATCH_SIZE:-100}" - # Uncomment and set a specific start block to override project.yaml - # - "--start-block=${SUBQUERY_START_BLOCK:-12345678}" - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ── GraphQL Engine (Query Service) ──────────────────────────────────────── - graphql-engine: - image: onfinality/subql-query:latest - container_name: trustlend-subquery-graphql - restart: unless-stopped - depends_on: - - postgres - - subquery-node - ports: - - "3001:3000" - environment: - <<: *env-defaults - DB_SCHEMA: trustlend - command: - - "--name=trustlend" - - "--db-schema=trustlend" - - "--db-host=postgres" - - "--db-port=5432" - - "--db-user=${SUBQUERY_DB_USER:-postgres}" - - "--db-pass=${SUBQUERY_DB_PASSWORD:-postgres}" - - "--db-database=${SUBQUERY_DB_NAME:-subquery}" - # Enable the GraphQL playground in dev - - "--playground=true" - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -volumes: - subquery-postgres-data: - driver: local - subquery-dist: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 13c8fe1..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: '3.8' - -services: - web: - build: - context: . - dockerfile: Dockerfile.dev - ports: - - "3000:3000" - volumes: - # Mount the local codebase to the container for hot-reloading - - .:/app - # Use an anonymous volume for node_modules to avoid Windows/Linux conflicts - - /app/node_modules - - /app/.next - env_file: - - .env.local - environment: - - NODE_ENV=development - # Enable polling for file changes (required for Windows hot-reload to work consistently) - - WATCHPACK_POLLING=true - - CHOKIDAR_USEPOLLING=true - - # Note on Supabase: - # This setup binds the Next.js app to your local machine. - # If you are running the local Supabase CLI (`npx supabase start`), - # you should set your NEXT_PUBLIC_SUPABASE_URL in `.env.local` to - # `http://host.docker.internal:54321` (or whichever port your local Supabase uses) - # so the Docker container can reach it. diff --git a/DOCS_APR_FORMULAS.md b/docs/apr-formulas.md similarity index 100% rename from DOCS_APR_FORMULAS.md rename to docs/apr-formulas.md diff --git a/SIWS_AUTH.md b/docs/auth-siws.md similarity index 100% rename from SIWS_AUTH.md rename to docs/auth-siws.md diff --git a/contracts/FLASH_LOANS.md b/docs/contracts/flash-loans.md similarity index 100% rename from contracts/FLASH_LOANS.md rename to docs/contracts/flash-loans.md diff --git a/contracts/GOVERNANCE.md b/docs/contracts/governance.md similarity index 100% rename from contracts/GOVERNANCE.md rename to docs/contracts/governance.md diff --git a/contracts/MULTISIG_ADMIN.md b/docs/contracts/multisig-admin.md similarity index 100% rename from contracts/MULTISIG_ADMIN.md rename to docs/contracts/multisig-admin.md diff --git a/contracts/ORACLE_INTEGRATION.md b/docs/contracts/oracle-integration.md similarity index 100% rename from contracts/ORACLE_INTEGRATION.md rename to docs/contracts/oracle-integration.md diff --git a/DEFAULT_AUTOMATION.md b/docs/default-automation.md similarity index 100% rename from DEFAULT_AUTOMATION.md rename to docs/default-automation.md diff --git a/DISASTER_RECOVERY.md b/docs/disaster-recovery.md similarity index 100% rename from DISASTER_RECOVERY.md rename to docs/disaster-recovery.md diff --git a/FORMAL_VERIFICATION.md b/docs/formal-verification.md similarity index 99% rename from FORMAL_VERIFICATION.md rename to docs/formal-verification.md index 684126d..023ff98 100644 --- a/FORMAL_VERIFICATION.md +++ b/docs/formal-verification.md @@ -207,5 +207,5 @@ contracts/lending/ Cargo.toml # Added proptest dev-dependency .github/workflows/ formal-verification.yml # CI workflow -FORMAL_VERIFICATION.md # This file (in repo root) +docs/formal-verification.md # This file ``` diff --git a/GETTING_STARTED.md b/docs/getting-started.md similarity index 97% rename from GETTING_STARTED.md rename to docs/getting-started.md index 329f819..db35d2f 100644 --- a/GETTING_STARTED.md +++ b/docs/getting-started.md @@ -434,6 +434,6 @@ This recreates the `.husky/_/` directory and ensures hooks are activated. The `n ## Next Steps - Read the [Contributing Guidelines](CONTRIBUTING.md) for the PR workflow. -- Check the [Roadmap](ROADMAP.md) for upcoming features. -- Browse project documentation: [Flash Loans](contracts/FLASH_LOANS.md), [MultiSig Admin](contracts/MULTISIG_ADMIN.md), [Oracle Integration](contracts/ORACLE_INTEGRATION.md), [Governance](contracts/GOVERNANCE.md). +- Check the [Roadmap](roadmap.md) for upcoming features. +- Browse project documentation: [Flash Loans](contracts/flash-loans.md), [MultiSig Admin](contracts/multisig-admin.md), [Oracle Integration](contracts/oracle-integration.md), [Governance](contracts/governance.md). - Join the community discussions on GitHub Issues. diff --git a/LIQUIDATION_KEEPER.md b/docs/liquidation-keeper.md similarity index 100% rename from LIQUIDATION_KEEPER.md rename to docs/liquidation-keeper.md diff --git a/ORACLE_PRICE_FEEDS.md b/docs/oracle-price-feeds.md similarity index 100% rename from ORACLE_PRICE_FEEDS.md rename to docs/oracle-price-feeds.md diff --git a/REFERRAL_PROGRAM.md b/docs/referral-program.md similarity index 100% rename from REFERRAL_PROGRAM.md rename to docs/referral-program.md diff --git a/ROADMAP.md b/docs/roadmap.md similarity index 100% rename from ROADMAP.md rename to docs/roadmap.md diff --git a/SEP24_FIAT_RAMP.md b/docs/sep24-fiat-ramp.md similarity index 100% rename from SEP24_FIAT_RAMP.md rename to docs/sep24-fiat-ramp.md diff --git a/lib/config/kyc-verification.config.ts b/lib/config/kyc-verification.config.ts deleted file mode 100644 index 6562613..0000000 --- a/lib/config/kyc-verification.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * KYC Verification Configuration - * - * Enhanced security configuration for KYC verification process - * Based on Level 5 user feedback: Strengthen fraud detection and validation - * - * Implementation: Stricter validation rules, enhanced document verification, - * improved fraud detection mechanisms - */ - -export const KYC_VERIFICATION_CONFIG = { - // Enhanced validation strictness - validation: { - strict_mode: true, - require_face_verification: true, - document_liveness_check: true, - additional_verification_questions: true, - }, - - // Fraud detection thresholds - fraud_detection: { - enable_ml_scoring: true, - risk_score_threshold: 0.3, - geographic_velocity_check: true, - document_tampering_detection: true, - duplicate_submission_check: true, - }, - - // Document verification - documents: { - require_government_id: true, - support_biometric_matching: true, - enable_document_expiry_check: true, - require_recent_proof_of_address: true, - }, - - // Implementation date - implemented_date: '2026-04-25', - feedback_source: 'Level 5 User: Souvik Mandal', - feedback_priority: 'CRITICAL', -}; - -export const getKYCConfig = () => KYC_VERIFICATION_CONFIG; diff --git a/lib/config/lender-pool-security.config.ts b/lib/config/lender-pool-security.config.ts deleted file mode 100644 index 39883a0..0000000 --- a/lib/config/lender-pool-security.config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Lender Pool Security Configuration - * - * Enhanced security measures for protecting lender investments - * Based on Level 5 user feedback: - * - Saurav Suman: Pool section security for interest preservation - * - Subham Singha: Lender-side safety mechanisms - * - * Implementation: Fund protection, default risk management, - * interest rate safeguards, deposit verification - */ - -export const LENDER_POOL_SECURITY_CONFIG = { - // Interest rate protection - interest_protection: { - enable_rate_locking: true, - prevent_rate_modification_after_deposit: true, - interest_accrual_verification: true, - automated_interest_calculation: true, - }, - - // Deposit security - deposit_security: { - require_deposit_verification: true, - minimum_deposit_amount: 100, // USDC equivalent - maximum_deposit_concentration: 0.2, // 20% of total pool - escrow_holding_period: 3600, // 1 hour in seconds - }, - - // Default risk management - default_management: { - enable_default_insurance: true, - automatic_default_detection: true, - graceful_default_handling: true, - liquidation_protection: true, - recovery_fund_allocation: 0.02, // 2% recovery fund - }, - - // Fund preservation - fund_preservation: { - separate_borrower_lender_wallets: true, - enable_multi_sig_approvals: false, // Future enhancement - cold_storage_integration: false, // Future enhancement - emergency_withdrawal_enabled: true, - }, - - // Implementation tracking - implemented_date: '2026-04-25', - feedback_sources: [ - 'Level 5 User: Saurav Suman (Lender)', - 'Level 5 User: Subham Singha (Lender)', - ], - feedback_priority: 'HIGH', -}; - -export const getPoolSecurityConfig = () => LENDER_POOL_SECURITY_CONFIG; diff --git a/lib/content/landing-content.ts b/lib/content/landing-content.ts index 1d39ba6..17ea904 100644 --- a/lib/content/landing-content.ts +++ b/lib/content/landing-content.ts @@ -207,7 +207,7 @@ export const trustBadges: TrustBadge[] = [ { label: "Formally verified contracts", detail: "Core accounting invariants proved with Kani and property tests in CI.", - href: `${REPO_URL}/blob/main/FORMAL_VERIFICATION.md`, + href: `${REPO_URL}/blob/main/docs/formal-verification.md`, icon: "verified", external: true, }, diff --git a/lib/dashboard/metrics.ts b/lib/dashboard/metrics.ts index f427831..b964c82 100644 --- a/lib/dashboard/metrics.ts +++ b/lib/dashboard/metrics.ts @@ -1,11 +1,4 @@ import { getServerSupabaseClient } from "@/lib/supabase/server"; -import { - getIndexedAdminReadModel, - getIndexedBorrowerReadModel, - getIndexedLenderReadModel, - isIndexerConfigured, - isIndexerRequired, -} from "@/lib/indexer/read-model"; export interface BorrowerDashboardMetrics { reputationScore: number; @@ -73,43 +66,7 @@ const ACTIVE_LOAN_STATUSES = ["active", "funded", "approved"]; export async function getBorrowerDashboardMetrics( userId: string, - walletAddress?: string | null, ): Promise { - if (isIndexerConfigured() && walletAddress) { - try { - const indexed = await getIndexedBorrowerReadModel({ userId, walletAddress }); - if (indexed.loans.length === 0 && indexed.reputationEvents.length === 0) { - throw new Error("Indexer borrower read model is empty"); - } - const reputationPoints = indexed.reputationEvents.reduce( - (sum, row) => sum + Number(row.pointsDelta ?? 0), - 0, - ); - const reputation = - indexed.reputationEvents.find((row) => Number.isFinite(row.scoreAfter ?? NaN)) - ?.scoreAfter ?? Math.max(0, 250 + reputationPoints); - const pendingLoans = indexed.loans.filter((loan) => - ["pending", "requested"].includes(loan.status), - ).length; - const activeLoans = indexed.loans.filter((loan) => - ACTIVE_LOAN_STATUSES.includes(loan.status), - ).length; - const repaidLoans = indexed.loans.filter((loan) => loan.status === "repaid").length; - const defaultedLoans = indexed.loans.filter((loan) => loan.status === "defaulted").length; - const repaymentBase = repaidLoans + defaultedLoans; - const repaymentRate = repaymentBase > 0 ? (repaidLoans / repaymentBase) * 100 : 100; - - return { - reputationScore: Math.max(0, Number(reputation ?? 0)), - availableCredit: Math.max(0, Number(reputation ?? 0)) * 10, - activeLoans, - pendingLoans, - repaymentRate, - }; - } catch (error) { - if (isIndexerRequired()) throw error; - } - } const supabase = await getServerSupabaseClient(); @@ -155,32 +112,14 @@ export async function getBorrowerDashboardMetrics( export async function getLenderDashboardMetrics( userId: string, - walletAddress?: string | null, ): Promise { - let indexedDeployed = 0; - let indexedEarnings = 0; - let indexedActive = 0; - - if (isIndexerConfigured() && walletAddress) { - try { - const indexed = await getIndexedLenderReadModel({ userId, walletAddress }); - indexedDeployed = indexed.loans.reduce((sum, loan) => sum + loan.principalAmount, 0); - indexedEarnings = indexed.loans.reduce( - (sum, loan) => sum + Math.max(0, loan.repaidAmount - loan.principalAmount), - 0, - ); - indexedActive = indexed.loans.filter((loan) => ACTIVE_LOAN_STATUSES.includes(loan.status)).length; - } catch (error) { - if (isIndexerRequired()) throw error; - } - } const { getServerSupabaseClient, getServiceRoleClient } = await import("@/lib/supabase/server"); const supabase = await getServerSupabaseClient(); const srClient = getServiceRoleClient(); if (!supabase || !srClient) { - return { deployedCapital: indexedDeployed, totalEarnings: indexedEarnings, activePositions: indexedActive, defaultRate: 0 }; + return { deployedCapital: 0, totalEarnings: 0, activePositions: 0, defaultRate: 0 }; } try { @@ -255,9 +194,9 @@ export async function getLenderDashboardMetrics( p2pActiveCount = (loans ?? []).filter(l => l.status === "active").length; } - const deployedCapital = poolDeployed + (indexedDeployed || p2pDeployed); - const totalEarnings = poolEarnings + (indexedEarnings || p2pProfit); - const activePositions = poolActive + (indexedActive || p2pActiveCount); + const deployedCapital = poolDeployed + p2pDeployed; + const totalEarnings = poolEarnings + p2pProfit; + const activePositions = poolActive + p2pActiveCount; const defaultRate = 0; return { deployedCapital, totalEarnings, activePositions, defaultRate }; @@ -271,22 +210,6 @@ export async function getAdminDashboardMetrics(): Promise if (!supabase) return { totalUsers: 0, totalLoans: 0, activeLoans: 0, highRiskUsers: 0 }; try { - let indexedLoanCounts: Pick | null = null; - if (isIndexerConfigured()) { - try { - const indexed = await getIndexedAdminReadModel(500); - if (indexed.loans.length > 0) { - indexedLoanCounts = { - totalLoans: indexed.loans.length, - activeLoans: indexed.loans.filter((loan) => - [...ACTIVE_LOAN_STATUSES, "pending", "requested"].includes(loan.status), - ).length, - }; - } - } catch (error) { - if (isIndexerRequired()) throw error; - } - } const [usersRes, totalLoansRes, activeLoansRes, highRiskRes] = await Promise.all([ supabase.from("profiles").select("id", { count: "exact", head: true }), @@ -298,8 +221,8 @@ export async function getAdminDashboardMetrics(): Promise ]); return { totalUsers: usersRes.count ?? 0, - totalLoans: indexedLoanCounts?.totalLoans ?? totalLoansRes.count ?? 0, - activeLoans: indexedLoanCounts?.activeLoans ?? activeLoansRes.count ?? 0, + totalLoans: totalLoansRes.count ?? 0, + activeLoans: activeLoansRes.count ?? 0, highRiskUsers: highRiskRes.count ?? 0, }; } catch { diff --git a/lib/indexer/read-model.ts b/lib/indexer/read-model.ts deleted file mode 100644 index aacc04b..0000000 --- a/lib/indexer/read-model.ts +++ /dev/null @@ -1,463 +0,0 @@ -type IndexerMode = "disabled" | "fallback" | "required"; - -export interface IndexedLoan { - id: string; - borrowerId?: string | null; - borrowerAddress?: string | null; - lenderId?: string | null; - lenderAddress?: string | null; - status: string; - principalAmount: number; - repaidAmount: number; - aprBps: number; - durationDays: number; - dueAt: string | null; - createdAt: string | null; - requestedAt?: string | null; - escrowId?: string | number | null; -} - -export interface IndexedReputationEvent { - id?: string; - borrowerId?: string | null; - borrowerAddress?: string | null; - eventType?: string | null; - pointsDelta: number; - scoreAfter?: number | null; - createdAt?: string | null; -} - -export interface IndexedEscrowEvent { - id?: string; - loanId: string; - lenderAddress?: string | null; - borrowerAddress?: string | null; - amount: number; - eventType?: string | null; - txHash?: string | null; - createdAt?: string | null; -} - -export interface IndexedDashboardReadModel { - loans: IndexedLoan[]; - reputationEvents: IndexedReputationEvent[]; - escrowEvents: IndexedEscrowEvent[]; -} - -/** - * Pagination options for indexer queries. - * - * Both `first` and `offset` are standard SubQuery GraphQL pagination params. - * `after` enables cursor-based pagination (takes precedence over `offset`). - * - * Examples: - * // First page, 20 items - * { limit: 20 } - * // Second page via offset - * { limit: 20, offset: 20 } - * // Next page via cursor - * { limit: 20, after: "YXJyYXljb25uZWN0aW9uOjI=" } - */ -export interface PaginationOptions { - /** Maximum records to return (default: 50). */ - limit?: number; - /** Zero-based offset for skip-based pagination. */ - offset?: number; - /** Cursor string for cursor-based pagination (overrides offset). */ - after?: string | null; -} - -interface ReadOptions extends PaginationOptions { - userId?: string | null; - walletAddress?: string | null; -} - -const DEFAULT_LIMIT = 50; -const DEFAULT_OFFSET = 0; - -function getMode(): IndexerMode { - const raw = (process.env.TRUSTLEND_INDEXER_READ_MODE ?? "fallback").toLowerCase(); - if (raw === "required" || raw === "disabled") return raw; - return "fallback"; -} - -export function isIndexerConfigured(): boolean { - return Boolean(process.env.TRUSTLEND_INDEXER_GRAPHQL_URL || process.env.TRUSTLEND_INDEXER_REST_URL); -} - -export function isIndexerRequired(): boolean { - return getMode() === "required"; -} - -function getHeaders(): HeadersInit { - const headers: HeadersInit = { "content-type": "application/json" }; - const token = process.env.TRUSTLEND_INDEXER_API_KEY; - if (token) { - headers.authorization = `Bearer ${token}`; - } - return headers; -} - -function unwrapRows(payload: unknown, preferredKey: string): T[] { - if (!payload || typeof payload !== "object") return []; - const record = payload as Record; - const direct = record[preferredKey]; - if (Array.isArray(direct)) return direct as T[]; - if (direct && typeof direct === "object" && Array.isArray((direct as Record).nodes)) { - return (direct as Record).nodes as T[]; - } - if (Array.isArray(record.nodes)) return record.nodes as T[]; - for (const value of Object.values(record)) { - if (Array.isArray(value)) return value as T[]; - if (value && typeof value === "object" && Array.isArray((value as Record).nodes)) { - return (value as Record).nodes as T[]; - } - } - return []; -} - -async function requestGraphql( - query: string, - variables: Record, - preferredKey: string, -): Promise { - const url = process.env.TRUSTLEND_INDEXER_GRAPHQL_URL; - if (!url) return []; - - const res = await fetch(url, { - method: "POST", - headers: getHeaders(), - body: JSON.stringify({ query, variables }), - cache: "no-store", - }); - if (!res.ok) throw new Error(`Indexer GraphQL returned ${res.status}`); - - const json = (await res.json()) as { data?: unknown; errors?: unknown }; - if (json.errors) throw new Error("Indexer GraphQL returned errors"); - return unwrapRows(json.data, preferredKey); -} - -async function requestRest( - path: string, - params: Record, - preferredKey: string, -): Promise { - const base = process.env.TRUSTLEND_INDEXER_REST_URL; - if (!base) return []; - - const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - } - - const res = await fetch(url, { headers: getHeaders(), cache: "no-store" }); - if (!res.ok) throw new Error(`Indexer REST returned ${res.status}`); - - const json = await res.json(); - return Array.isArray(json) ? (json as T[]) : unwrapRows(json, preferredKey); -} - -/** - * Build pagination variables for GraphQL queries. - */ -export function buildPaginationVariables(pagination?: PaginationOptions): Record { - return { - limit: pagination?.limit ?? DEFAULT_LIMIT, - offset: pagination?.offset ?? DEFAULT_OFFSET, - after: pagination?.after ?? null, - }; -} - -/** - * Build pagination params for REST API calls. - */ -export function buildRestPaginationParams(pagination?: PaginationOptions): Record { - return { - limit: pagination?.limit ?? DEFAULT_LIMIT, - offset: pagination?.offset ?? DEFAULT_OFFSET, - after: pagination?.after ?? null, - }; -} - -async function readIndexed({ - graphqlQuery, - graphqlQueryEnv, - restPath, - variables, - restParams, - preferredKey, -}: { - graphqlQuery: string; - graphqlQueryEnv: string; - restPath: string; - variables: Record; - restParams: Record; - preferredKey: string; -}): Promise { - if (getMode() === "disabled" || !isIndexerConfigured()) return []; - - const configuredQuery = process.env[graphqlQueryEnv]; - if (process.env.TRUSTLEND_INDEXER_GRAPHQL_URL) { - return requestGraphql(configuredQuery || graphqlQuery, variables, preferredKey); - } - return requestRest(restPath, restParams, preferredKey); -} - -function toNumber(value: unknown): number { - if (typeof value === "number") return Number.isFinite(value) ? value : 0; - if (typeof value === "bigint") return Number(value); - if (typeof value === "string") { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function toStringOrNull(value: unknown): string | null { - if (value === undefined || value === null || value === "") return null; - return String(value); -} - -function normalizeLoan(row: Record): IndexedLoan { - const principal = - row.principalAmount ?? row.principal_amount ?? row.amount ?? row.totalPrincipal ?? 0; - return { - id: String(row.id ?? row.loanId ?? row.loan_id ?? ""), - borrowerId: toStringOrNull(row.borrowerId ?? row.borrower_id), - borrowerAddress: toStringOrNull(row.borrowerAddress ?? row.borrower), - lenderId: toStringOrNull(row.lenderId ?? row.lender_id), - lenderAddress: toStringOrNull(row.lenderAddress ?? row.lender), - status: String(row.status ?? "requested").toLowerCase(), - principalAmount: toNumber(principal), - repaidAmount: toNumber(row.repaidAmount ?? row.repaid_amount ?? row.totalRepaid ?? 0), - aprBps: toNumber(row.aprBps ?? row.apr_bps ?? row.interestRateBps ?? row.interest_rate_bps ?? 0), - durationDays: toNumber(row.durationDays ?? row.duration_days ?? 0), - dueAt: toStringOrNull(row.dueAt ?? row.due_at), - createdAt: toStringOrNull(row.createdAt ?? row.created_at ?? row.ledgerTimestamp), - requestedAt: toStringOrNull(row.requestedAt ?? row.requested_at), - escrowId: toStringOrNull(row.escrowId ?? row.escrow_id), - }; -} - -function normalizeReputationEvent(row: Record): IndexedReputationEvent { - return { - id: toStringOrNull(row.id) ?? undefined, - borrowerId: toStringOrNull(row.borrowerId ?? row.user_id), - borrowerAddress: toStringOrNull(row.borrowerAddress ?? row.borrower), - eventType: toStringOrNull(row.eventType ?? row.event_type), - pointsDelta: toNumber(row.pointsDelta ?? row.points_delta ?? row.delta), - scoreAfter: toNumber(row.scoreAfter ?? row.score_after ?? row.score), - createdAt: toStringOrNull(row.createdAt ?? row.created_at ?? row.ledgerTimestamp), - }; -} - -function normalizeEscrowEvent(row: Record): IndexedEscrowEvent { - return { - id: toStringOrNull(row.id) ?? undefined, - loanId: String(row.loanId ?? row.loan_id ?? row.refId ?? ""), - lenderAddress: toStringOrNull(row.lenderAddress ?? row.lender), - borrowerAddress: toStringOrNull(row.borrowerAddress ?? row.borrower), - amount: toNumber(row.amount), - eventType: toStringOrNull(row.eventType ?? row.event_type ?? row.type), - txHash: toStringOrNull(row.txHash ?? row.tx_hash ?? row.transactionHash), - createdAt: toStringOrNull(row.createdAt ?? row.created_at ?? row.ledgerTimestamp), - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// GraphQL Queries (now with offset + cursor-based pagination) -// ───────────────────────────────────────────────────────────────────────────── - -const BORROWER_LOANS_QUERY = ` - query TrustLendBorrowerLoans( - $userId: String, $walletAddress: String, - $limit: Int!, $offset: Int, $after: String - ) { - loans( - first: $limit - offset: $offset - after: $after - orderBy: createdAt - orderDirection: desc - where: { borrowerId: $userId, borrowerAddress: $walletAddress } - ) { - id borrowerId borrowerAddress lenderId lenderAddress status principalAmount repaidAmount - aprBps durationDays dueAt createdAt requestedAt escrowId - } - } -`; - -const LENDER_LOANS_QUERY = ` - query TrustLendLenderLoans( - $userId: String, $walletAddress: String, - $limit: Int!, $offset: Int, $after: String - ) { - loans( - first: $limit - offset: $offset - after: $after - orderBy: createdAt - orderDirection: desc - where: { lenderId: $userId, lenderAddress: $walletAddress } - ) { - id borrowerId borrowerAddress lenderId lenderAddress status principalAmount repaidAmount - aprBps durationDays dueAt createdAt requestedAt escrowId - } - } -`; - -const ADMIN_LOANS_QUERY = ` - query TrustLendAdminLoans($limit: Int!, $offset: Int, $after: String) { - loans(first: $limit, offset: $offset, after: $after, orderBy: createdAt, orderDirection: desc) { - id borrowerId borrowerAddress lenderId lenderAddress status principalAmount repaidAmount - aprBps durationDays dueAt createdAt requestedAt escrowId - } - } -`; - -const REPUTATION_EVENTS_QUERY = ` - query TrustLendReputationEvents( - $userId: String, $walletAddress: String, - $limit: Int!, $offset: Int, $after: String - ) { - reputationEvents( - first: $limit - offset: $offset - after: $after - orderBy: createdAt - orderDirection: desc - where: { borrowerId: $userId, borrowerAddress: $walletAddress } - ) { - id borrowerId borrowerAddress eventType pointsDelta scoreAfter createdAt - } - } -`; - -const ESCROW_EVENTS_QUERY = ` - query TrustLendEscrowEvents( - $walletAddress: String, - $limit: Int!, $offset: Int, $after: String - ) { - escrowEvents( - first: $limit - offset: $offset - after: $after - orderBy: createdAt - orderDirection: desc - where: { lenderAddress: $walletAddress } - ) { - id loanId lenderAddress borrowerAddress amount eventType txHash createdAt - } - } -`; - -// ───────────────────────────────────────────────────────────────────────────── -// Public reader functions -// ───────────────────────────────────────────────────────────────────────────── - -export async function getIndexedBorrowerReadModel(options: ReadOptions): Promise { - const variables = { - userId: options.userId ?? "", - walletAddress: options.walletAddress ?? "", - ...buildPaginationVariables(options), - }; - - const restParams = { - borrowerId: options.userId, - borrowerAddress: options.walletAddress, - ...buildRestPaginationParams(options), - }; - - const [loans, reputationEvents] = await Promise.all([ - readIndexed>({ - graphqlQuery: BORROWER_LOANS_QUERY, - graphqlQueryEnv: "TRUSTLEND_INDEXER_BORROWER_LOANS_QUERY", - restPath: "/loans", - variables, - restParams, - preferredKey: "loans", - }), - readIndexed>({ - graphqlQuery: REPUTATION_EVENTS_QUERY, - graphqlQueryEnv: "TRUSTLEND_INDEXER_REPUTATION_EVENTS_QUERY", - restPath: "/reputation-events", - variables, - restParams, - preferredKey: "reputationEvents", - }), - ]); - - return { - loans: loans.map(normalizeLoan).filter((loan) => loan.id), - reputationEvents: reputationEvents.map(normalizeReputationEvent), - escrowEvents: [], - }; -} - -export async function getIndexedLenderReadModel(options: ReadOptions): Promise { - const variables = { - userId: options.userId ?? "", - walletAddress: options.walletAddress ?? "", - ...buildPaginationVariables(options), - }; - - const restParams = { - lenderId: options.userId, - lenderAddress: options.walletAddress, - ...buildRestPaginationParams(options), - }; - - const [loans, escrowEvents] = await Promise.all([ - readIndexed>({ - graphqlQuery: LENDER_LOANS_QUERY, - graphqlQueryEnv: "TRUSTLEND_INDEXER_LENDER_LOANS_QUERY", - restPath: "/loans", - variables, - restParams, - preferredKey: "loans", - }), - readIndexed>({ - graphqlQuery: ESCROW_EVENTS_QUERY, - graphqlQueryEnv: "TRUSTLEND_INDEXER_ESCROW_EVENTS_QUERY", - restPath: "/escrow-events", - variables, - restParams: { - lenderAddress: options.walletAddress, - ...buildRestPaginationParams(options), - }, - preferredKey: "escrowEvents", - }), - ]); - - return { - loans: loans.map(normalizeLoan).filter((loan) => loan.id), - reputationEvents: [], - escrowEvents: escrowEvents.map(normalizeEscrowEvent).filter((event) => event.loanId), - }; -} - -export async function getIndexedAdminReadModel( - limit?: number, - options?: PaginationOptions, -): Promise { - const pagination: PaginationOptions = { ...options, limit: limit ?? options?.limit ?? DEFAULT_LIMIT }; - const variables = buildPaginationVariables(pagination); - const restParams = buildRestPaginationParams(pagination); - - const loans = await readIndexed>({ - graphqlQuery: ADMIN_LOANS_QUERY, - graphqlQueryEnv: "TRUSTLEND_INDEXER_ADMIN_LOANS_QUERY", - restPath: "/loans", - variables, - restParams, - preferredKey: "loans", - }); - - return { - loans: loans.map(normalizeLoan).filter((loan) => loan.id), - reputationEvents: [], - escrowEvents: [], - }; -} diff --git a/lib/level5-feedback.ts b/lib/level5-feedback.ts deleted file mode 100644 index 3698444..0000000 --- a/lib/level5-feedback.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Level 5: User Feedback Implementation - * - * This file documents user feedback implementation from Level 5 onboarding phase. - * - * User Feedback Summary: - * - 5 unique users tested TrustLend (3 Borrowers, 2 Lenders) - * - Key feedback: Enhanced KYC security, improved lender protection, pool safety - * - Implementation: 5 commits addressing all critical feedback items - */ - -export const LEVEL5_USER_FEEDBACK = { - phase: 'User Onboarding & Feedback Collection', - startDate: '2026-04-22', - endDate: '2026-04-24', - usersOnboarded: 5, - feedbackImplemented: true, - criticalIssuesAddressed: 3, - commitIds: [ - 'daa8141', // KYC enhancement - '8c3b9d4', // Lender pool security - '2f5e1a6', // Documentation - '5d8c2b7', // Lender protection - '9e2f5c1' // Project roadmap - ] -}; diff --git a/lib/pool-security-config.ts b/lib/pool-security-config.ts deleted file mode 100644 index 5da2c12..0000000 --- a/lib/pool-security-config.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Lender Pool Security Enhancements - * - * This module contains security configurations for lender pool operations. - * Based on user feedback (Saurav Suman - Level 5 Testing): - * "Enhanced security for preserving lenders' interest in Pool section" - * - * Implemented features: - * - Enhanced interest rate protection - * - Pool deposit verification - * - Default risk management - * - Fund preservation protocols - */ - -export const POOL_SECURITY_CONFIG = { - enabled: true, - features: { - interestProtection: true, - depositVerification: true, - defaultRiskManagement: true, - fundPreservation: true - }, - level5Feedback: 'Saurav Suman', - feedbackTheme: 'Lender pool security and interest preservation', - implementationDate: '2026-04-24' -}; diff --git a/lib/scheduler/default-management.ts b/lib/scheduler/default-management.ts index 51f94b3..a93ebf8 100644 --- a/lib/scheduler/default-management.ts +++ b/lib/scheduler/default-management.ts @@ -15,7 +15,7 @@ * the kind of rare, high-impact operation that needs N-of-M human approval. * The automation's own key must be a REGISTERED SIGNER on the MultiSigAdmin * contract so it can propose; a human still has to approve + execute before - * any funds actually move. See `contracts/MULTISIG_ADMIN.md`. + * any funds actually move. See `docs/contracts/multisig-admin.md`. * * Every step is idempotent (guarded by Supabase state) and individually * error-handled so one bad loan never aborts the whole run. diff --git a/package-lock.json b/package-lock.json index dee6c3f..8a649df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "trustlend", "version": "0.1.0", "dependencies": { - "@albedo-link/intent": "^0.13.0", "@creit.tech/stellar-wallets-kit": "^2.5.0", "@radix-ui/react-tooltip": "^1.2.16", "@stellar/freighter-api": "^6.0.1", @@ -34,7 +33,6 @@ "@commitlint/config-conventional": "^19.8.0", "@commitlint/types": "^19.8.0", "@playwright/test": "^1.62.0", - "@subql/types-stellar": "^5.2.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pdfkit": "^0.17.6", @@ -45,7 +43,6 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.2.3", - "ethers": "^6.17.0", "husky": "^9.1.7", "tailwindcss": "^4", "tsx": "^4.19.2", @@ -59,12 +56,6 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, - "node_modules/@albedo-link/intent": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@albedo-link/intent/-/intent-0.13.0.tgz", - "integrity": "sha512-A8CBXqGQEBMXhwxNXj5inC6HLjyx5Do7jW99NOFeecYd1nPUq8gfM0tvoNoR8H8JQ11aTl9tyQBuu/+l3xeBnQ==", - "license": "MIT" - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4994,71 +4985,6 @@ "pnpm": ">=9.0.0" } }, - "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", - "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", - "buffer": "^6.0.3", - "sha.js": "^2.4.12" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-base/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@stellar/stellar-base/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@stellar/stellar-base/node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@stellar/stellar-base/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/@stellar/stellar-sdk": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.0.1.tgz", @@ -5085,116 +5011,6 @@ "node": ">=22.0.0" } }, - "node_modules/@subql/types-core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@subql/types-core/-/types-core-2.2.0.tgz", - "integrity": "sha512-aDA49e3mu0pC/AItemrBANBKXnbiZaKeImjkDN2lhEeyy8zercnXWKX0sRtXWyJsTsxe/dDwH/hykAUgx8bvOw==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "package-json-type": "^1.0.3", - "pino": "^6.13.3" - } - }, - "node_modules/@subql/types-core/node_modules/pino": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", - "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-redact": "^3.0.0", - "fast-safe-stringify": "^2.0.8", - "flatstr": "^1.0.12", - "pino-std-serializers": "^3.1.0", - "process-warning": "^1.0.0", - "quick-format-unescaped": "^4.0.3", - "sonic-boom": "^1.0.2" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/@subql/types-core/node_modules/pino-std-serializers": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", - "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@subql/types-core/node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@subql/types-core/node_modules/sonic-boom": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", - "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0", - "flatstr": "^1.0.12" - } - }, - "node_modules/@subql/types-stellar": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@subql/types-stellar/-/types-stellar-5.2.0.tgz", - "integrity": "sha512-chgnVwRLXysr6dXsNZVsHmAwWb4yC1uNxLfU+tWoRVFiWcykZF+0CfeAVBP3WS050cEVDK3w/NsIHvnrtMPYQw==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "@stellar/stellar-sdk": "^14.4.3", - "@subql/types-core": "^2.1.0" - } - }, - "node_modules/@subql/types-stellar/node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@subql/types-stellar/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@subql/types-stellar/node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@supabase/auth-js": { "version": "2.103.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz", @@ -7556,13 +7372,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -9702,85 +9511,6 @@ "node": ">=0.10.0" } }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true, - "license": "0BSD" - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT" - }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -9885,23 +9615,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-stable-stringify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", @@ -10000,13 +9713,6 @@ "node": ">=16" } }, - "node_modules/flatstr": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", - "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==", - "dev": true, - "license": "MIT" - }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -12672,13 +12378,6 @@ "node": ">=6" } }, - "node_modules/package-json-type": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/package-json-type/-/package-json-type-1.1.2.tgz", - "integrity": "sha512-QMy9vgEVIkHp/6LI0H7v6P+7ZFp0zQyPZfv8rGV/JoDhu63WMgzoQS34Pm+T0S5j/b9Zbkrx4SE8YOv2huou6Q==", - "dev": true, - "license": "MIT" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -13559,27 +13258,6 @@ "node": ">= 0.4" } }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "dev": true, - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/sha1": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", @@ -14227,21 +13905,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14255,13 +13918,6 @@ "node": ">=8.0" } }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "dev": true, - "license": "MIT" - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14756,13 +14412,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/utf-8-validate": { "version": "6.0.6", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", diff --git a/package.json b/package.json index 920db43..ab488ca 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "test:e2e": "playwright test" }, "dependencies": { - "@albedo-link/intent": "^0.13.0", "@creit.tech/stellar-wallets-kit": "^2.5.0", "@radix-ui/react-tooltip": "^1.2.16", "@stellar/freighter-api": "^6.0.1", @@ -52,7 +51,6 @@ "@commitlint/config-conventional": "^19.8.0", "@commitlint/types": "^19.8.0", "@playwright/test": "^1.62.0", - "@subql/types-stellar": "^5.2.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pdfkit": "^0.17.6", @@ -63,7 +61,6 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.2.3", - "ethers": "^6.17.0", "husky": "^9.1.7", "tailwindcss": "^4", "tsx": "^4.19.2", diff --git a/project.yaml b/project.yaml deleted file mode 100644 index 3673513..0000000 --- a/project.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# SubQuery Manifest File for Soroban Smart Contracts -# See docker-compose.subquery.yml for Docker-based local development. - -specVersion: 1.0.0 -name: trustlend-soroban-indexer -version: 0.1.0 -runner: - node: - name: '@subql/node-stellar' - version: '*' - query: - name: '@subql/query' - version: '*' -description: >- - Indexer to capture deposits, borrows, and liquidations from TrustLend Soroban contracts -repository: 'https://github.com/thisisouvik/trustlend-stellar' -schema: - file: ./schema.graphql - -network: - # Stellar Testnet passphrase and RPC endpoint - chainId: 'Test SDF Network ; September 2015' - endpoint: 'https://soroban-testnet.stellar.org' - # Genesis ledger to start indexing from - genesisHash: '0000000000000000000000000000000000000000000000000000000000000000' - -dataSources: - # ── Lending Contract ──────────────────────────────────────────────────── - - kind: stellar/Soroban - startBlock: 12345678 # TODO: Replace with actual lending contract deployment ledger height - mapping: - file: ./dist/index.js - handlers: - - handler: handleLendingEvent - kind: stellar/SorobanEventHandler - filter: - contractId: "NEXT_PUBLIC_LENDING_CONTRACT_ID" - topics: - - "loan" - - # ── Reputation Contract ───────────────────────────────────────────────── - - kind: stellar/Soroban - startBlock: 12345678 # TODO: Replace with actual reputation contract deployment ledger height - mapping: - file: ./dist/index.js - handlers: - - handler: handleReputationEvent - kind: stellar/SorobanEventHandler - filter: - contractId: "NEXT_PUBLIC_REPUTATION_CONTRACT_ID" - - # ── Escrow Contract ───────────────────────────────────────────────────── - - kind: stellar/Soroban - startBlock: 12345678 # TODO: Replace with actual escrow contract deployment ledger height - mapping: - file: ./dist/index.js - handlers: - - handler: handleEscrowEvent - kind: stellar/SorobanEventHandler - filter: - contractId: "NEXT_PUBLIC_ESCROW_CONTRACT_ID" diff --git a/schema.graphql b/schema.graphql deleted file mode 100644 index c156272..0000000 --- a/schema.graphql +++ /dev/null @@ -1,35 +0,0 @@ -# SubQuery Schema definition for TrustLend events -# TODO (SubQuery Indexer Migration): -# Define schema mappings for Deposit, Borrow, and Liquidation events. - -type Deposit @entity { - id: ID! # Transaction Hash + Event Index - poolId: String! @index - lenderAddress: String! @index - amount: BigInt! - ledgerHeight: Int! - timestamp: Date! -} - -type Borrow @entity { - id: ID! # Transaction Hash + Event Index - loanId: String! @index - borrowerAddress: String! @index - amount: BigInt! - durationDays: Int! - interestRateBps: Int! - totalDue: BigInt! - ledgerHeight: Int! - timestamp: Date! -} - -type Liquidation @entity { - id: ID! # Transaction Hash + Event Index - loanId: String! @index - borrowerAddress: String! @index - liquidatorAddress: String! - repaidAmount: BigInt! - collateralSeized: BigInt! - ledgerHeight: Int! - timestamp: Date! -} diff --git a/scripts/backup.sh b/scripts/backup.sh index 8cbf037..27ef952 100644 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -6,7 +6,7 @@ # AES-256 before it ever leaves the machine, uploads it to S3, verifies the # uploaded object, and prunes backups past the retention window. # -# Restore instructions live in DISASTER_RECOVERY.md. A backup nobody has +# Restore instructions live in docs/disaster-recovery.md. A backup nobody has # restored is a hypothesis, not a backup — please run the quarterly drill. # # Usage: @@ -35,7 +35,7 @@ BACKUP_S3_PREFIX="${BACKUP_S3_PREFIX:-backups}" BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" BACKUP_DRY_RUN="${BACKUP_DRY_RUN:-0}" # Comma-separated schemas to skip. Empty by default: a backup missing data is -# worse than one carrying extra. See DISASTER_RECOVERY.md for Supabase notes. +# worse than one carrying extra. See docs/disaster-recovery.md for Supabase notes. BACKUP_EXCLUDE_SCHEMAS="${BACKUP_EXCLUDE_SCHEMAS:-}" # Minimum plausible dump size; guards against silently archiving an empty file. BACKUP_MIN_BYTES="${BACKUP_MIN_BYTES:-1024}" @@ -46,7 +46,7 @@ fail() { printf '%s ERROR: %s\n' "[$(date -u +%H:%M:%S)]" "$*" >&2; exit 1; } require_env() { local name="$1" if [[ -z "${!name:-}" ]]; then - fail "$name is not set. See .env.example and DISASTER_RECOVERY.md." + fail "$name is not set. See .env.example and docs/disaster-recovery.md." fi } @@ -168,7 +168,7 @@ log " Uploaded and verified (${remote_bytes} bytes)." # ── 6. Retention ────────────────────────────────────────────────────────────── # An S3 lifecycle policy on the bucket is the more robust way to do this (it -# keeps working even if this job stops running) — see DISASTER_RECOVERY.md. +# keeps working even if this job stops running) — see docs/disaster-recovery.md. # This prune is a self-contained backstop for buckets without one. log "6/6 Pruning backups older than ${BACKUP_RETENTION_DAYS} days…" # No trailing 'Z': S3 reports LastModified as "...T00:00:00+00:00", and this is a diff --git a/scripts/liquidation-keeper.ts b/scripts/liquidation-keeper.ts index 0fa5adb..04a853f 100644 --- a/scripts/liquidation-keeper.ts +++ b/scripts/liquidation-keeper.ts @@ -27,7 +27,7 @@ // npm run liquidation:keeper -- --interval=60 # background service, poll every 60s // npm run liquidation:keeper:service # shorthand: poll every minute // POST /api/cron/liquidation (Vercel Cron, * * * * *) — deployed worker, see -// vercel.json + LIQUIDATION_KEEPER.md +// vercel.json + docs/liquidation-keeper.md // // ── Required env ───────────────────────────────────────────────────────────── // ADMIN_SECRET_KEY, NEXT_PUBLIC_LENDING_CONTRACT_ID, diff --git a/scripts/price-oracle-keeper.ts b/scripts/price-oracle-keeper.ts index ccc03a6..4331864 100644 --- a/scripts/price-oracle-keeper.ts +++ b/scripts/price-oracle-keeper.ts @@ -371,7 +371,7 @@ async function main(): Promise { if (!cfg.dryRun) { console.warn( "[price-oracle] On-chain publishing is not configured; running in dry-run mode. " + - "See ORACLE_PRICE_FEEDS.md for the set_asset_oracle_prices wiring.", + "See docs/oracle-price-feeds.md for the set_asset_oracle_prices wiring.", ); } diff --git a/sql/05_horizon_sync_schema.sql b/sql/05_horizon_sync_schema.sql deleted file mode 100644 index 5da3877..0000000 --- a/sql/05_horizon_sync_schema.sql +++ /dev/null @@ -1,44 +0,0 @@ -create table if not exists public.horizon_sync_state ( - id text primary key, - last_synced_ledger integer not null default 0, - last_synced_cursor text, - last_synced_at timestamptz, - consecutive_failures integer not null default 0, - last_error text, - updated_at timestamptz not null default now() -); - -create index if not exists idx_horizon_sync_state_updated_at on public.horizon_sync_state(updated_at desc); - -create table if not exists public.indexer_health ( - id boolean primary key default true, - last_successful_check timestamptz, - last_failed_check timestamptz, - consecutive_failures integer not null default 0, - is_degraded boolean not null default false, - updated_at timestamptz not null default now(), - constraint indexer_health_single_row check (id = true) -); - -drop trigger if exists trg_horizon_sync_state_updated_at on public.horizon_sync_state; -create trigger trg_horizon_sync_state_updated_at -before update on public.horizon_sync_state -for each row execute function public.set_updated_at(); - -drop trigger if exists trg_indexer_health_updated_at on public.indexer_health; -create trigger trg_indexer_health_updated_at -before update on public.indexer_health -for each row execute function public.set_updated_at(); - -insert into public.indexer_health (id) values (true) -on conflict (id) do nothing; - -alter table public.chain_events - add column if not exists ledger integer; - -create index if not exists idx_chain_events_ledger on public.chain_events(ledger); - -alter table public.loans - add column if not exists contract_loan_id text; - -create unique index if not exists idx_loans_contract_loan_id on public.loans(contract_loan_id); diff --git a/subquery/Dockerfile b/subquery/Dockerfile deleted file mode 100644 index 0d06943..0000000 --- a/subquery/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -# ────────────────────────────────────────────────────────────────────────────── -# TrustLend — SubQuery Soroban Indexer Dockerfile -# ────────────────────────────────────────────────────────────────────────────── -# Multi-stage build: -# 1. Install dependencies and build the SubQuery project (codegen + compile) -# 2. Run with the official @subql/node-stellar image -# ────────────────────────────────────────────────────────────────────────────── - -# ── Stage 1: Builder ────────────────────────────────────────────────────────── -FROM node:20-alpine AS builder - -WORKDIR /subquery - -# Install build toolchain -RUN apk add --no-cache python3 make g++ git - -# Copy SubQuery project files -COPY subquery/package.json subquery/tsconfig.json ./ -COPY subquery/src/ ./src/ -COPY project.yaml ./project.yaml -COPY schema.graphql ./schema.graphql - -# Install dependencies -RUN npm ci --ignore-scripts - -# Generate GraphQL types and compile TypeScript mappings -RUN npx subql codegen -RUN npx subql build - -# ── Stage 2: Runtime ────────────────────────────────────────────────────────── -FROM onfinality/subql-node-stellar:latest AS runner - -LABEL org.opencontainers.image.source="https://github.com/thisisouvik/trustlend-stellar" -LABEL org.opencontainers.image.description="TrustLend Soroban SubQuery Indexer" -LABEL org.opencontainers.image.licenses="MIT" - -# Copy the built project from the builder stage -COPY --from=builder /subquery/dist/ /app/dist/ -COPY --from=builder /subquery/project.yaml /app/project.yaml -COPY --from=builder /subquery/schema.graphql /app/schema.graphql - -# The official subql-node-stellar image expects the project at /app -WORKDIR /app - -# Default command — override via docker-compose or CLI -CMD ["-f=/app", "--db-schema=trustlend"] diff --git a/subquery/package.json b/subquery/package.json deleted file mode 100644 index 5fe574d..0000000 --- a/subquery/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "trustlend-soroban-indexer", - "version": "0.1.0", - "private": true, - "scripts": { - "codegen": "subql codegen", - "build": "subql build", - "start:docker": "docker compose -f ../docker-compose.subquery.yml pull && docker compose -f ../docker-compose.subquery.yml up --remove-orphans", - "dev": "subql codegen && subql build && docker compose -f ../docker-compose.subquery.yml up" - }, - "dependencies": { - "@subql/node-stellar": "^4.0.0", - "@subql/query": "^4.0.0", - "@subql/types-stellar": "^4.0.0", - "ethers": "^6.0.0", - "reflect-metadata": "^0.2.2" - }, - "devDependencies": { - "@subql/cli": "^4.0.0", - "typescript": "^5.0.0" - } -} diff --git a/subquery/src/mappings/handlers.ts b/subquery/src/mappings/handlers.ts deleted file mode 100644 index cd5d434..0000000 --- a/subquery/src/mappings/handlers.ts +++ /dev/null @@ -1,345 +0,0 @@ -// TrustLend — SubQuery Soroban Event Mapping Handlers -// -// Maps on-chain Soroban contract events to the GraphQL entities defined in -// schema.graphql. Handles events from three contracts: -// - Lending Contract (loans, payments, defaults) -// - Reputation Contract (scores, oracle updates, freezes) -// - Escrow Contract (deposits, withdrawals, transfers) -// -// Event topic formats follow the conventions in SOROBAN_INDEXER_MIGRATION.md. -// -// Amounts emitted by Soroban contracts are in stroops (1 XLM = 10_000_000 stroops). -// The read-model layer (lib/indexer/read-model.ts) converts them to XLM for display. - -import type { - SorobanEvent, -} from "@subql/types-stellar"; - -// ─── Type helpers ──────────────────────────────────────────────────────────── - -/** Decode a Soroban ScVal address to a G... string. */ -function addressToString(raw: unknown): string { - if (typeof raw === "string") return raw; - if (raw && typeof raw === "object" && "contractId" in (raw as Record)) { - return (raw as Record).contractId as string; - } - return String(raw ?? ""); -} - -/** Decode an i128 ScVal to a BigNumber. */ -function toBigNumber(raw: unknown): bigint { - if (typeof raw === "bigint") return raw; - if (typeof raw === "number") return BigInt(raw); - if (typeof raw === "string") return BigInt(raw); - return 0n; -} - -/** Decode a u32 ScVal to a number. */ -function toNumber(raw: unknown): number { - if (typeof raw === "number") return raw; - if (typeof raw === "bigint") return Number(raw); - if (typeof raw === "string") return parseInt(raw, 10); - return Number(raw ?? 0); -} - -/** Decode a symbol ScVal to a string. */ -function symbolToString(raw: unknown): string { - if (typeof raw === "string") return raw; - if (raw && typeof raw === "object" && "symbol" in (raw as Record)) { - return (raw as Record).symbol as string; - } - return String(raw ?? ""); -} - -// ─── Dispatch Handlers (exported — match project.yaml handler names) ─────── - -/** - * Dispatch handler for all Lending contract events. - * Registered in project.yaml as `handleLendingEvent` with filter contractId - * = NEXT_PUBLIC_LENDING_CONTRACT_ID and topic prefix "loan". - * Routes to sub-handlers based on the second topic element. - */ -export async function handleLendingEvent(rawEvent: SorobanEvent): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const event = rawEvent as any; - const { topic, data, ledger } = event; - const eventType = symbolToString(topic[1] ?? ""); - const params = extractParams(data); - - switch (eventType) { - case "request": - await handleLoanRequested(ledger, params); - break; - case "approved": - await handleLoanApproved(params); - break; - case "revoked": - await handleLoanRevoked(params); - break; - case "active": - await handleLoanActivated(params); - break; - case "payment": - await handleLoanPayment(params); - break; - case "default": - await handleLoanDefaulted(ledger, params); - break; - default: - logger.warn(`[lending] Unknown event type: ${eventType}`); - } -} - -/** - * Dispatch handler for all Reputation contract events. - * Registered in project.yaml as `handleReputationEvent` with filter - * contractId = NEXT_PUBLIC_REPUTATION_CONTRACT_ID. - */ -export async function handleReputationEvent(rawEvent: SorobanEvent): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const event = rawEvent as any; - const { topic, data } = event; - const eventType = symbolToString(topic[0] ?? ""); - const subType = symbolToString(topic[1] ?? ""); - const params = extractParams(data); - - const compoundType = `${eventType}.${subType}`; - - switch (compoundType) { - case "oracle.set": - await handleOracleSet(params); - break; - case "oracle.score": - await handleOracleScore(params); - break; - case "rep.event": - await handleRepEvent(params); - break; - case "rep.totals": - await handleRepTotals(params); - break; - case "rep.freeze": - await handleRepFreeze(params); - break; - default: - logger.warn(`[reputation] Unknown event type: ${compoundType}`); - } -} - -/** - * Dispatch handler for all Escrow contract events. - * Registered in project.yaml as `handleEscrowEvent` with filter - * contractId = NEXT_PUBLIC_ESCROW_CONTRACT_ID. - */ -export async function handleEscrowEvent(rawEvent: SorobanEvent): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const event = rawEvent as any; - const { topic, data, ledger } = event; - const eventType = symbolToString(topic[1] ?? ""); - const params = extractParams(data); - - switch (eventType) { - case "deposit": - await handleEscrowDeposit(ledger, params); - break; - case "withdraw": - await handleEscrowWithdraw(params); - break; - case "transfer": - await handleEscrowTransfer(params); - break; - default: - logger.warn(`[escrow] Unknown event type: ${eventType}`); - } -} - -// ─── Sub-handlers: Lending ──────────────────────────────────────────────────── - -/** - * Handle `(loan, request)` — (loan_id, borrower, amount, duration_days, - * interest_rate_bps, total_due, due_at) - */ -async function handleLoanRequested( - ledger: { sequence: number; timestamp?: number }, - params: unknown[], -): Promise { - const loanId = toNumber(params[0]); - const borrower = addressToString(params[1]); - const amount = toBigNumber(params[2]); - const durationDays = toNumber(params[3]); - const interestRateBps = toNumber(params[4]); - const totalDue = toBigNumber(params[5]); - - await store.set( - "Borrow", - `${ledger.sequence}-${loanId}`, - { - id: `${ledger.sequence}-${loanId}`, - loanId: loanId.toString(), - borrowerAddress: borrower, - amount, - durationDays, - interestRateBps, - totalDue, - ledgerHeight: ledger.sequence, - timestamp: new Date(ledger.timestamp ?? Date.now()), - }, - ); - - logger.info(`[loan] Requested #${loanId} by ${borrower.slice(0, 6)}…`); -} - -async function handleLoanApproved(params: unknown[]): Promise { - const loanId = toNumber(params[0]); - const lender = addressToString(params[1]); - const escrowId = toNumber(params[2]); - logger.info(`[loan] Approved #${loanId} by ${lender.slice(0, 6)}… (escrow #${escrowId})`); -} - -async function handleLoanRevoked(params: unknown[]): Promise { - const loanId = toNumber(params[0]); - logger.info(`[loan] Revoked #${loanId}`); -} - -async function handleLoanActivated(params: unknown[]): Promise { - const loanId = toNumber(params[0]); - logger.info(`[loan] Activated #${loanId}`); -} - -async function handleLoanPayment(params: unknown[]): Promise { - const loanId = toNumber(params[0]); - const amount = toBigNumber(params[1]); - const remainingDue = toBigNumber(params[2]); - const status = symbolToString(params[3]); - logger.info(`[loan] Payment #${loanId}: ${amount.toString()} stroops, status=${status}`); -} - -async function handleLoanDefaulted( - ledger: { sequence: number; timestamp?: number }, - params: unknown[], -): Promise { - const loanId = toNumber(params[0]); - - await store.set( - "Liquidation", - `liq-${ledger.sequence}-${loanId}`, - { - id: `liq-${ledger.sequence}-${loanId}`, - loanId: loanId.toString(), - borrowerAddress: "", - liquidatorAddress: "", - repaidAmount: 0n, - collateralSeized: 0n, - ledgerHeight: ledger.sequence, - timestamp: new Date(ledger.timestamp ?? Date.now()), - }, - ); - - logger.info(`[loan] Defaulted #${loanId}`); -} - -// ─── Sub-handlers: Reputation ─────────────────────────────────────────────── - -async function handleOracleSet(params: unknown[]): Promise { - const oracle = addressToString(params[0]); - logger.info(`[rep] Oracle set to ${oracle.slice(0, 6)}…`); -} - -async function handleOracleScore(params: unknown[]): Promise { - const borrower = addressToString(params[0]); - const creditScore = toNumber(params[1]); - const boostBps = toNumber(params[2]); - logger.info(`[rep] Oracle score for ${borrower.slice(0, 6)}…: ${creditScore} (boost ${boostBps} bps)`); -} - -async function handleRepEvent(params: unknown[]): Promise { - const borrower = addressToString(params[0]); - const eventType = symbolToString(params[1]); - const delta = toBigNumber(params[2]); - const newScore = toBigNumber(params[3]); - logger.info(`[rep] Event ${eventType} for ${borrower.slice(0, 6)}…: delta=${delta.toString()} score=${newScore.toString()}`); -} - -async function handleRepTotals(params: unknown[]): Promise { - const borrower = addressToString(params[0]); - const borrowedDelta = toBigNumber(params[1]); - const repaidDelta = toBigNumber(params[2]); - logger.info(`[rep] Totals for ${borrower.slice(0, 6)}…: borrowed=${borrowedDelta.toString()} repaid=${repaidDelta.toString()}`); -} - -async function handleRepFreeze(params: unknown[]): Promise { - const borrower = addressToString(params[0]); - const isFrozen = Boolean(params[1]); - logger.info(`[rep] ${isFrozen ? "Froze" : "Unfroze"} ${borrower.slice(0, 6)}…`); -} - -// ─── Sub-handlers: Escrow ─────────────────────────────────────────────────── - -async function handleEscrowDeposit( - ledger: { sequence: number; timestamp?: number }, - params: unknown[], -): Promise { - const lender = addressToString(params[0]); - const loanId = toNumber(params[1]); - const amount = toBigNumber(params[2]); - - await store.set( - "Deposit", - `dep-${ledger.sequence}-${loanId}`, - { - id: `dep-${ledger.sequence}-${loanId}`, - poolId: loanId.toString(), - lenderAddress: lender, - amount, - ledgerHeight: ledger.sequence, - timestamp: new Date(ledger.timestamp ?? Date.now()), - }, - ); - - logger.info(`[escrow] Deposit loan #${loanId} by ${lender.slice(0, 6)}…: ${amount.toString()} stroops`); -} - -async function handleEscrowWithdraw(params: unknown[]): Promise { - const lender = addressToString(params[0]); - const loanId = toNumber(params[1]); - const amount = toBigNumber(params[2]); - logger.info(`[escrow] Withdraw loan #${loanId} by ${lender.slice(0, 6)}…: ${amount.toString()} stroops`); -} - -async function handleEscrowTransfer(params: unknown[]): Promise { - const escrowId = toNumber(params[0]); - const loanId = toNumber(params[1]); - const borrower = addressToString(params[2]); - const amount = toBigNumber(params[3]); - logger.info(`[escrow] Transfer escrow #${escrowId} loan #${loanId} to ${borrower.slice(0, 6)}…: ${amount.toString()} stroops`); -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Extract the decoded ScVal parameters from a Soroban event's data payload. - * Soroban events emit a Vec; each element in the array is one parameter. - */ -function extractParams(data: unknown): unknown[] { - if (Array.isArray(data)) return data; - if (data && typeof data === "object") { - const record = data as Record; - // Some indexer SDKs wrap the params in a `values` or `data` field - if (Array.isArray(record.values)) return record.values; - if (Array.isArray(record.data)) return record.data; - } - return []; -} - -// ─── Re-export store and logger from SubQuery runtime ───────────────────────── -// These are injected by the SubQuery node at runtime. -interface Store { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - set(entity: string, id: string, data: any): Promise; -} -declare const store: Store; -declare const logger: { - info: (msg: string) => void; - warn: (msg: string) => void; - error: (msg: string) => void; -}; diff --git a/subquery/tsconfig.json b/subquery/tsconfig.json deleted file mode 100644 index 313e707..0000000 --- a/subquery/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "module": "commonjs", - "rootDir": "src", - "outDir": "dist", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vitest.config.ts b/vitest.config.ts index 8d63e63..5ea8eea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ test: { environment: "node", globals: true, + // Playwright specs live in e2e/ and must not be collected by vitest. + exclude: ["**/node_modules/**", "**/.next/**", "e2e/**", "contracts/**"], }, resolve: { alias: {