diff --git a/.env.example b/.env.example index 377ed0c..0b1afc5 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_SOROBAN_URL=https://soroban-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +# Stellar Admin (used for server-side operations like marking loans defaulted) +STELLAR_ADMIN_SECRET= + # Smart Contract IDs (deployed smart contracts) REPUTATION_CONTRACT_ID= CREDITLINE_CONTRACT_ID= diff --git a/src/app.module.ts b/src/app.module.ts index 5f76bf0..cc3aa9d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { LoanPaymentReminderModule } from './jobs/loan-payment-reminder/loan-pay import { TransactionStatusCheckerModule } from './jobs/transaction-status-checker/transaction-status-checker.module'; import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module'; import { SupabaseKeepAliveModule } from './jobs/supabase-keepalive/supabase-keepalive.module'; +import { JobsModule } from './jobs/jobs.module'; import { StellarModule } from './stellar/stellar.module'; import { LoggerModule } from './common/logger/logger.module'; import { MetricsModule } from './modules/metrics/metrics.module'; @@ -63,6 +64,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa CreditScoringModule, AdminModule, StellarModule, + JobsModule, ], controllers: [], providers: [ diff --git a/src/jobs/default-detection.processor.ts b/src/jobs/default-detection.processor.ts new file mode 100644 index 0000000..3017521 --- /dev/null +++ b/src/jobs/default-detection.processor.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { SupabaseService } from '../database/supabase.client'; +import { CreditLineContractClient } from '../stellar/contracts/clients/creditline.client'; +import { SequenceManagerService } from '../modules/transactions/sequence-manager.service'; +import { TransactionType } from '../modules/transactions/dto/submit-transaction-request.dto'; + +interface OverdueLoan { + id: string; + loan_id: string; + user_wallet: string; +} + +const DEFAULT_GRACE_PERIOD_DAYS = 7; + +@Injectable() +export class DefaultDetectionProcessor { + private readonly logger = new Logger(DefaultDetectionProcessor.name); + private isRunning = false; + + constructor( + private readonly supabaseService: SupabaseService, + private readonly creditLineContractClient: CreditLineContractClient, + private readonly sequenceManagerService: SequenceManagerService, + ) {} + + @Cron('0 * * * *') + async detectDefaults(): Promise { + if (this.isRunning) return; + this.isRunning = true; + + try { + const loans = await this.fetchOverdueLoans(); + if (loans.length === 0) { + this.logger.log('No overdue loans found'); + return; + } + + this.logger.log(`Found ${loans.length} overdue loan(s)`); + + for (const loan of loans) { + try { + await this.processOverdueLoan(loan); + } catch (error) { + this.logger.error(`Failed to process overdue loan ${loan.loan_id}: ${error.message}`); + } + } + } catch (error) { + this.logger.error(`Fatal error in default detection job: ${error.message}`); + } finally { + this.isRunning = false; + } + } + + private async fetchOverdueLoans(): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - DEFAULT_GRACE_PERIOD_DAYS); + + const { data, error } = await db + .from('loans') + .select('id, loan_id, user_wallet') + .eq('status', 'active') + .lt('next_payment_due', cutoff.toISOString()) + .not('next_payment_due', 'is', null); + + if (error) { + throw new Error(`Failed to fetch overdue loans: ${error.message}`); + } + + return (data ?? []) as OverdueLoan[]; + } + + private async processOverdueLoan(loan: OverdueLoan): Promise { + if (this.sequenceManagerService.hasAdminKeypair) { + try { + await this.sequenceManagerService.submitAdminTransaction( + TransactionType.LOAN_DEFAULT, + (source) => this.creditLineContractClient.buildMarkDefaultedTx(loan.loan_id, source), + ); + + this.logger.log(`mark_defaulted submitted for loan ${loan.loan_id}`); + await this.markDefaultedOffChain(loan); + } catch (error) { + this.logger.error( + `On-chain mark_defaulted failed for ${loan.loan_id}: ${(error as Error).message} — off-chain marking skipped so the loan can be retried on the next cycle`, + ); + } + } else { + this.logger.warn( + `No admin keypair configured — marking ${loan.loan_id} as defaulted off-chain only`, + ); + await this.markDefaultedOffChain(loan); + } + } + + private async markDefaultedOffChain(loan: OverdueLoan): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const now = new Date().toISOString(); + + const { error: updateError } = await db + .from('loans') + .update({ + status: 'defaulted', + defaulted_at: now, + updated_at: now, + }) + .eq('id', loan.id) + .eq('status', 'active'); + + if (updateError) { + throw new Error(`Failed to update loan ${loan.loan_id} to defaulted: ${updateError.message}`); + } + + const { error: deleteError } = await db + .from('reputation_cache') + .delete() + .eq('wallet_address', loan.user_wallet); + + if (deleteError) { + this.logger.warn(`Failed to clear reputation cache for ${loan.user_wallet}: ${deleteError.message}`); + } + + this.logger.log(`Loan ${loan.loan_id} marked as defaulted`); + } +} diff --git a/src/jobs/jobs.module.ts b/src/jobs/jobs.module.ts new file mode 100644 index 0000000..b3033ed --- /dev/null +++ b/src/jobs/jobs.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DefaultDetectionProcessor } from './default-detection.processor'; +import { SupabaseService } from '../database/supabase.client'; +import { StellarModule } from '../stellar/stellar.module'; +import { TransactionsModule } from '../modules/transactions/transactions.module'; + +@Module({ + imports: [ConfigModule, StellarModule, TransactionsModule], + providers: [DefaultDetectionProcessor, SupabaseService], +}) +export class JobsModule {} diff --git a/src/modules/transactions/dto/submit-transaction-request.dto.ts b/src/modules/transactions/dto/submit-transaction-request.dto.ts index 68ddae0..4a351d5 100644 --- a/src/modules/transactions/dto/submit-transaction-request.dto.ts +++ b/src/modules/transactions/dto/submit-transaction-request.dto.ts @@ -4,6 +4,7 @@ import { ApiProperty } from '@nestjs/swagger'; export enum TransactionType { LOAN_CREATE = 'loan_create', LOAN_REPAY = 'loan_repay', + LOAN_DEFAULT = 'loan_default', DEPOSIT = 'deposit', WITHDRAW = 'withdraw', } diff --git a/src/modules/transactions/sequence-manager.service.ts b/src/modules/transactions/sequence-manager.service.ts new file mode 100644 index 0000000..3b60eb8 --- /dev/null +++ b/src/modules/transactions/sequence-manager.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { TransactionsService } from './transactions.service'; +import { TransactionType } from './dto/submit-transaction-request.dto'; + +@Injectable() +export class SequenceManagerService { + private readonly logger = new Logger(SequenceManagerService.name); + private readonly horizonServer: StellarSdk.Horizon.Server; + private readonly networkPassphrase: string; + private readonly adminKeypair: StellarSdk.Keypair | null = null; + private currentSequence: string | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly transactionsService: TransactionsService, + ) { + const horizonUrl = + this.configService.get('STELLAR_HORIZON_URL') || + 'https://horizon-testnet.stellar.org'; + + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + + const secret = this.configService.get('STELLAR_ADMIN_SECRET'); + if (secret) { + try { + this.adminKeypair = StellarSdk.Keypair.fromSecret(secret); + this.logger.log(`Admin keypair loaded: ${this.adminKeypair.publicKey().slice(0, 8)}...`); + } catch { + this.logger.warn('STELLAR_ADMIN_SECRET is invalid — admin transactions will fail'); + } + } else { + this.logger.warn('STELLAR_ADMIN_SECRET is not set — admin transactions cannot be submitted'); + } + } + + get hasAdminKeypair(): boolean { + return this.adminKeypair !== null; + } + + async submitAdminTransaction( + txType: TransactionType, + buildTx: (source: StellarSdk.Account) => Promise, + ): Promise<{ transactionHash: string }> { + if (!this.adminKeypair) { + throw new Error('Admin keypair not configured — cannot submit admin transaction'); + } + + const adminAccount = await this.fetchAdminAccount(); + + const unsignedXdr = await buildTx(adminAccount); + + const transaction = StellarSdk.TransactionBuilder.fromXDR(unsignedXdr, this.networkPassphrase); + transaction.sign(this.adminKeypair); + const signedXdr = transaction.toXDR(); + + try { + const result = await this.transactionsService.submitTransaction( + this.adminKeypair.publicKey(), + { xdr: signedXdr, type: txType }, + ); + + this.currentSequence = String(Number(adminAccount.sequenceNumber()) + 1); + + return result; + } catch (error) { + this.currentSequence = null; + throw error; + } + } + + private async fetchAdminAccount(): Promise { + const adminPubKey = this.adminKeypair!.publicKey(); + + try { + const accountRecord = await this.horizonServer + .accounts() + .accountId(adminPubKey) + .call(); + + const account = new StellarSdk.Account(adminPubKey, accountRecord.sequence); + this.currentSequence = accountRecord.sequence; + return account; + } catch (error) { + this.logger.error( + `Failed to fetch admin account ${adminPubKey.slice(0, 8)}...: ${(error as Error).message}`, + ); + throw error; + } + } +} diff --git a/src/modules/transactions/transactions.module.ts b/src/modules/transactions/transactions.module.ts index 8aa42b8..3bd794f 100644 --- a/src/modules/transactions/transactions.module.ts +++ b/src/modules/transactions/transactions.module.ts @@ -3,6 +3,7 @@ import { CacheModule } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TransactionsController } from './transactions.controller'; import { TransactionsService } from './transactions.service'; +import { SequenceManagerService } from './sequence-manager.service'; import { AuthModule } from '../auth/auth.module'; import { SupabaseService } from '../../database/supabase.client'; import { getRedisConfig } from '../../config/redis.config'; @@ -18,7 +19,7 @@ import { getRedisConfig } from '../../config/redis.config'; }), ], controllers: [TransactionsController], - providers: [TransactionsService, SupabaseService], - exports: [TransactionsService], + providers: [TransactionsService, SequenceManagerService, SupabaseService], + exports: [TransactionsService, SequenceManagerService], }) export class TransactionsModule {} diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index ddd231d..ca53237 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -102,6 +102,42 @@ export class CreditLineContractClient { } } + async buildMarkDefaultedTx( + loanId: string, + sourceAccount?: StellarSdk.Account, + ): Promise { + this.ensureConfigured(); + + try { + const contract = new StellarSdk.Contract(this.contractId); + const server = this.sorobanService.getServer(); + const networkPassphrase = this.sorobanService.getNetworkPassphrase(); + const loanIdArg = StellarSdk.nativeToScVal(loanId, { type: 'string' }); + + const source = sourceAccount ?? (() => { + const keypair = StellarSdk.Keypair.random(); + return new StellarSdk.Account(keypair.publicKey(), '0'); + })(); + + const tx = new StellarSdk.TransactionBuilder(source, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call('mark_defaulted', loanIdArg)) + .setTimeout(300) + .build(); + + const prepared = await server.prepareTransaction(tx); + return prepared.toXDR(); + } catch (error) { + if (error instanceof ContractNotConfiguredError) { + throw error; + } + this.logger.error(`Failed to build mark_defaulted transaction: ${error.message}`); + throw new ContractTxBuildError('mark_defaulted'); + } + } + private ensureConfigured(): void { if (!this.contractId) { throw new ContractNotConfiguredError('Credit line contract'); diff --git a/src/stellar/contracts/interfaces/creditline.interface.ts b/src/stellar/contracts/interfaces/creditline.interface.ts index 8a60d2d..150ea33 100644 --- a/src/stellar/contracts/interfaces/creditline.interface.ts +++ b/src/stellar/contracts/interfaces/creditline.interface.ts @@ -1,3 +1,5 @@ +import * as StellarSdk from 'stellar-sdk'; + export interface CreateLoanParams { loanId: string; vendorId: string; @@ -21,4 +23,9 @@ export interface ICreditLineClient { loanId: string, amount: number, ): Promise; + + buildMarkDefaultedTx( + loanId: string, + sourceAccount?: StellarSdk.Account, + ): Promise; } diff --git a/src/stellar/contracts/mocks/creditline.mock.ts b/src/stellar/contracts/mocks/creditline.mock.ts index 525d0c6..a389cb6 100644 --- a/src/stellar/contracts/mocks/creditline.mock.ts +++ b/src/stellar/contracts/mocks/creditline.mock.ts @@ -14,4 +14,10 @@ export class MockCreditLineContractClient { return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; }, ); + + buildMarkDefaultedTx = jest.fn( + async (_loanId: string, _sourceAccount?: unknown): Promise => { + return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + }, + ); }