Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,6 +64,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa
CreditScoringModule,
AdminModule,
StellarModule,
JobsModule,
],
controllers: [],
providers: [
Expand Down
148 changes: 148 additions & 0 deletions src/jobs/default-detection.processor.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<void> {
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<OverdueLoan[]> {
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<void> {
if (this.adminKeypair) {
try {
const unsignedXdr = await this.creditLineContractClient.buildMarkDefaultedTx(loan.loan_id);

const networkPassphrase =
this.configService.get<string>('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<void> {
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`);
}
}
12 changes: 12 additions & 0 deletions src/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down
31 changes: 31 additions & 0 deletions src/stellar/contracts/clients/creditline.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,37 @@ export class CreditLineContractClient {
}
}

async buildMarkDefaultedTx(loanId: string): Promise<string> {
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');
Expand Down
2 changes: 2 additions & 0 deletions src/stellar/contracts/interfaces/creditline.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ export interface ICreditLineClient {
loanId: string,
amount: number,
): Promise<string>;

buildMarkDefaultedTx(loanId: string): Promise<string>;
}
Loading