From 58f9f404f9fa31366b79cbdf9747f1279059cb35 Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:42:52 +0000 Subject: [PATCH 1/2] feat: add default detection cron job for overdue loans - Add DefaultDetectionProcessor (@Cron '0 * * * *') that queries active loans past grace period, builds mark_defaulted XDR, signs with admin keypair, submits via transactions service, updates DB status, and clears reputation cache - Add buildMarkDefaultedTx to CreditLineContractClient - Add LOAN_DEFAULT to TransactionType enum - Register JobsModule in app.module.ts with required dependencies Closes #71 --- .env.example | 3 + src/app.module.ts | 2 + src/jobs/default-detection.processor.ts | 148 ++++++++++++++++++ src/jobs/jobs.module.ts | 12 ++ .../dto/submit-transaction-request.dto.ts | 1 + .../contracts/clients/creditline.client.ts | 31 ++++ .../interfaces/creditline.interface.ts | 2 + 7 files changed, 199 insertions(+) create mode 100644 src/jobs/default-detection.processor.ts create mode 100644 src/jobs/jobs.module.ts 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..366b766 --- /dev/null +++ b/src/jobs/default-detection.processor.ts @@ -0,0 +1,148 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { SupabaseService } from '../database/supabase.client'; +import { CreditLineContractClient } from '../stellar/contracts/clients/creditline.client'; +import { TransactionsService } from '../modules/transactions/transactions.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; + private readonly adminKeypair: StellarSdk.Keypair | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly supabaseService: SupabaseService, + private readonly creditLineContractClient: CreditLineContractClient, + private readonly transactionsService: TransactionsService, + ) { + 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 — default detection will fail to submit on-chain'); + } + } else { + this.logger.warn('STELLAR_ADMIN_SECRET is not set — default detection will skip on-chain submission'); + } + } + + @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.adminKeypair) { + try { + const unsignedXdr = await this.creditLineContractClient.buildMarkDefaultedTx(loan.loan_id); + + const networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + const transaction = StellarSdk.TransactionBuilder.fromXDR(unsignedXdr, networkPassphrase); + transaction.sign(this.adminKeypair); + const signedXdr = transaction.toXDR(); + + await this.transactionsService.submitTransaction( + this.adminKeypair.publicKey(), + { xdr: signedXdr, type: TransactionType.LOAN_DEFAULT }, + ); + + this.logger.log(`mark_defaulted submitted for loan ${loan.loan_id}`); + } catch (error) { + this.logger.error(`On-chain mark_defaulted failed for ${loan.loan_id}: ${error.message}`); + } + } 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/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index ddd231d..5f1f19c 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -102,6 +102,37 @@ export class CreditLineContractClient { } } + async buildMarkDefaultedTx(loanId: string): 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 sourceKeypair = StellarSdk.Keypair.random(); + const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + 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..ed32939 100644 --- a/src/stellar/contracts/interfaces/creditline.interface.ts +++ b/src/stellar/contracts/interfaces/creditline.interface.ts @@ -21,4 +21,6 @@ export interface ICreditLineClient { loanId: string, amount: number, ): Promise; + + buildMarkDefaultedTx(loanId: string): Promise; } From d12e0fdc5254b9b7fc03c3840c806ee70d898e97 Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:29:34 +0000 Subject: [PATCH 2/2] fix: gate off-chain default marking on on-chain success + add SequenceManagerService - Only mark loan as defaulted off-chain when on-chain submission succeeds; on failure the loan stays active and is retried next cycle, preventing permanent DB/contract divergence. - Introduce SequenceManagerService to manage admin account sequence numbers and route admin submissions (mark_defaulted) through it, ready for integration with #87 sequence-number manager. - Update CreditLineContractClient.buildMarkDefaultedTx to accept an optional source account from the sequence manager. --- src/jobs/default-detection.processor.ts | 52 +++------- .../transactions/sequence-manager.service.ts | 96 +++++++++++++++++++ .../transactions/transactions.module.ts | 5 +- .../contracts/clients/creditline.client.ts | 13 ++- .../interfaces/creditline.interface.ts | 7 +- .../contracts/mocks/creditline.mock.ts | 6 ++ 6 files changed, 135 insertions(+), 44 deletions(-) create mode 100644 src/modules/transactions/sequence-manager.service.ts diff --git a/src/jobs/default-detection.processor.ts b/src/jobs/default-detection.processor.ts index 366b766..3017521 100644 --- a/src/jobs/default-detection.processor.ts +++ b/src/jobs/default-detection.processor.ts @@ -1,10 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { ConfigService } from '@nestjs/config'; -import * as StellarSdk from 'stellar-sdk'; import { SupabaseService } from '../database/supabase.client'; import { CreditLineContractClient } from '../stellar/contracts/clients/creditline.client'; -import { TransactionsService } from '../modules/transactions/transactions.service'; +import { SequenceManagerService } from '../modules/transactions/sequence-manager.service'; import { TransactionType } from '../modules/transactions/dto/submit-transaction-request.dto'; interface OverdueLoan { @@ -19,26 +17,12 @@ const DEFAULT_GRACE_PERIOD_DAYS = 7; export class DefaultDetectionProcessor { private readonly logger = new Logger(DefaultDetectionProcessor.name); private isRunning = false; - private readonly adminKeypair: StellarSdk.Keypair | null = null; constructor( - private readonly configService: ConfigService, private readonly supabaseService: SupabaseService, private readonly creditLineContractClient: CreditLineContractClient, - private readonly transactionsService: TransactionsService, - ) { - 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 — default detection will fail to submit on-chain'); - } - } else { - this.logger.warn('STELLAR_ADMIN_SECRET is not set — default detection will skip on-chain submission'); - } - } + private readonly sequenceManagerService: SequenceManagerService, + ) {} @Cron('0 * * * *') async detectDefaults(): Promise { @@ -88,32 +72,26 @@ export class DefaultDetectionProcessor { } private async processOverdueLoan(loan: OverdueLoan): Promise { - if (this.adminKeypair) { + if (this.sequenceManagerService.hasAdminKeypair) { try { - const unsignedXdr = await this.creditLineContractClient.buildMarkDefaultedTx(loan.loan_id); - - const networkPassphrase = - this.configService.get('STELLAR_NETWORK_PASSPHRASE') || - StellarSdk.Networks.TESTNET; - - const transaction = StellarSdk.TransactionBuilder.fromXDR(unsignedXdr, networkPassphrase); - transaction.sign(this.adminKeypair); - const signedXdr = transaction.toXDR(); - - await this.transactionsService.submitTransaction( - this.adminKeypair.publicKey(), - { xdr: signedXdr, type: TransactionType.LOAN_DEFAULT }, + 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.message}`); + 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`); + this.logger.warn( + `No admin keypair configured — marking ${loan.loan_id} as defaulted off-chain only`, + ); + await this.markDefaultedOffChain(loan); } - - await this.markDefaultedOffChain(loan); } private async markDefaultedOffChain(loan: OverdueLoan): Promise { 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 5f1f19c..ca53237 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -102,7 +102,10 @@ export class CreditLineContractClient { } } - async buildMarkDefaultedTx(loanId: string): Promise { + async buildMarkDefaultedTx( + loanId: string, + sourceAccount?: StellarSdk.Account, + ): Promise { this.ensureConfigured(); try { @@ -111,10 +114,12 @@ export class CreditLineContractClient { const networkPassphrase = this.sorobanService.getNetworkPassphrase(); const loanIdArg = StellarSdk.nativeToScVal(loanId, { type: 'string' }); - const sourceKeypair = StellarSdk.Keypair.random(); - const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + const source = sourceAccount ?? (() => { + const keypair = StellarSdk.Keypair.random(); + return new StellarSdk.Account(keypair.publicKey(), '0'); + })(); - const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + const tx = new StellarSdk.TransactionBuilder(source, { fee: StellarSdk.BASE_FEE, networkPassphrase, }) diff --git a/src/stellar/contracts/interfaces/creditline.interface.ts b/src/stellar/contracts/interfaces/creditline.interface.ts index ed32939..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; @@ -22,5 +24,8 @@ export interface ICreditLineClient { amount: number, ): Promise; - buildMarkDefaultedTx(loanId: string): 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=='; + }, + ); }