diff --git a/PR_SUMMARY.md b/PR_SUMMARY.md deleted file mode 100644 index 84c0669e..00000000 --- a/PR_SUMMARY.md +++ /dev/null @@ -1,174 +0,0 @@ -# PR Summary: Liquidation Bonus Cap, Contract Migration, and Disputes Management - -## Overview -This pull request addresses three critical issues in the RemitLend lending protocol: -1. **Liquidation Bonus Cap** - Prevents over-rewarding liquidators and potential pool insolvency -2. **Contract Upgrade Migration** - Ensures data integrity during contract version upgrades with idempotent migration guard -3. **Disputes Table & Management** - Enables dispute resolution workflow with borrower notifications - -## Changes by Issue - -### Issue 1: Add Liquidation Bonus Cap - -**Branch:** `fix/liquidation-and-disputes` -**Commit:** `2150d70` - -#### Problem -The liquidation bonus BPS (basis points) was admin-configurable with no upper bound. An admin mistake or governance exploit could set a 100% bonus, allowing liquidators to drain collateral beyond its value, leaving the lending pool insolvent. - -#### Solution -- Added `MAX_LIQUIDATION_BONUS_BPS = 2000` constant (20% cap). -- Updated `validate_liquidation_bonus_bps()` to enforce the cap with `InvalidConfiguration` error -- Added safety assertions in `liquidate()` function to ensure bonus never exceeds remaining collateral -- Added comprehensive test: `test_liquidation_bonus_cap_enforced` - -#### Files Modified -- `contracts/loan_manager/src/lib.rs` - Added constant, validation, and assertions -- `contracts/loan_manager/src/test.rs` - Added test case - -#### Test Results -✅ All 75 tests pass, including the new bonus cap test - ---- - -### Issue 2: Contract Upgrade Migration with Guard - -**Commit:** `2074163` - -#### Problem -The `migrate()` function existed but contained only a version bump with no actual data migration logic. More critically, there was no guard to prevent double-execution, which could corrupt data during contract upgrades with schema changes. - -#### Solution -- Created `MIGRATION.md` documenting the migration strategy and patterns -- Added `MigratedVersion` key to `DataKey` enum for tracking migration state -- Implemented idempotent migration guard that: - - Checks if already migrated to current version - - Returns early if version is up-to-date - - Prevents double-execution and data corruption -- Updated `migrate()` to initialize rate bounds (MIN_RATE_BPS, MAX_RATE_BPS) -- Added test: `test_migration_guard_prevents_double_execution` - -#### Files Modified -- `contracts/loan_manager/src/lib.rs` - Migration guard implementation -- `contracts/loan_manager/MIGRATION.md` - Migration strategy documentation (new file) -- `contracts/loan_manager/src/test.rs` - Migration guard test - -#### Test Results -✅ Migration test passes; guard correctly prevents double-execution -✅ Data remains readable after upgrade - ---- - -### Issue 3: Disputes Table Migration & Management Endpoints - -**Commit:** `05bad5e` - -#### Problem -The dispute system lacked: -- Proper database schema with `admin_note` column -- Missing indexes for efficient querying -- No borrower notification when disputes are resolved -- Incomplete endpoint documentation - -#### Solution -- Updated disputes table migration to include: - - `admin_note` column for admin comments visible to borrowers - - Indexes on `status`, `borrower`, and `loan_id` for efficient queries -- Enhanced `adminDisputeController` with: - - Borrower notification service integration - - Admin note parameter in resolution endpoint - - Separate notification types for "confirm" vs "reverse" actions - - Error handling to prevent notification failures from blocking resolution -- Updated Swagger documentation with new parameter details - -#### Files Modified -- `backend/migrations/1784000000014_add-loan-disputes.js` - Added indexes and admin_note -- `backend/src/controllers/adminDisputeController.ts` - Added notification logic -- `backend/src/routes/adminRoutes.ts` - Updated Swagger documentation - -#### Features -- ✅ Admin can mark disputes as resolved with detailed reason -- ✅ Admin can add optional note visible to borrower -- ✅ Borrower receives notification with dispute outcome -- ✅ Dispute resolution includes automatic loan event logging (DefaultConfirmed/DefaultReversed) -- ✅ Database indexes enable efficient dispute lookups - ---- - -## Test Summary - -### Contract Tests (loan_manager) -``` -test result: ok. 75 passed; 0 failed -``` - -**New Tests Added:** -- ✅ `test_liquidation_bonus_cap_enforced` - Verifies 20% bonus cap enforcement -- ✅ `test_migration_guard_prevents_double_execution` - Verifies idempotent migrations - -**Existing Tests:** All 73 existing tests continue to pass - ---- - -## Breaking Changes -None. This PR is fully backward compatible. - ---- - -## Deployment Notes - -### Contract Upgrade -1. Deploy new LoanManager contract (v4) -2. Call `migrate()` function as admin to initialize new fields and activate migration guard -3. Subsequent calls to `migrate()` will be no-op (idempotent) - -### Database Migration -1. Run `1784000000014_add-loan-disputes.js` migration -2. Existing dispute data will be preserved; new indexes will be created - -### Configuration -No additional configuration required. Defaults are: -- MAX_LIQUIDATION_BONUS_BPS: 2000 (20%) -- MIN_RATE_BPS: 1 (0.01%) -- MAX_RATE_BPS: 100_000 (1000%) - ---- - -## Security Considerations - -### Liquidation Bonus Cap -- **Risk Mitigated:** Admin-controlled incentive attack draining pool collateral -- **Implementation:** Hard cap enforced at contract level, not just configuration -- **Testing:** Cap verified with multiple liquidation scenarios - -### Migration Guard -- **Risk Mitigated:** Data corruption from accidental multiple migrations -- **Implementation:** Version-based idempotent guard prevents re-execution -- **Testing:** Verified that multiple migration calls don't change state - -### Dispute Notifications -- **Privacy:** Borrower address used as user ID for notification delivery -- **Reliability:** Notification failures don't block dispute resolution -- **Audit:** All disputes logged with timestamps and admin notes - ---- - -## Future Improvements -1. Add pagination to dispute listing endpoint -2. Implement dispute appeal workflow -3. Add analytics dashboard for dispute trends -4. Create automated dispute resolution suggestions using ML - ---- - -## References -- Issue #[liquidation-bonus-cap] -- Issue #[contract-migration] -- Issue #[disputes-management] - ---- - -**Branch:** fix/liquidation-and-disputes -**Author:** Automated Issue Resolution -**Test Status:** ✅ All tests passing -**Ready for Review:** Yes diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 5ccd8b85..b6761c5a 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -224,3 +224,127 @@ describe('Auth API', () => { }); }); }); + +describe('authService unit tests', () => { + let authService: typeof import('../services/authService.js'); + + beforeAll(async () => { + authService = await import('../services/authService.js'); + }); + + describe('verifySignature', () => { + it('should return true for valid signature', () => { + const keypair = Keypair.random(); + const message = 'test message'; + const signature = keypair.sign(Buffer.from(message, 'utf-8')).toString('base64'); + + const result = authService.verifySignature(keypair.publicKey(), message, signature); + expect(result).toBe(true); + }); + + it('should return false for wrong signer', () => { + const keypair1 = Keypair.random(); + const keypair2 = Keypair.random(); + const message = 'test message'; + const signature = keypair1.sign(Buffer.from(message, 'utf-8')).toString('base64'); + + const result = authService.verifySignature(keypair2.publicKey(), message, signature); + expect(result).toBe(false); + }); + + it('should return false for non-64-byte signature', () => { + const keypair = Keypair.random(); + const message = 'test message'; + const invalidSignature = Buffer.from('short').toString('base64'); + + const result = authService.verifySignature(keypair.publicKey(), message, invalidSignature); + expect(result).toBe(false); + }); + + it('should return false for non-base64 input', () => { + const keypair = Keypair.random(); + const message = 'test message'; + const invalidSignature = '!!!not-base64!!!'; + + const result = authService.verifySignature(keypair.publicKey(), message, invalidSignature); + expect(result).toBe(false); + }); + + it('should return false for invalid public key', () => { + const message = 'test message'; + const signature = Buffer.from('a'.repeat(64)).toString('base64'); + + const result = authService.verifySignature('INVALID_KEY', message, signature); + expect(result).toBe(false); + }); + }); + + describe('verifyChallengeTimestamp', () => { + it('should accept timestamp at the window edge', () => { + const maxAge = 5 * 60 * 1000; // 5 minutes + const timestamp = Date.now() - maxAge; + + const result = authService.verifyChallengeTimestamp(timestamp, maxAge); + expect(result).toBe(true); + }); + + it('should accept timestamp under the window', () => { + const maxAge = 5 * 60 * 1000; + const timestamp = Date.now() - 1000; // 1 second ago + + const result = authService.verifyChallengeTimestamp(timestamp, maxAge); + expect(result).toBe(true); + }); + + it('should reject timestamp over the window', () => { + const maxAge = 5 * 60 * 1000; + const timestamp = Date.now() - maxAge - 1000; // 1 second too old + + const result = authService.verifyChallengeTimestamp(timestamp, maxAge); + expect(result).toBe(false); + }); + + it('should accept future timestamp within tolerance', () => { + const maxAge = 5 * 60 * 1000; + const timestamp = Date.now() + 1000; // 1 second in future + + const result = authService.verifyChallengeTimestamp(timestamp, maxAge); + expect(result).toBe(true); + }); + }); + + describe('extractBearerToken', () => { + it('should extract token from valid Bearer header', () => { + const token = 'my-jwt-token'; + const header = `Bearer ${token}`; + + const result = authService.extractBearerToken(header); + expect(result).toBe(token); + }); + + it('should return null for undefined header', () => { + const result = authService.extractBearerToken(undefined); + expect(result).toBeNull(); + }); + + it('should return null for wrong scheme', () => { + const result = authService.extractBearerToken('Basic dGVzdA=='); + expect(result).toBeNull(); + }); + + it('should return null for malformed Bearer with no token', () => { + const result = authService.extractBearerToken('Bearer'); + expect(result).toBeNull(); + }); + + it('should return null for lowercase bearer', () => { + const result = authService.extractBearerToken('bearer my-token'); + expect(result).toBeNull(); + }); + + it('should return null for wrong part count', () => { + const result = authService.extractBearerToken('Bearer token extra'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/backend/src/__tests__/database.test.ts b/backend/src/__tests__/database.test.ts index 84ccd296..7d8de748 100644 --- a/backend/src/__tests__/database.test.ts +++ b/backend/src/__tests__/database.test.ts @@ -267,6 +267,26 @@ describeIf('Database Services', () => { expect(events.length).toBeGreaterThan(0); }); + it('should return null when creating duplicate event_id', async () => { + const input = { + event_id: 'event_duplicate_test', + event_type: 'LoanRequested', + contract_id: 'CONTRACT_TEST', + tx_hash: 'tx_hash_duplicate', + ledger: 12348, + ledger_closed_at: new Date(), + }; + + // First insert should succeed + const firstEvent = await IndexedEventsService.create(input); + expect(firstEvent).toBeDefined(); + expect(firstEvent?.event_id).toBe('event_duplicate_test'); + + // Second insert with same event_id should return null + const duplicateEvent = await IndexedEventsService.create(input); + expect(duplicateEvent).toBeNull(); + }); + afterAll(async () => { await query('DELETE FROM indexed_events WHERE event_id LIKE $1', ['event_%']); }); diff --git a/backend/src/services/README.md b/backend/src/services/README.md index 83485e17..66560519 100644 --- a/backend/src/services/README.md +++ b/backend/src/services/README.md @@ -1,5 +1,79 @@ # Services +## Overview + +The `services/` layer contains the core business logic of the RemitLend backend. Each service encapsulates a specific domain concern and provides a clean interface for controllers, middleware, and background jobs. Services handle database access, external API calls (blockchain, cache), and stateful operations like webhook retries and score reconciliation. + +### Services Index + +| Service | Responsibility | Key Entry Points | +| ------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **authService** | JWT generation/verification, signature validation, challenge flow | `generateChallenge()`, `verifySignature()`, `generateJwtToken()`, `verifyJwtToken()`, `revokeToken()` | +| **cacheService** | Redis wrapper for key-value caching | `get()`, `set()`, `delete()`, `setNotExists()` | +| **databaseService** | User profiles, loan history, indexed events CRUD | `UserProfileService.*`, `LoanHistoryService.*`, `IndexedEventsService.*` | +| **eventIndexer** | Polls Stellar RPC for contract events, stores in PostgreSQL | `startIndexing()`, `stopIndexing()` (via IndexerManager) | +| **indexerManager** | Lifecycle management for the event indexer | `start()`, `stop()` | +| **eventStreamService** | SSE stream of loan events for frontend real-time updates | `getEventStream()` | +| **defaultChecker** | Background scheduler that calls on-chain `check_defaults` for overdue loans | Runs automatically on interval | +| **scoresService** | Bulk credit score updates and reconciliation | `updateUserScoresBulk()`, `setAbsoluteUserScoresBulk()` | +| **scoreReconciliationService** | Compares DB scores vs on-chain, auto-corrects divergence | Runs automatically on interval | +| **scoreDecayService** | Score decay logic (not currently scheduled) | `applyDecay()` | +| **sorobanService** | Stellar/Soroban contract interaction (read/write) | `getContract()`, `submitTransaction()` | +| **webhookService** | Deliver webhooks to registered URLs with signature | `deliverWebhook()`, `retryFailedWebhook()` | +| **webhookRetryScheduler** | Background scheduler for exponential backoff webhook retries | Runs automatically every 60s | +| **webhookRetryProcessor** | Alternative webhook retry implementation (not currently used) | — | +| **notificationService** | In-app notifications, digests, cleanup | `createNotification()`, `sendDigest()`, cleanup runs every 24h | +| **remittanceService** | Remittance NFT operations and queries | `createRemittance()`, `getRemittancesByUser()` | +| **rateLimitService** | Track and enforce rate limits by key | `checkRateLimit()`, `incrementCounter()` | +| **auditLogService** | Record audit trail for sensitive operations | `logAction()` | +| **jobMetricsService** | Track success/failure metrics for background jobs | `recordJobRun()`, `getJobMetrics()` | +| **yieldHistoryService** | Query and aggregate yield data for lenders | `getYieldHistory()`, `calculateAPY()` | + +### Background Schedulers + +Several services run as background jobs that are started in `index.ts` when the API process launches: + +- **Event Indexer**: Continuous poll (configurable via `INDEXER_POLL_INTERVAL_MS`, default 30s) +- **Default Checker**: Interval (configurable via `DEFAULT_CHECK_INTERVAL_MS`, default 30m) +- **Webhook Retry Scheduler**: Fixed 60s interval +- **Score Reconciliation**: Interval (configurable via `SCORE_RECONCILIATION_INTERVAL_MS`, default 1h) +- **Notification Cleanup**: Fixed 24h interval (retention controlled by `NOTIFICATION_RETENTION_DAYS`, `READ_NOTIFICATION_RETENTION_DAYS`) +- **Loan Due Check**: Cron `0 * * * *` (top of every hour) + +See the [Background Jobs table in the original README](#background-jobs) for full details on env vars and behavior. + +### Environment Variables + +Services read configuration from `.env`. Key variables: + +- `JWT_SECRET`: HMAC secret for JWT signing +- `REDIS_URL`: Redis connection string for caching +- `DATABASE_URL`: PostgreSQL connection string +- `STELLAR_NETWORK`, `STELLAR_RPC_URL`, `STELLAR_NETWORK_PASSPHRASE`: Blockchain config +- `LOAN_MANAGER_CONTRACT_ID`, `LENDING_POOL_CONTRACT_ID`, etc.: Contract addresses +- `INDEXER_POLL_INTERVAL_MS`, `INDEXER_BATCH_SIZE`: Event indexer tuning +- `DEFAULT_CHECK_INTERVAL_MS`: Default checker frequency +- `SCORE_RECONCILIATION_INTERVAL_MS`: Reconciliation frequency +- `NOTIFICATION_RETENTION_DAYS`, `READ_NOTIFICATION_RETENTION_DAYS`: Notification cleanup + +Refer to `backend/.env.example` for the full list. + +### Testing + +Most services have corresponding test files in `services/__tests__/`. Tests use Jest and mock external dependencies (database, Redis, Stellar RPC). Run: + +```bash +npm test -- services/ +``` + +### Related Documentation + +- [Event Indexer deep-dive](#event-indexer-service) (below) +- [Indexer Recovery Runbook](../../docs/runbooks/indexer-recovery.md) +- [Webhooks Guide](../../docs/webhooks.md) + +--- + ## Event Indexer Service ## Overview diff --git a/backend/src/services/__tests__/scoresService-additions.test.ts b/backend/src/services/__tests__/scoresService-additions.test.ts new file mode 100644 index 00000000..021dfdf0 --- /dev/null +++ b/backend/src/services/__tests__/scoresService-additions.test.ts @@ -0,0 +1,156 @@ +/** + * Additional tests for scoresService - clamping and setAbsoluteUserScoresBulk + */ + +import { jest, describe, it, expect, beforeAll, beforeEach } from '@jest/globals'; +import type { PoolClient } from '../../db/connection.js'; + +type QueryFn = (sql: string, params?: unknown[]) => Promise<{ rows: never[]; rowCount: number }>; +type DeleteFn = (key: string) => Promise; + +let updateUserScoresBulk: (updates: Map, client?: PoolClient) => Promise; +let setAbsoluteUserScoresBulk: (scores: Map) => Promise; +let mockQuery: jest.MockedFunction; +let mockLoggerError: jest.Mock; +let mockCacheDelete: jest.MockedFunction; + +beforeAll(async () => { + mockQuery = jest.fn(async () => ({ + rows: [], + rowCount: 1, + })) as jest.MockedFunction; + mockLoggerError = jest.fn(); + mockCacheDelete = jest.fn(async () => undefined) as jest.MockedFunction; + + jest.unstable_mockModule('../../db/connection.js', () => ({ + query: mockQuery, + getClient: jest.fn(), + withTransaction: jest.fn(), + TRANSIENT_ERROR_CODES: new Set(), + })); + + jest.unstable_mockModule('../../utils/logger.js', () => { + const mockLogger = { + info: jest.fn(), + error: mockLoggerError, + warn: jest.fn(), + debug: jest.fn(), + withContext: jest.fn(), + }; + mockLogger.withContext.mockImplementation(() => mockLogger); + return { default: mockLogger }; + }); + + jest.unstable_mockModule('../cacheService.js', () => ({ + cacheService: { + delete: mockCacheDelete, + get: jest.fn(async () => null), + set: jest.fn(async () => undefined), + setNotExists: jest.fn(async () => true), + close: jest.fn(async () => undefined), + }, + })); + + const mod = await import('../scoresService.js'); + updateUserScoresBulk = mod.updateUserScoresBulk; + setAbsoluteUserScoresBulk = mod.setAbsoluteUserScoresBulk; +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockQuery.mockResolvedValue({ rows: [], rowCount: 1 }); +}); + +describe('updateUserScoresBulk clamping', () => { + it('clamps insert score to 300 minimum', async () => { + await updateUserScoresBulk(new Map([['user_low', -250]])); + + const [sql] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('GREATEST(300,'); + }); + + it('clamps insert score to 850 maximum', async () => { + await updateUserScoresBulk(new Map([['user_high', 400]])); + + const [sql] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('LEAST(850,'); + }); + + it('clamps update score within 300-850 range', async () => { + await updateUserScoresBulk(new Map([['user_existing', 100]])); + + const [sql] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('LEAST(850, GREATEST(300,'); + }); +}); + +describe('setAbsoluteUserScoresBulk', () => { + it('is a noop for empty map', async () => { + await setAbsoluteUserScoresBulk(new Map()); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('is a noop for map with only empty user IDs', async () => { + await setAbsoluteUserScoresBulk(new Map([['', 700]])); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('generates correct value placeholders for single user', async () => { + await setAbsoluteUserScoresBulk(new Map([['user1', 650]])); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + + expect(sql).toContain('WITH reconciled_scores'); + expect(sql).toContain('VALUES ($1, $2)'); + expect(params).toEqual(['user1', 650]); + }); + + it('generates correct value placeholders for multiple users', async () => { + await setAbsoluteUserScoresBulk( + new Map([ + ['alice', 750], + ['bob', 550], + ]), + ); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + + expect(sql).toContain('WITH reconciled_scores'); + expect(params).toContain('alice'); + expect(params).toContain(750); + expect(params).toContain('bob'); + expect(params).toContain(550); + }); + + it('invalidates cache for reconciled users', async () => { + await setAbsoluteUserScoresBulk(new Map([['user1', 600]])); + + expect(mockCacheDelete).toHaveBeenCalledWith('score:userId:user1'); + expect(mockCacheDelete).toHaveBeenCalledWith('score:breakdown:user1'); + }); + + it('propagates errors and logs them', async () => { + mockQuery.mockRejectedValueOnce(new Error('reconciliation db error')); + + await expect(setAbsoluteUserScoresBulk(new Map([['user1', 600]]))).rejects.toThrow( + 'reconciliation db error', + ); + + expect(mockLoggerError).toHaveBeenCalledWith( + 'Failed to apply absolute user score reconciliation updates', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('sets absolute values without clamping', async () => { + await setAbsoluteUserScoresBulk(new Map([['user1', 999]])); + + const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + + expect(sql).not.toContain('LEAST'); + expect(sql).not.toContain('GREATEST'); + expect(params).toContain(999); + }); +}); diff --git a/backend/src/services/__tests__/scoresService.test.ts b/backend/src/services/__tests__/scoresService.test.ts index c7f7e303..bdc69267 100644 --- a/backend/src/services/__tests__/scoresService.test.ts +++ b/backend/src/services/__tests__/scoresService.test.ts @@ -105,7 +105,7 @@ describe('updateUserScoresBulk', () => { expect(mockQuery).not.toHaveBeenCalled(); }); - it.skip('calls pool query with correct placeholders for a single user', async () => { + it('calls pool query with correct placeholders for a single user', async () => { await updateUserScoresBulk(new Map([['user1', 10]])); expect(mockQuery).toHaveBeenCalledTimes(1); @@ -116,7 +116,7 @@ describe('updateUserScoresBulk', () => { expect(params).toEqual(['user1', 10]); }, 20000); - it.skip('calls pool query for multiple users in a single statement', async () => { + it('calls pool query for multiple users in a single statement', async () => { const updates = new Map([ ['alice', 15], ['bob', -20], diff --git a/backend/src/services/databaseService.ts b/backend/src/services/databaseService.ts deleted file mode 100644 index 2bba7f97..00000000 --- a/backend/src/services/databaseService.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { query, getClient } from '../db/connection.js'; -import type { PoolClient } from 'pg'; - -export interface UserProfile { - id: number; - public_key: string; - display_name?: string; - email?: string; - created_at: Date; - updated_at: Date; - metadata?: Record; -} - -export interface CreateUserProfileInput { - public_key: string; - display_name?: string; - email?: string; - metadata?: Record; -} - -export interface UpdateUserProfileInput { - display_name?: string; - email?: string; - metadata?: Record; -} - -export class UserProfileService { - static async create(input: CreateUserProfileInput): Promise { - const result = await query( - `INSERT INTO user_profiles (public_key, display_name, email, metadata) - VALUES ($1, $2, $3, $4) - RETURNING *`, - [input.public_key, input.display_name, input.email, input.metadata], - ); - return result.rows[0] as UserProfile; - } - - static async findByPublicKey(publicKey: string): Promise { - const result = await query(`SELECT * FROM user_profiles WHERE public_key = $1`, [publicKey]); - return (result.rows[0] as UserProfile) || null; - } - - static async findById(id: number): Promise { - const result = await query(`SELECT * FROM user_profiles WHERE id = $1`, [id]); - return (result.rows[0] as UserProfile) || null; - } - - static async update( - publicKey: string, - input: UpdateUserProfileInput, - ): Promise { - const updates: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (input.display_name !== undefined) { - updates.push(`display_name = $${paramIndex++}`); - values.push(input.display_name); - } - if (input.email !== undefined) { - updates.push(`email = $${paramIndex++}`); - values.push(input.email); - } - if (input.metadata !== undefined) { - updates.push(`metadata = $${paramIndex++}`); - values.push(input.metadata); - } - - if (updates.length === 0) { - return this.findByPublicKey(publicKey); - } - - updates.push(`updated_at = CURRENT_TIMESTAMP`); - values.push(publicKey); - - const result = await query( - `UPDATE user_profiles SET ${updates.join(', ')} WHERE public_key = $${paramIndex} RETURNING *`, - values, - ); - return (result.rows[0] as UserProfile) || null; - } - - static async delete(publicKey: string): Promise { - const result = await query(`DELETE FROM user_profiles WHERE public_key = $1`, [publicKey]); - return (result.rowCount ?? 0) > 0; - } - - static async upsert(input: CreateUserProfileInput): Promise { - const result = await query( - `INSERT INTO user_profiles (public_key, display_name, email, metadata) - VALUES ($1, $2, $3, $4) - ON CONFLICT (public_key) - DO UPDATE SET - display_name = COALESCE($2, user_profiles.display_name), - email = COALESCE($3, user_profiles.email), - metadata = COALESCE($4, user_profiles.metadata), - updated_at = CURRENT_TIMESTAMP - RETURNING *`, - [input.public_key, input.display_name, input.email, input.metadata], - ); - return result.rows[0] as UserProfile; - } -} - -export interface LoanHistory { - id: number; - loan_id: number; - borrower_public_key: string; - lender_public_key?: string; - principal_amount: number; - interest_rate_bps: number; - principal_paid: number; - interest_paid: number; - accrued_interest: number; - status: string; - due_date?: Date; - requested_at?: Date; - approved_at?: Date; - repaid_at?: Date; - defaulted_at?: Date; - created_at: Date; - updated_at: Date; - metadata?: Record; -} - -export interface CreateLoanHistoryInput { - loan_id: number; - borrower_public_key: string; - lender_public_key?: string; - principal_amount: number; - interest_rate_bps: number; - status: string; - due_date?: Date; - requested_at?: Date; - metadata?: Record; -} - -export interface UpdateLoanHistoryInput { - principal_paid?: number; - interest_paid?: number; - accrued_interest?: number; - status?: string; - approved_at?: Date; - repaid_at?: Date; - defaulted_at?: Date; - metadata?: Record; -} - -export class LoanHistoryService { - static async create(input: CreateLoanHistoryInput): Promise { - const result = await query( - `INSERT INTO loan_history - (loan_id, borrower_public_key, lender_public_key, principal_amount, - interest_rate_bps, status, due_date, requested_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *`, - [ - input.loan_id, - input.borrower_public_key, - input.lender_public_key, - input.principal_amount, - input.interest_rate_bps, - input.status, - input.due_date, - input.requested_at, - input.metadata, - ], - ); - return result.rows[0] as LoanHistory; - } - - static async findByLoanId(loanId: number): Promise { - const result = await query(`SELECT * FROM loan_history WHERE loan_id = $1`, [loanId]); - return (result.rows[0] as LoanHistory) || null; - } - - static async findByBorrower(publicKey: string, limit = 50, offset = 0): Promise { - const result = await query( - `SELECT * FROM loan_history - WHERE borrower_public_key = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, - [publicKey, limit, offset], - ); - return result.rows as LoanHistory[]; - } - - static async findByLender(publicKey: string, limit = 50, offset = 0): Promise { - const result = await query( - `SELECT * FROM loan_history - WHERE lender_public_key = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, - [publicKey, limit, offset], - ); - return result.rows as LoanHistory[]; - } - - static async update(loanId: number, input: UpdateLoanHistoryInput): Promise { - const updates: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (input.principal_paid !== undefined) { - updates.push(`principal_paid = $${paramIndex++}`); - values.push(input.principal_paid); - } - if (input.interest_paid !== undefined) { - updates.push(`interest_paid = $${paramIndex++}`); - values.push(input.interest_paid); - } - if (input.accrued_interest !== undefined) { - updates.push(`accrued_interest = $${paramIndex++}`); - values.push(input.accrued_interest); - } - if (input.status !== undefined) { - updates.push(`status = $${paramIndex++}`); - values.push(input.status); - } - if (input.approved_at !== undefined) { - updates.push(`approved_at = $${paramIndex++}`); - values.push(input.approved_at); - } - if (input.repaid_at !== undefined) { - updates.push(`repaid_at = $${paramIndex++}`); - values.push(input.repaid_at); - } - if (input.defaulted_at !== undefined) { - updates.push(`defaulted_at = $${paramIndex++}`); - values.push(input.defaulted_at); - } - if (input.metadata !== undefined) { - updates.push(`metadata = $${paramIndex++}`); - values.push(input.metadata); - } - - if (updates.length === 0) { - return this.findByLoanId(loanId); - } - - updates.push(`updated_at = CURRENT_TIMESTAMP`); - values.push(loanId); - - const result = await query( - `UPDATE loan_history SET ${updates.join(', ')} WHERE loan_id = $${paramIndex} RETURNING *`, - values, - ); - return (result.rows[0] as LoanHistory) || null; - } - - static async findByStatus(status: string, limit = 50, offset = 0): Promise { - const result = await query( - `SELECT * FROM loan_history - WHERE status = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, - [status, limit, offset], - ); - return result.rows as LoanHistory[]; - } -} - -export interface IndexedEvent { - id: number; - event_id: string; - event_type: string; - contract_id: string; - tx_hash: string; - ledger: number; - ledger_closed_at: Date; - topics?: Record; - value?: string; - processed: boolean; - created_at: Date; - updated_at: Date; -} - -export interface CreateIndexedEventInput { - event_id: string; - event_type: string; - contract_id: string; - tx_hash: string; - ledger: number; - ledger_closed_at: Date; - topics?: Record; - value?: string; -} - -export class IndexedEventsService { - static async create(input: CreateIndexedEventInput): Promise { - const result = await query( - `INSERT INTO indexed_events - (event_id, event_type, contract_id, tx_hash, ledger, ledger_closed_at, topics, value) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (event_id) DO NOTHING - RETURNING *`, - [ - input.event_id, - input.event_type, - input.contract_id, - input.tx_hash, - input.ledger, - input.ledger_closed_at, - input.topics, - input.value, - ], - ); - return result.rows[0] as IndexedEvent; - } - - static async findById(id: number): Promise { - const result = await query(`SELECT * FROM indexed_events WHERE id = $1`, [id]); - return (result.rows[0] as IndexedEvent) || null; - } - - static async findByEventId(eventId: string): Promise { - const result = await query(`SELECT * FROM indexed_events WHERE event_id = $1`, [eventId]); - return (result.rows[0] as IndexedEvent) || null; - } - - static async findUnprocessed(limit = 100): Promise { - const result = await query( - `SELECT * FROM indexed_events - WHERE processed = false - ORDER BY ledger ASC - LIMIT $1`, - [limit], - ); - return result.rows as IndexedEvent[]; - } - - static async markProcessed(eventId: string): Promise { - const result = await query( - `UPDATE indexed_events - SET processed = true, updated_at = CURRENT_TIMESTAMP - WHERE event_id = $1`, - [eventId], - ); - return (result.rowCount ?? 0) > 0; - } - - static async findByTxHash(txHash: string): Promise { - const result = await query( - `SELECT * FROM indexed_events WHERE tx_hash = $1 ORDER BY ledger ASC`, - [txHash], - ); - return result.rows as IndexedEvent[]; - } - - static async findByContract(contractId: string, limit = 50, offset = 0): Promise { - const result = await query( - `SELECT * FROM indexed_events - WHERE contract_id = $1 - ORDER BY ledger DESC - LIMIT $2 OFFSET $3`, - [contractId, limit, offset], - ); - return result.rows as IndexedEvent[]; - } - - static async deleteByLedgerRange(startLedger: number, endLedger: number): Promise { - const result = await query(`DELETE FROM indexed_events WHERE ledger >= $1 AND ledger <= $2`, [ - startLedger, - endLedger, - ]); - return result.rowCount ?? 0; - } -} - -export class DatabaseService { - static async withTransaction(callback: (client: PoolClient) => Promise): Promise { - const client = await getClient(); - try { - await client.query('BEGIN'); - const result = await callback(client); - await client.query('COMMIT'); - return result; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - static async healthCheck(): Promise { - try { - const result = await query('SELECT 1'); - return result.rowCount === 1; - } catch { - return false; - } - } -} diff --git a/pr.md b/pr.md deleted file mode 100644 index 0dc3e07b..00000000 --- a/pr.md +++ /dev/null @@ -1,58 +0,0 @@ -## Summary - -This PR resolves several infrastructure, testing, and documentation issues to improve the repository's baseline health before release. It adds automated load testing, container vulnerability scanning, issue/PR templates, and a security disclosure policy. - -## Type of change - -- [x] Bug fix -- [x] New feature -- [x] Documentation update -- [x] Refactor / chore -- [ ] Smart contract change - -## Related issue - -Closes #905 -Closes #906 -Closes #907 -Closes #908 - -## Changes - -- Added `baseline.js` k6 script and `loadtest.yml` workflow for manual performance testing. -- Added Trivy container scanning to `deploy-staging.yml` to fail on CRITICAL vulnerabilities. -- Added `.trivyignore` and `docs/wiki/security-scanning.md`. -- Added GitHub issue templates (Bug, Feature, Security, Config) and a PR template. -- Added `SECURITY.md` defining our disclosure policy and linked it from `README.md`. - -### Backend Fixes - -1. **Event Indexer Test Stability**: - * Aligned the test expectation in `eventIndexer.test.ts` with the observed behavior in CI (handling separate score update calls). - * Ensured all required Jest globals are explicitly imported for ESM compatibility. - -2. **ESM Connection Exports**: - * Fixed a `SyntaxError` where `getClient` was not recognized as an export from `connection.js` by making exports more explicit. This resolves failures in controller tests (e.g., `poolController`). - -## Verification - -- **Frontend**: Manual testing of forms (precision) and logout flow. -- **Backend**: Verified manual code review of indexer logic and connection exports. - -Fixes: #580 fixed -Fixes: #578 fixed -Fixes: #562 fixed -Fixes: #567 fixed - -## Testing - -- [x] Tested locally (Syntax and sanity checks) -- [ ] Added/updated unit tests -- [ ] Manually tested UI flow - -## Checklist - -- [x] My code follows the project style -- [x] I've updated docs if needed -- [x] No console errors or warnings -- [x] I've rebased on latest `main` diff --git a/pr_body.txt b/pr_body.txt deleted file mode 100644 index d1c760ea..00000000 --- a/pr_body.txt +++ /dev/null @@ -1,7 +0,0 @@ -This PR addresses issue #299: -- Added `is_paused()` query to `LendingPool`. -- Updated `LoanManager` to check `LendingPool` and `RemittanceNFT` pause state before allowing mutating operations (`assert_not_paused` cascade). -- Added `NftPaused` and `PoolPaused` errors to `LoanManager`. -- Added `is_paused()`, `pause()`, and `unpause()` to `RemittanceNFT` and added `assert_not_paused` to all mutating operations. -- Fixed a version mismatch in `LendingPool` tests. -- Added tests in `LoanManager` for the pause cascade.