Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
126 changes: 126 additions & 0 deletions src/jobs/default-detection.processor.ts
Original file line number Diff line number Diff line change
@@ -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<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.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<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
96 changes: 96 additions & 0 deletions src/modules/transactions/sequence-manager.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('STELLAR_HORIZON_URL') ||
'https://horizon-testnet.stellar.org';

this.networkPassphrase =
this.configService.get<string>('STELLAR_NETWORK_PASSPHRASE') ||
StellarSdk.Networks.TESTNET;

this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl);

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 — 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<string>,
): 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<StellarSdk.Account> {
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;
}
}
}
5 changes: 3 additions & 2 deletions src/modules/transactions/transactions.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {}
36 changes: 36 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,42 @@ export class CreditLineContractClient {
}
}

async buildMarkDefaultedTx(
loanId: string,
sourceAccount?: StellarSdk.Account,
): 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 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');
Expand Down
7 changes: 7 additions & 0 deletions src/stellar/contracts/interfaces/creditline.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as StellarSdk from 'stellar-sdk';

export interface CreateLoanParams {
loanId: string;
vendorId: string;
Expand All @@ -21,4 +23,9 @@ export interface ICreditLineClient {
loanId: string,
amount: number,
): Promise<string>;

buildMarkDefaultedTx(
loanId: string,
sourceAccount?: StellarSdk.Account,
): Promise<string>;
}
6 changes: 6 additions & 0 deletions src/stellar/contracts/mocks/creditline.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ export class MockCreditLineContractClient {
return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
},
);

buildMarkDefaultedTx = jest.fn(
async (_loanId: string, _sourceAccount?: unknown): Promise<string> => {
return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
},
);
}
Loading