diff --git a/docs/setup/environment-variables.md b/docs/setup/environment-variables.md index a460387..a46fc4d 100644 --- a/docs/setup/environment-variables.md +++ b/docs/setup/environment-variables.md @@ -91,6 +91,25 @@ REDIS_DB=0 REPUTATION_CACHE_TTL=300 ``` +### Liquidity Reservation (issue #81) + +The reservation ledger that prevents concurrent `createLoan` callers +from over-committing pool funds. Defaults to an in-process store when +`REDIS_URL` is not set or `NODE_ENV=test`. + +```env +# Optional override for the reservation-ledger Redis URL. Falls back +# to REDIS_URL when unset. +LIQUIDITY_RESERVATION_REDIS_URL=redis://localhost:6379 + +# Pool identifier used when more than one pool is ever wired up. +LIQUIDITY_RESERVATION_POOL_ID=default + +# Reservation TTL in seconds. After this window a non-submitted loan +# simply expires and the funds return to the pool. +LIQUIDITY_RESERVATION_TTL_SECONDS=900 +``` + ## Optional Variables ### Logging @@ -274,4 +293,4 @@ REMINDER_CRON=0 9 * * * --- -*Last Updated: 2026-02-13* +*Last Updated: 2026-07-17* diff --git a/src/app.module.ts b/src/app.module.ts index 65d297a..2e572a8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { VouchingModule } from './modules/vouching/vouching.module'; import { BlockchainModule } from './modules/blockchain/blockchain.module'; import { SponsorsModule } from './modules/sponsors/sponsors.module'; import { LiquidityModule } from './modules/liquidity/liquidity.module'; +import { ReservationsModule } from './modules/liquidity/reservations/reservations.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { TransactionsModule } from './modules/transactions/transactions.module'; import { LearnersModule } from './modules/learners/learners.module'; @@ -53,6 +54,7 @@ import { StateReconciliationModule } from './jobs/state-reconciliation/state-rec BlockchainModule, SponsorsModule, LiquidityModule, + ReservationsModule, NotificationsModule, TransactionsModule, LearnersModule, diff --git a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts index fcc8dee..bd15944 100644 --- a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts +++ b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; import { SupabaseService } from '../../database/supabase.client'; import { TransactionType } from '../../modules/transactions/dto/submit-transaction-request.dto'; +import { LiquidityReservationService } from '../../modules/liquidity/reservations/liquidity-reservation.service'; interface PendingTransaction { id: string; @@ -56,6 +57,7 @@ export class TransactionStatusCheckerService { constructor( private readonly configService: ConfigService, private readonly supabaseService: SupabaseService, + private readonly reservationService: LiquidityReservationService, ) { const horizonUrl = this.configService.get('STELLAR_HORIZON_URL') || @@ -329,6 +331,7 @@ export class TransactionStatusCheckerService { } const followUp = await this.applyFollowUpActions(transaction, status); + await this.releaseReservationIfApplicable(transaction, status); await this.createNotification(transaction, status, errorMessage, followUp); this.logger.log( @@ -342,6 +345,51 @@ export class TransactionStatusCheckerService { ); } + /** + * If the finalized transaction was a LOAN_CREATE, release the + * liquidity reservation attached to the loan. This holds for both + * success and failure finalizations: on success the on-chain + * settlement takes over the locked-funds accounting; on failure the + * funds must be made available for another borrower. + * + * Repayment transactions do not own a reservation, so they are a + * no-op here. + */ + private async releaseReservationIfApplicable( + transaction: PendingTransaction, + status: 'success' | 'failed', + ): Promise { + if (transaction.type !== TransactionType.LOAN_CREATE) { + return; + } + try { + const metadata = this.parseTransactionMetadata(transaction.xdr); + if (!metadata?.loanId) { + return; + } + const released = await this.reservationService.releaseForLoan(metadata.loanId); + this.logger.log( + { + transactionHash: transaction.transaction_hash, + loanId: metadata.loanId, + status, + released, + }, + 'Liquidity reservation cleanup after transaction finalisation', + ); + } catch (error) { + // Reservation cleanup must never block the finalisation pipeline + // — the underlying reservation has its own TTL as a backstop. + this.logger.warn( + { + transactionHash: transaction.transaction_hash, + err: (error as Error).message, + }, + 'Failed to release reservation during transaction finalisation; relying on TTL expiry', + ); + } + } + private async applyFollowUpActions( transaction: PendingTransaction, status: 'success' | 'failed', diff --git a/src/modules/liquidity/reservations/in-memory-reservation-store.ts b/src/modules/liquidity/reservations/in-memory-reservation-store.ts new file mode 100644 index 0000000..ebe8cde --- /dev/null +++ b/src/modules/liquidity/reservations/in-memory-reservation-store.ts @@ -0,0 +1,264 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + ReservationAcquireOutcome, + ReservationMetadata, + ReservationStore, +} from './reservation-store.interface'; + +/** + * In-process implementation of the reservation ledger. + * + * Used when: + * - `NODE_ENV === 'test'` + * - `REDIS_URL` / `LIQUIDITY_RESERVATION_REDIS_URL` is not configured + * + * Semantics are a faithful, JS-faithful reproduction of the Redis Lua + * scripts in {@link ./reservations.constants}. Atomicity within a single + * process is guaranteed by a per-pool promise-chain mutex; cross-process + * atomicity would require the Redis-backed implementation instead. + */ +@Injectable() +export class InMemoryReservationStore implements ReservationStore { + private readonly logger = new Logger(InMemoryReservationStore.name); + + private readonly metadata = new Map(); + private readonly byLoan = new Map(); + /** poolId -> array of { reservationId, amount, expiresAt } */ + private readonly pools = new Map(); + private readonly chainTail = new Map>(); + private readonly expiryTimers = new Map(); + + async acquire(input: { + poolId: string; + capacity: bigint; + reservationId: string; + amount: bigint; + ttlSeconds: number; + metadata: ReservationMetadata; + }): Promise { + return this.withPoolLock(input.poolId, async () => { + this.pruneExpired(input.poolId); + + if (this.metadata.has(input.reservationId)) { + return { + kind: 'duplicate', + reservationId: input.reservationId, + code: 'LIQUIDITY_RESERVATION_CONFLICT' as const, + } as ReservationAcquireOutcome; + } + + const sum = this.computeSum(input.poolId); + if (sum + input.amount > input.capacity) { + return { + kind: 'insufficient', + currentlyReserved: sum, + capacity: input.capacity, + code: 'LIQUIDITY_RESERVATION_INSUFFICIENT' as const, + } as ReservationAcquireOutcome; + } + + const expiresAt = new Date(Date.now() + input.ttlSeconds * 1000); + const entry: ReservationEntry = { + reservationId: input.reservationId, + amount: input.amount, + expiresAt, + }; + + this.metadata.set(input.reservationId, input.metadata); + this.byLoan.set(input.metadata.loanId, input.reservationId); + + const list = this.pools.get(input.poolId) ?? []; + list.push(entry); + this.pools.set(input.poolId, list); + + this.scheduleExpiry(input.poolId, input.reservationId, input.ttlSeconds * 1000); + + return { + kind: 'acquired', + reservationId: input.reservationId, + totalReserved: sum + input.amount, + expiresAt, + } as ReservationAcquireOutcome; + }); + } + + async release(input: { + poolId: string; + reservationId: string; + amount: bigint; + }): Promise { + return this.withPoolLock(input.poolId, async () => { + this.pruneExpired(input.poolId); + + const idx = this.findEntryIndex(input.poolId, input.reservationId, input.amount); + if (idx < 0) { + // Already gone — make idempotent (matches Redis Lua semantics). + return false; + } + + const meta = this.metadata.get(input.reservationId); + this.pools.get(input.poolId)!.splice(idx, 1); + this.metadata.delete(input.reservationId); + if (meta) { + this.byLoan.delete(meta.loanId); + } + + const timer = this.expiryTimers.get(input.reservationId); + if (timer) { + clearTimeout(timer); + this.expiryTimers.delete(input.reservationId); + } + + return true; + }); + } + + async findByLoanId(loanId: string): Promise { + const reservationId = this.byLoan.get(loanId); + if (!reservationId) { + return null; + } + const meta = this.metadata.get(reservationId); + if (!meta) { + // Index is stale; clean up + this.byLoan.delete(loanId); + return null; + } + if (meta.expiresAt && new Date(meta.expiresAt).getTime() <= Date.now()) { + return null; + } + return reservationId; + } + + async getMetadata(reservationId: string): Promise { + const meta = this.metadata.get(reservationId); + if (!meta) { + return null; + } + if (new Date(meta.expiresAt).getTime() <= Date.now()) { + return null; + } + return meta; + } + + async totalReserved(poolId: string): Promise { + return this.withPoolLock(poolId, async () => { + this.pruneExpired(poolId); + return this.computeSum(poolId); + }); + } + + async listActive(poolId: string): Promise { + return this.withPoolLock(poolId, async () => { + this.pruneExpired(poolId); + const entries = this.pools.get(poolId) ?? []; + const out: ReservationMetadata[] = []; + for (const entry of entries) { + const meta = this.metadata.get(entry.reservationId); + if (meta) { + out.push(meta); + } + } + return out; + }); + } + + /** Internal: serialize logic around a single pool's state. */ + private withPoolLock(poolId: string, fn: () => Promise | T): Promise { + const prev = this.chainTail.get(poolId) ?? Promise.resolve(); + const next = prev.then(() => fn()); + this.chainTail.set(poolId, next.catch(() => undefined)); + return next; + } + + private pruneExpired(poolId: string): void { + const now = Date.now(); + const list = this.pools.get(poolId); + if (!list) { + return; + } + const survivors: ReservationEntry[] = []; + for (const entry of list) { + if (entry.expiresAt.getTime() > now) { + survivors.push(entry); + continue; + } + // Expired — clean up + this.metadata.delete(entry.reservationId); + this.expiryTimers.delete(entry.reservationId); + // byLoan: we don't know the loanId without metadata, so leave the + // reverse index alone; the next `findByLoanId` will reconcile it. + } + if (survivors.length !== list.length) { + this.pools.set(poolId, survivors); + } + } + + private computeSum(poolId: string): bigint { + const list = this.pools.get(poolId) ?? []; + let sum = 0n; + for (const entry of list) { + sum += entry.amount; + } + return sum; + } + + private findEntryIndex(poolId: string, reservationId: string, amount: bigint): number { + const list = this.pools.get(poolId) ?? []; + return list.findIndex( + (entry) => entry.reservationId === reservationId && entry.amount === amount, + ); + } + + private scheduleExpiry(poolId: string, reservationId: string, ttlMs: number): void { + const timer = setTimeout(() => { + const list = this.pools.get(poolId); + if (!list) { + return; + } + const idx = list.findIndex((entry) => entry.reservationId === reservationId); + if (idx < 0) { + return; + } + const entry = list[idx]; + if (entry.expiresAt.getTime() > Date.now()) { + // Window was extended — re-schedule with remaining time. + this.scheduleExpiry(poolId, reservationId, entry.expiresAt.getTime() - Date.now()); + return; + } + list.splice(idx, 1); + this.metadata.delete(reservationId); + const meta = this.metadata.get(reservationId); + if (meta) { + this.byLoan.delete(meta.loanId); + } + this.expiryTimers.delete(reservationId); + }, ttlMs); + + if (typeof timer === 'object' && timer !== null && 'unref' in timer) { + (timer as NodeJS.Timeout).unref(); + } + + this.expiryTimers.set(reservationId, timer); + } + + /** + * Test-only helpers — not part of the public ReservationStore interface. + */ + __test_only_clear(): void { + this.metadata.clear(); + this.byLoan.clear(); + this.pools.clear(); + this.chainTail.clear(); + for (const t of this.expiryTimers.values()) { + clearTimeout(t); + } + this.expiryTimers.clear(); + } +} + +interface ReservationEntry { + reservationId: string; + amount: bigint; + expiresAt: Date; +} diff --git a/src/modules/liquidity/reservations/index.ts b/src/modules/liquidity/reservations/index.ts new file mode 100644 index 0000000..eace0aa --- /dev/null +++ b/src/modules/liquidity/reservations/index.ts @@ -0,0 +1,30 @@ +/** + * Public surface of the reservations package. + * + * Only export value-bearing modules here; re-exporting interfaces or + * the same symbol from two modules triggers "duplicate export" errors. + */ +export { + RESERVATION_DEFAULTS, + RESERVATION_ERROR_CODES, + reservationKey, + ACQUIRE_RESERVATION_LUA, + RELEASE_RESERVATION_LUA, + LOOKUP_RESERVATION_BY_LOAN_LUA, + SUM_ACTIVE_RESERVATIONS_LUA, + type ReservationErrorCode, +} from './reservations.constants'; +export type { + ReservationAcquireOutcome, + ReservationMetadata, + ReservationStore, +} from './reservation-store.interface'; +export { InMemoryReservationStore } from './in-memory-reservation-store'; +export { RedisReservationStore } from './redis-reservation-store'; +export { + ReservationsModule, + RESERVATION_STORE, + RESERVATION_REDIS_CLIENT, +} from './reservations.module'; +export { LiquidityReservationService } from './liquidity-reservation.service'; +export { LIQUIDITY_RESERVATION_METRICS } from './liquidity-reservation.metrics'; diff --git a/src/modules/liquidity/reservations/liquidity-reservation.metrics.ts b/src/modules/liquidity/reservations/liquidity-reservation.metrics.ts new file mode 100644 index 0000000..751037f --- /dev/null +++ b/src/modules/liquidity/reservations/liquidity-reservation.metrics.ts @@ -0,0 +1,26 @@ +/** + * Prometheus metric identifiers for the liquidity reservation layer. + * + * These tokens are shared between the Nest module that registers the + * metrics and the service that emits them, ensuring both sides agree on + * the label names and bucket boundaries. + */ + +export const LIQUIDITY_RESERVATION_METRICS = { + RESERVATIONS_INFLIGHT: 'stepfi_liquidity_reservations_inflight', + RESERVATIONS_ACQUIRED_TOTAL: 'stepfi_liquidity_reservations_acquired_total', + RESERVATIONS_RELEASED_TOTAL: 'stepfi_liquidity_reservations_released_total', + RESERVATIONS_REJECTED_TOTAL: 'stepfi_liquidity_reservations_rejected_total', + DRIFT_DETECTED: 'stepfi_liquidity_reservations_drift_stroops', +} as const; + +export type LiquidityReservationMetricName = + (typeof LIQUIDITY_RESERVATION_METRICS)[keyof typeof LIQUIDITY_RESERVATION_METRICS]; + +export interface LiquidityReservationMetrics { + readonly RESERVATIONS_INFLIGHT: typeof LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_INFLIGHT; + readonly RESERVATIONS_ACQUIRED_TOTAL: typeof LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_ACQUIRED_TOTAL; + readonly RESERVATIONS_RELEASED_TOTAL: typeof LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_RELEASED_TOTAL; + readonly RESERVATIONS_REJECTED_TOTAL: typeof LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_REJECTED_TOTAL; + readonly DRIFT_DETECTED: typeof LIQUIDITY_RESERVATION_METRICS.DRIFT_DETECTED; +} diff --git a/src/modules/liquidity/reservations/liquidity-reservation.service.ts b/src/modules/liquidity/reservations/liquidity-reservation.service.ts new file mode 100644 index 0000000..9aac33d --- /dev/null +++ b/src/modules/liquidity/reservations/liquidity-reservation.service.ts @@ -0,0 +1,285 @@ +import { + Injectable, + Inject, + Logger, + OnModuleDestroy, + BadRequestException, + InternalServerErrorException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { Counter, Gauge } from 'prom-client'; +import { randomUUID } from 'node:crypto'; +import { ReservationStore } from './reservation-store.interface'; +import { + DEFAULT_LIQUIDITY_POOL_ID, + reservationKey, + RESERVATION_DEFAULTS, + RESERVATION_ERROR_CODES, + RESERVATION_STORE, +} from './reservations.constants'; +import { LiquidityPoolContractClient } from '../../../stellar/contracts/clients/liquidity-pool.client'; +import { + LIQUIDITY_RESERVATION_METRICS, + LiquidityReservationMetrics, +} from './liquidity-reservation.metrics'; + +const STROOPS = 10_000_000n; + +/** + * Orchestrates pool-fund reservations around credit assessment → + * funding-XDR → on-chain settlement. + * + * Responsibilities: + * 1. Look up the on-chain pool capacity (Liquid + Locked) so the + * "available" figure accounts for in-flight capital. + * 2. Translate user-supplied dollar amounts ↔ stroops. + * 3. Delegate atomic acquire / release to the injected {@link ReservationStore} + * (Redis in production, in-memory in tests). + * 4. Reconcile reservations against on-chain `locked_liquidity` on a + * cadence (default every 5 min) and surface drift via metrics. + */ +@Injectable() +export class LiquidityReservationService implements OnModuleDestroy { + private readonly logger = new Logger(LiquidityReservationService.name); + private readonly ttlSeconds: number; + private readonly poolId: string; + + constructor( + @Inject(RESERVATION_STORE) private readonly store: ReservationStore, + private readonly poolClient: LiquidityPoolContractClient, + private readonly configService: ConfigService, + @InjectMetric(LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_INFLIGHT) + private readonly inflightGauge: Gauge, + @InjectMetric(LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_ACQUIRED_TOTAL) + private readonly acquiredCounter: Counter, + @InjectMetric(LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_RELEASED_TOTAL) + private readonly releasedCounter: Counter, + @InjectMetric(LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_REJECTED_TOTAL) + private readonly rejectedCounter: Counter, + @InjectMetric(LIQUIDITY_RESERVATION_METRICS.DRIFT_DETECTED) + private readonly driftGauge: Gauge, + ) { + this.ttlSeconds = this.configService.get( + 'LIQUIDITY_RESERVATION_TTL_SECONDS', + RESERVATION_DEFAULTS.ttlSeconds, + ); + this.poolId = this.configService.get( + 'LIQUIDITY_RESERVATION_POOL_ID', + DEFAULT_LIQUIDITY_POOL_ID, + ); + } + + /** + * Acquire a reservation for a pending loan. Throws Nest exceptions that + * map cleanly to HTTP responses — controllers forward them. + */ + async reserveForLoan(input: { + loanId: string; + wallet: string; + amountUsd: number; + }): Promise<{ reservationId: string; expiresAt: Date; poolCapacityUsd: number }> { + const amountStroops = this.toStroops(input.amountUsd); + + const poolStats = await this.fetchPoolStats(); + const availableStroops = poolStats.availableLiquidity; + + // Sum currently-reserved stroops and use the smaller of contract's + // availableLiquidity and (available - reserved) as the effective + // capacity. We ALWAYS submit capacity = availableLiquidity in the + // Lua script so the reserve-side and on-chain view cannot drift + // beyond what the contract says is liquid. + const currentlyReserved = await this.store.totalReserved(this.poolId); + const effectiveCapacity = availableStroops; + + const reservationId = `resv-${input.loanId}-${randomUUID()}`; + const metadata = { + poolId: this.poolId, + wallet: input.wallet, + loanId: input.loanId, + amount: amountStroops, + acquiredAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + this.ttlSeconds * 1000).toISOString(), + }; + + const outcome = await this.store.acquire({ + poolId: this.poolId, + capacity: effectiveCapacity, + reservationId, + amount: amountStroops, + ttlSeconds: this.ttlSeconds, + metadata, + }); + + if (outcome.kind === 'acquired') { + this.acquiredCounter.inc(); + this.inflightGauge.inc(); + this.logger.log( + { + reservationId, + loanId: input.loanId, + amountUsd: input.amountUsd, + wallet: input.wallet, + totalReservedStroops: outcome.totalReserved.toString(), + }, + 'Liquidity reservation acquired', + ); + return { + reservationId, + expiresAt: outcome.expiresAt, + poolCapacityUsd: this.fromStroops(effectiveCapacity), + }; + } + + if (outcome.kind === 'insufficient') { + this.rejectedCounter.inc({ reason: 'insufficient' }); + throw new BadRequestException({ + code: RESERVATION_ERROR_CODES.INSUFFICIENT, + message: `Pool has insufficient liquidity for $${input.amountUsd}. Available: $${this.fromStroops(outcome.capacity - currentlyReserved).toFixed(2)}.`, + }); + } + + // duplicate + this.rejectedCounter.inc({ reason: 'duplicate' }); + throw new InternalServerErrorException({ + code: RESERVATION_ERROR_CODES.CONFLICT, + message: 'A reservation for this loan already exists.', + }); + } + + async releaseForLoan(loanId: string): Promise { + const reservationId = await this.store.findByLoanId(loanId); + if (!reservationId) { + // Nothing to release — could be already cleaned up by TTL. + return false; + } + const meta = await this.store.getMetadata(reservationId); + if (!meta) { + return false; + } + const released = await this.store.release({ + poolId: this.poolId, + reservationId, + amount: meta.amount, + }); + if (released) { + this.releasedCounter.inc(); + this.inflightGauge.dec(); + this.logger.log( + { reservationId, loanId, amountStroops: meta.amount.toString() }, + 'Liquidity reservation released', + ); + } + return released; + } + + /** Force release using a known reservation handle (used in error paths). */ + async releaseReservation(reservationId: string): Promise { + const meta = await this.store.getMetadata(reservationId); + if (!meta) { + return false; + } + const released = await this.store.release({ + poolId: meta.poolId, + reservationId, + amount: meta.amount, + }); + if (released) { + this.releasedCounter.inc(); + this.inflightGauge.dec(); + } + return released; + } + + /** + * Pure introspection helper; used by tests and the admin-style + * reconciliation log. Note: returns stroops. + */ + async getCurrentReservationTotal(): Promise { + return this.store.totalReserved(this.poolId); + } + + /** + * Cron reconciliation: prune expired entries (already done lazily by + * the store) AND emit metrics about the drift between reservation + * total and on-chain locked_liquidity. Drift is expected early in a + * reservation window (the on-chain lock hasn't happened yet) so we + * only flag it when reservations > locked. + */ + @Cron(RESERVATION_DEFAULTS.reconcileCron) + async reconcile(): Promise { + try { + const poolStats = await this.fetchPoolStats().catch(() => null); + const reserved = await this.store.totalReserved(this.poolId); + + if (!poolStats) { + this.driftGauge.set(0); + return; + } + + const onchainLocked = poolStats.lockedLiquidity; + // Positive drift = we have stale reservations above what's locked on-chain. + // (Reservations < on-chain locked is the expected interim state during + // in-flight funding, so we don't flag that direction.) + const drift = reserved > onchainLocked ? reserved - onchainLocked : 0n; + this.driftGauge.set(Number(this.fromStroops(drift))); + + if (drift > 0n) { + this.logger.warn( + { + reservedStroops: reserved.toString(), + onchainLockedStroops: onchainLocked.toString(), + driftStroops: drift.toString(), + }, + 'Reservations exceed on-chain locked liquidity — possible drift (likely expired reservations not yet observed by contract)', + ); + } + } catch (error) { + this.logger.error( + { err: (error as Error).message }, + 'Reservation reconciliation failed', + ); + } + } + + onModuleDestroy(): void { + // No persistent client owned here — the ioredis connection is owned + // by {@link RedisClientFactory}. + } + + private async fetchPoolStats() { + try { + return await this.poolClient.getPoolStats(); + } catch (error) { + this.logger.error( + { err: (error as Error).message }, + 'Failed to fetch pool stats for reservation; refusing to proceed', + ); + throw new ServiceUnavailableException({ + code: 'LIQUIDITY_POOL_STATS_UNAVAILABLE', + message: 'Unable to read liquidity pool stats right now. Please try again later.', + }); + } + } + + private toStroops(value: number): bigint { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException({ + code: 'LIQUIDITY_RESERVATION_INVALID_AMOUNT', + message: 'Reservation amount must be a positive, finite number.', + }); + } + return BigInt(Math.round(value * Number(STROOPS))); + } + + private fromStroops(value: bigint): number { + return Math.round(Number(value) / Number(STROOPS) * Number(STROOPS)) / Number(STROOPS); + } +} + +export const RESERVATION_SERVICE_TOKEN = Symbol('LiquidityReservationService'); +// Re-export the metric constants so tests can locate the symbol names. +export type { LiquidityReservationMetrics }; +export { reservationKey }; diff --git a/src/modules/liquidity/reservations/redis-reservation-store.ts b/src/modules/liquidity/reservations/redis-reservation-store.ts new file mode 100644 index 0000000..5030533 --- /dev/null +++ b/src/modules/liquidity/reservations/redis-reservation-store.ts @@ -0,0 +1,228 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Redis as RedisClient } from 'ioredis'; +import { + ReservationAcquireOutcome, + ReservationMetadata, + ReservationStore, +} from './reservation-store.interface'; +import { + ACQUIRE_RESERVATION_LUA, + RELEASE_RESERVATION_LUA, + LOOKUP_RESERVATION_BY_LOAN_LUA, + SUM_ACTIVE_RESERVATIONS_LUA, + reservationKey, + RESERVATION_ERROR_CODES, + ReservationErrorCode, +} from './reservations.constants'; + +/** + * Cross-process, Lua-atomic implementation of the reservation ledger. + * + * Uses a single ioredis connection. All acquire / release / totalReserved + * operations are wrapped in Lua scripts so the ZSET and metadata key moves + * are atomic even under heavy concurrency. + */ +@Injectable() +export class RedisReservationStore implements ReservationStore { + private readonly logger = new Logger(RedisReservationStore.name); + private readonly shaAcquire = this.scriptLoad(ACQUIRE_RESERVATION_LUA); + private readonly shaRelease = this.scriptLoad(RELEASE_RESERVATION_LUA); + private readonly shaLookup = this.scriptLoad(LOOKUP_RESERVATION_BY_LOAN_LUA); + private readonly shaSum = this.scriptLoad(SUM_ACTIVE_RESERVATIONS_LUA); + + constructor(private readonly redis: RedisClient) {} + + async acquire(input: { + poolId: string; + capacity: bigint; + reservationId: string; + amount: bigint; + ttlSeconds: number; + metadata: ReservationMetadata; + }): Promise { + const keys = [ + reservationKey.poolActive(input.poolId), + reservationKey.metadata(input.reservationId), + reservationKey.byLoan(input.metadata.loanId), + ]; + const argv = [ + String(Date.now()), + input.reservationId, + input.amount.toString(), + String(input.ttlSeconds), + `${input.reservationId}|${input.amount.toString()}`, + input.capacity.toString(), + JSON.stringify(input.metadata), + ]; + + const raw = (await this.evalsha(this.shaAcquire, ACQUIRE_RESERVATION_LUA, keys, argv)) as + | string[] + | null; + + if (!raw || raw.length === 0) { + throw new Error('Empty response from ACQUIRE_RESERVATION_LUA'); + } + const status = raw[0]; + if (status === 'ok') { + const totalReserved = BigInt(raw[2] ?? '0'); + const expiresAt = new Date(Date.now() + input.ttlSeconds * 1000); + return { + kind: 'acquired', + reservationId: input.reservationId, + totalReserved, + expiresAt, + } satisfies ReservationAcquireOutcome; + } + if (status === 'insufficient') { + return { + kind: 'insufficient', + currentlyReserved: BigInt(raw[1] ?? '0'), + capacity: BigInt(raw[2] ?? input.capacity.toString()), + code: RESERVATION_ERROR_CODES.INSUFFICIENT as ReservationErrorCode, + } satisfies ReservationAcquireOutcome; + } + if (status === 'already') { + return { + kind: 'duplicate', + reservationId: input.reservationId, + code: RESERVATION_ERROR_CODES.CONFLICT as ReservationErrorCode, + } satisfies ReservationAcquireOutcome; + } + throw new Error(`Unexpected ACQUIRE_RESERVATION_LUA status: ${status}`); + } + + async release(input: { + poolId: string; + reservationId: string; + amount: bigint; + }): Promise { + const keys = [ + reservationKey.poolActive(input.poolId), + reservationKey.metadata(input.reservationId), + reservationKey.byLoan(''), // filled below using current loanId + ]; + + // We need the loanId for the by-loan key lookup. Pull it first (best + // effort — release is idempotent regardless). + const metadataRaw = await this.redis.get(reservationKey.metadata(input.reservationId)); + let loanId = ''; + if (metadataRaw) { + try { + const meta = JSON.parse(metadataRaw) as { loanId?: string }; + loanId = meta.loanId ?? ''; + } catch (error) { + this.logger.warn( + { reservationId: input.reservationId, err: (error as Error).message }, + 'Failed to parse reservation metadata during release', + ); + } + } + keys[2] = reservationKey.byLoan(loanId); + + const argv = [ + input.reservationId, + input.amount.toString(), + String(Date.now()), + ]; + + const result = (await this.evalsha(this.shaRelease, RELEASE_RESERVATION_LUA, keys, argv)) as + | number + | null; + + return (result ?? 0) === 1; + } + + async findByLoanId(loanId: string): Promise { + const raw = (await this.evalsha( + this.shaLookup, + LOOKUP_RESERVATION_BY_LOAN_LUA, + [reservationKey.byLoan(loanId)], + [], + )) as string | null; + return raw ?? null; + } + + async getMetadata(reservationId: string): Promise { + const raw = await this.redis.get(reservationKey.metadata(reservationId)); + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as ReservationMetadata; + if (new Date(parsed.expiresAt).getTime() <= Date.now()) { + return null; + } + return parsed; + } catch (error) { + this.logger.warn( + { reservationId, err: (error as Error).message }, + 'Failed to parse reservation metadata', + ); + return null; + } + } + + async totalReserved(poolId: string): Promise { + const raw = (await this.evalsha( + this.shaSum, + SUM_ACTIVE_RESERVATIONS_LUA, + [reservationKey.poolActive(poolId)], + [String(Date.now())], + )) as string | null; + return BigInt(raw ?? '0'); + } + + async listActive(poolId: string): Promise { + const now = Date.now(); + const rawMembers = await this.redis.zrangebyscore( + reservationKey.poolActive(poolId), + now, + '+inf', + ); + if (rawMembers.length === 0) { + return []; + } + const reservationIds = rawMembers.map((m) => m.split('|', 1)[0]); + const metadataBlobs = await this.redis.mget( + ...reservationIds.map((rid) => reservationKey.metadata(rid)), + ); + const out: ReservationMetadata[] = []; + metadataBlobs.forEach((blob, index) => { + if (!blob) { + return; + } + try { + out.push(JSON.parse(blob) as ReservationMetadata); + } catch (error) { + this.logger.warn( + { reservationId: reservationIds[index], err: (error as Error).message }, + 'Skipping malformed reservation metadata in listActive', + ); + } + }); + return out; + } + + private scriptLoad(script: string): string { + // SHA1 of the script for EVALSHA, with EVAL fallback when the script + // has not yet been loaded (returns NOSCRIPT). + return require('crypto').createHash('sha1').update(script).digest('hex'); + } + + private async evalsha( + sha: string, + source: string, + keys: string[], + argv: string[], + ): Promise { + try { + return await this.redis.evalsha(sha, keys.length, ...keys, ...argv); + } catch (error) { + const message = (error as Error).message ?? ''; + if (message.includes('NOSCRIPT')) { + return this.redis.eval(source, keys.length, ...keys, ...argv); + } + throw error; + } + } +} diff --git a/src/modules/liquidity/reservations/reservation-store.interface.ts b/src/modules/liquidity/reservations/reservation-store.interface.ts new file mode 100644 index 0000000..87f0021 --- /dev/null +++ b/src/modules/liquidity/reservations/reservation-store.interface.ts @@ -0,0 +1,76 @@ +import { ReservationErrorCode } from './reservations.constants'; + +/** + * Outcome of an acquire request. + * + * - `acquired`: the reservation is now authoritative; the caller MUST + * commit to it (release on failure, settlement on success). + * - `insufficient`: pool capacity is below the requested amount. + * - `duplicate`: an identically-identified reservation already exists. + */ +export type ReservationAcquireOutcome = + | { kind: 'acquired'; reservationId: string; totalReserved: bigint; expiresAt: Date } + | { + kind: 'insufficient'; + currentlyReserved: bigint; + capacity: bigint; + code: ReservationErrorCode; + } + | { kind: 'duplicate'; reservationId: string; code: ReservationErrorCode }; + +export interface ReservationMetadata { + poolId: string; + wallet: string; + loanId: string; + amount: bigint; + acquiredAt: string; + expiresAt: string; +} + +export interface ReservationStore { + /** + * Atomically acquire `amount` from `poolId` against `capacity`. + * Reservations auto-expire after `ttlSeconds` and are removed on the + * next acquire / release / reconcile call. + */ + acquire(input: { + poolId: string; + capacity: bigint; + reservationId: string; + amount: bigint; + ttlSeconds: number; + metadata: ReservationMetadata; + }): Promise; + + /** + * Release a previously-acquired reservation. Idempotent: a second + * call returns false (no-op). + */ + release(input: { + poolId: string; + reservationId: string; + amount: bigint; + }): Promise; + + /** + * Resolve the active reservation backing a given loanId (read-only). + * Returns null if expired or already released. + */ + findByLoanId(loanId: string): Promise; + + /** + * Get the full metadata document for a reservationId, or null. + */ + getMetadata(reservationId: string): Promise; + + /** + * Sum the stroops currently held by active (non-expired) reservations + * for `poolId`. Expired entries are pruned as a side effect. + */ + totalReserved(poolId: string): Promise; + + /** + * List active reservation metadata entries (used by reconcile & tests). + */ + listActive(poolId: string): Promise; +} diff --git a/src/modules/liquidity/reservations/reservations.constants.ts b/src/modules/liquidity/reservations/reservations.constants.ts new file mode 100644 index 0000000..f316464 --- /dev/null +++ b/src/modules/liquidity/reservations/reservations.constants.ts @@ -0,0 +1,211 @@ +/** + * Constants, key builders, and default configuration for the liquidity + * reservation ledger. + * + * The reservation layer prevents double-spend of pool funds between the + * credit assessment / XDR build step and the on-chain `create_loan` + * settlement. Until that link is sealed, the assessed amount is held + * against the pool so a second concurrent caller cannot pass the same + * availability check. + */ + +export const RESERVATION_DEFAULTS = { + /** + * How long a reservation is considered authoritative. After this + * window a non-submitted loan simply expires and the funds return to + * the pool. Starlight wallets should sign & submit well within this. + */ + ttlSeconds: 15 * 60, + + /** Pool identifier used when none is configured. */ + poolId: 'default', + + /** Reconciliation cron cadence (cleaning expired entries). */ + reconcileCron: '0 */5 * * * *', +} as const; + +export const RESERVATION_ERROR_CODES = { + INSUFFICIENT: 'LIQUIDITY_RESERVATION_INSUFFICIENT', + POOL_UNCONFIGURED: 'LIQUIDITY_POOL_UNCONFIGURED', + CONFLICT: 'LIQUIDITY_RESERVATION_CONFLICT', + REDIS_UNAVAILABLE: 'LIQUIDITY_RESERVATION_STORE_UNAVAILABLE', + INTERNAL: 'LIQUIDITY_RESERVATION_INTERNAL_ERROR', +} as const; + +/** + * Single canonical DI token for the configured {@link ReservationStore}. + * + * Declared once here so the service's `@Inject(RESERVATION_STORE)` and + * any module / test / NestJS provider resolve to the SAME JavaScript + * symbol instance. Two `Symbol('RESERVATION_STORE')` calls would + * produce two distinct symbols and Nest would not link them. + */ +export const RESERVATION_STORE = Symbol('RESERVATION_STORE'); +export const RESERVATION_REDIS_CLIENT = Symbol('RESERVATION_REDIS_CLIENT'); + +export type ReservationErrorCode = + (typeof RESERVATION_ERROR_CODES)[keyof typeof RESERVATION_ERROR_CODES]; + +/** Pool identifier for the global Aprende.fi liquidity pool. */ +export const DEFAULT_LIQUIDITY_POOL_ID = 'default'; + +export const reservationKey = { + /** Per-reservation metadata blob. JSON-encoded, TTL = LOCK_TTL. */ + metadata: (reservationId: string): string => + `liquidity:reservation:${reservationId}`, + + /** + * Reverse index by `loanId` so the status checker can locate the + * reservation when finalising a transaction. TTL is aligned with the + * metadata key — if one is gone, the other is gone. + */ + byLoan: (loanId: string): string => `liquidity:reservation:byLoan:${loanId}`, + + /** + * The active-reservations ZSET for a pool. + * score = epoch-ms expiry timestamp + * member = `${reservationId}|${amount}` + * + * The total reserved amount is derived from this set's active + * (non-expired) members — no separate counter to drift. + */ + poolActive: (poolId: string): string => + `liquidity:reservation:pool:${poolId}:active`, +} as const; + +/** + * Reserve `amount` against `poolId` atomically. + * + * KEYS: + * [1] = pool active ZSET + * [2] = metadata key for the prospective reservation + * [3] = by-loan index key for the prospective reservation + * + * ARGV: + * [1] now (ms) + * [2] reservationId + * [3] amountStr (integer, stroops) + * [4] ttlSeconds + * [5] memberStr (`|`) + * [6] capacityStr (integer, stroops) — max reservable amount + * [7] metadataJson + * + * Returns: + * {'ok',reservationId} on success + * {'insufficient',currentReserved,capacity} when over capacity + * {'already'} when reservationId collides + */ +export const ACQUIRE_RESERVATION_LUA = ` +local now = tonumber(ARGV[1]) +local rid = ARGV[2] +local amount = tonumber(ARGV[3]) +local ttl = tonumber(ARGV[4]) +local member = ARGV[5] +local capacity = tonumber(ARGV[6]) +local metadata = ARGV[7] + +-- 1. Clean expired entries up to now +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', '(' .. now) + +-- 2. Idempotency / collision: if a reservation with this id already exists +if redis.call('EXISTS', KEYS[2]) == 1 then + return {'already'} +end + +-- 3. Sum active reservations for the pool +local members = redis.call('ZRANGE', KEYS[1], 0, -1) +local sum = 0 +for i = 1, #members do + local entry = members[i] + local sep = string.find(entry, '|', 1, true) + if sep then + local n = tonumber(string.sub(entry, sep + 1)) + if n then sum = sum + n end + end +end + +-- 4. Capacity check +if sum + amount > capacity then + return {'insufficient', tostring(sum), tostring(capacity)} +end + +-- 5. Commit +local expiresAt = now + ttl * 1000 +redis.call('ZADD', KEYS[1], expiresAt, member) +redis.call('SET', KEYS[2], metadata, 'EX', ttl) +redis.call('SET', KEYS[3], rid, 'EX', ttl) +return {'ok', rid, tostring(sum + amount)} +`; + +/** + * Release a reservation atomically. + * + * KEYS: + * [1] = pool active ZSET + * [2] = metadata key + * [3] = by-loan key + * + * ARGV: + * [1] reservationId + * [2] amountStr + * [3] nowMs + * + * Returns: + * 1 if released, 0 if not found / already released + */ +export const RELEASE_RESERVATION_LUA = ` +local rid = ARGV[1] +local amount = tonumber(ARGV[2]) +local member = rid .. '|' .. tostring(amount) + +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[3]) + +local removed = redis.call('ZREM', KEYS[1], member) +local metaGone = redis.call('DEL', KEYS[2]) == 1 +local idxGone = redis.call('DEL', KEYS[3]) == 1 + +if removed == 1 or metaGone or idxGone then + return 1 +end +return 0 +`; + +/** + * Resolve reservationId by loanId. + * + * KEYS: + * [1] = by-loan key + * + * ARGV: none + * + * Returns the stored reservationId or nil. + */ +export const LOOKUP_RESERVATION_BY_LOAN_LUA = ` +return redis.call('GET', KEYS[1]) +`; + +/** + * Sum the active reservations in a pool's ZSET, removing expired first. + * + * KEYS: + * [1] = pool active ZSET + * ARGV: + * [1] = nowMs + * + * Returns the sum (as a string of integer stroops). + */ +export const SUM_ACTIVE_RESERVATIONS_LUA = ` +local now = tonumber(ARGV[1]) +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', '(' .. now) +local members = redis.call('ZRANGE', KEYS[1], 0, -1) +local sum = 0 +for i = 1, #members do + local entry = members[i] + local sep = string.find(entry, '|', 1, true) + if sep then + local n = tonumber(string.sub(entry, sep + 1)) + if n then sum = sum + n end + end +end +return tostring(sum) +`; diff --git a/src/modules/liquidity/reservations/reservations.module.ts b/src/modules/liquidity/reservations/reservations.module.ts new file mode 100644 index 0000000..357328c --- /dev/null +++ b/src/modules/liquidity/reservations/reservations.module.ts @@ -0,0 +1,124 @@ +import { Module, Global, FactoryProvider } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { + PrometheusModule, + makeCounterProvider, + makeGaugeProvider, +} from '@willsoto/nestjs-prometheus'; +import { LiquidityReservationService } from './liquidity-reservation.service'; +import { InMemoryReservationStore } from './in-memory-reservation-store'; +import { RedisReservationStore } from './redis-reservation-store'; +import { + ReservationStore, +} from './reservation-store.interface'; +import { + LIQUIDITY_RESERVATION_METRICS, +} from './liquidity-reservation.metrics'; +import { StellarModule } from '../../../stellar/stellar.module'; + +/** + * DI tokens are declared once in {@link ./reservations.constants} and + * imported here from there, so the service's `@Inject(RESERVATION_STORE)` + * matches the providers below to the SAME symbol instance. + */ +import { RESERVATION_STORE, RESERVATION_REDIS_CLIENT } from './reservations.constants'; + +/** Token for the ioredis client used by the RedisReservationStore. */ +// re-exported above from reservations.constants to maintain the public +// shape of this module (Nest provider tokens come from the constants file). +export { RESERVATION_STORE, RESERVATION_REDIS_CLIENT }; + +/** + * Global module that wires the liquidity reservation ledger. + * Exports LiquidityReservationService so loans and the + * transaction-status pipeline can inject it without re-importing. + */ +@Global() +@Module({ + imports: [ConfigModule, StellarModule, PrometheusModule.register()], + providers: [ + InMemoryReservationStore, + { + provide: RESERVATION_REDIS_CLIENT, + useFactory: (configService: ConfigService): unknown => { + const isTest = process.env.NODE_ENV === 'test'; + const configuredUrl = + configService.get('LIQUIDITY_RESERVATION_REDIS_URL') ?? + configService.get('REDIS_URL'); + if (isTest || !configuredUrl) { + return null; + } + try { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const IoRedis = require('ioredis'); + return new IoRedis(configuredUrl, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: false, + }); + } catch { + return null; + } + } catch { + return null; + } + }, + inject: [ConfigService], + } satisfies FactoryProvider, + /** + * ReservationStore selector. Returns RedisReservationStore when + * `LIQUIDITY_RESERVATION_REDIS_URL` (or `REDIS_URL`) is configured + * and the client is constructable; otherwise InMemoryReservationStore + * is used. Tests (NODE_ENV=test) always use the in-memory backend. + */ + { + provide: RESERVATION_STORE, + useFactory: ( + configService: ConfigService, + redisClient: unknown, + inMemoryImpl: InMemoryReservationStore, + ): ReservationStore => { + const isTest = process.env.NODE_ENV === 'test'; + const configuredUrl = + configService.get('LIQUIDITY_RESERVATION_REDIS_URL') ?? + configService.get('REDIS_URL'); + if (isTest || !configuredUrl || !redisClient) { + return inMemoryImpl; + } + return new RedisReservationStore(redisClient as never); + }, + inject: [ConfigService, RESERVATION_REDIS_CLIENT, InMemoryReservationStore], + } satisfies FactoryProvider, + // Prometheus metric providers — keyed by the metric NAME so + // @InjectMetric(name) in the service resolves to the same token. + makeGaugeProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_INFLIGHT, + help: 'Number of in-flight liquidity reservations currently held.', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_ACQUIRED_TOTAL, + help: 'Total number of liquidity reservations acquired since process start.', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_RELEASED_TOTAL, + help: 'Total number of liquidity reservations released since process start.', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_REJECTED_TOTAL, + help: 'Total number of liquidity reservation attempts rejected by the store, labeled by reason.', + labelNames: ['reason'], + }), + makeGaugeProvider({ + name: LIQUIDITY_RESERVATION_METRICS.DRIFT_DETECTED, + help: 'Drift (in stroops) between reservation total and on-chain locked liquidity. Positive only.', + }), + LiquidityReservationService, + ], + exports: [ + LiquidityReservationService, + RESERVATION_STORE, + InMemoryReservationStore, + ], +}) +export class ReservationsModule {} diff --git a/src/modules/loans/dto/create-loan-response.dto.ts b/src/modules/loans/dto/create-loan-response.dto.ts index 232e4d5..e84c6ba 100644 --- a/src/modules/loans/dto/create-loan-response.dto.ts +++ b/src/modules/loans/dto/create-loan-response.dto.ts @@ -34,4 +34,28 @@ export class CreateLoanResponseDto { nullable: true, }) assessment: CreditAssessmentResultDto | null; + + @ApiProperty({ + description: + 'Liquidity reservation handle that proves the pool has committed the requested amount until the loan settles (or auto-expires).', + example: 'resv-pending-1711180800000-ab12cd34-1f9b2d4a-...', + nullable: true, + }) + reservationId: string | null; + + @ApiProperty({ + description: + 'ISO 8601 timestamp at which the reservation auto-releases if the loan has not been confirmed on-chain by then.', + example: '2026-07-17T12:34:56.000Z', + nullable: true, + }) + reservationExpiresAt: string | null; + + @ApiProperty({ + description: + 'Effective pool capacity (in USD) used during the reservation check. Useful for the UI to display remaining headroom.', + example: 8420.5, + nullable: true, + }) + reservationPoolCapacityUsd: number | null; } diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 91b3377..8af1bd9 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -10,6 +10,7 @@ import { ReputationService } from '../reputation/reputation.service'; import { SupabaseService } from '../../database/supabase.client'; import { CreditLineContractClient } from '../../stellar/contracts/clients/creditline.client'; import { ReputationContractClient } from '../../stellar/contracts/clients/reputation.client'; +import { LiquidityReservationService } from '../liquidity/reservations/liquidity-reservation.service'; import { LoanQuoteRequestDto } from './dto/loan-quote-request.dto'; import { LoanQuoteResponseDto, SchedulePaymentDto } from './dto/loan-quote-response.dto'; import { CreateLoanRequestDto } from './dto/create-loan-request.dto'; @@ -51,6 +52,8 @@ interface CreateLoanRecord { term: number; status: 'pending' | 'under_review'; next_payment_due: string | null; + reservation_id?: string | null; + reservation_expires_at?: string | null; } interface LoanPaymentRow { @@ -92,6 +95,7 @@ export class LoansService { private readonly creditLineContractClient: CreditLineContractClient, private readonly reputationContractClient: ReputationContractClient, private readonly creditScoringService: CreditScoringService, + private readonly reservationService: LiquidityReservationService, ) {} async calculateLoanQuote( @@ -118,9 +122,34 @@ export class LoansService { } const status = assessment.decision === 'approved' ? 'pending' : 'under_review'; + + let reservation: { + reservationId: string; + expiresAt: Date; + poolCapacityUsd: number; + } | null = null; + let xdr: string | null = null; if (status === 'pending') { + // Reserve pool liquidity BEFORE building the funding XDR so a + // second concurrent caller cannot pass the same availability + // check and end up producing a fund_loan that will revert with + // InsufficientLiquidity after the user has signed. + try { + reservation = await this.reservationService.reserveForLoan({ + loanId, + wallet, + amountUsd: terms.loanAmount, + }); + } catch (error) { + // reservation errors are already structured; surface them + this.logger.error( + `Reservation refused for ${loanId}: ${(error as Error).message ?? 'unknown error'}`, + ); + throw error; + } + try { xdr = await this.creditLineContractClient.buildCreateLoanTransaction(wallet, { loanId, @@ -133,6 +162,7 @@ export class LoansService { }); } catch (error) { this.logger.error(`Failed to build create_loan XDR for ${loanId}: ${error.message}`); + await this.releaseReservationQuietly(reservation, loanId); throw new InternalServerErrorException({ code: 'BLOCKCHAIN_CREATE_LOAN_XDR_FAILED', message: 'Failed to construct unsigned loan transaction. Please try again.', @@ -154,9 +184,12 @@ export class LoansService { term: terms.term, status, next_payment_due: terms.schedule[0]?.dueDate ?? null, + reservation_id: reservation?.reservationId ?? null, + reservation_expires_at: reservation?.expiresAt.toISOString() ?? null, }); } catch (error) { this.logger.error(`Failed to persist pending loan ${loanId}: ${error.message}`); + await this.releaseReservationQuietly(reservation, loanId); throw new InternalServerErrorException({ code: 'DATABASE_CREATE_LOAN_FAILED', message: 'Failed to persist pending loan record. Please try again.', @@ -169,9 +202,36 @@ export class LoansService { description, terms, assessment, + reservationId: reservation?.reservationId ?? null, + reservationExpiresAt: reservation?.expiresAt.toISOString() ?? null, + reservationPoolCapacityUsd: reservation?.poolCapacityUsd ?? null, }; } + /** + * Best-effort release of a freshly-acquired reservation so that an + * error in buildCreateLoanTransaction or persistPendingLoan does not + * strand pool capacity until the TTL expires. + */ + private async releaseReservationQuietly( + reservation: { reservationId: string } | null, + loanId: string, + ): Promise { + if (!reservation) { + return; + } + try { + const released = await this.reservationService.releaseReservation(reservation.reservationId); + if (!released) { + await this.reservationService.releaseForLoan(loanId); + } + } catch (error) { + this.logger.warn( + `Failed to release reservation ${reservation.reservationId} for ${loanId}: ${(error as Error).message}`, + ); + } + } + async buildRepaymentXdr( wallet: string, loanId: string, diff --git a/supabase/migrations/20260717000003_add_loan_reservation_columns.sql b/supabase/migrations/20260717000003_add_loan_reservation_columns.sql new file mode 100644 index 0000000..3c81742 --- /dev/null +++ b/supabase/migrations/20260717000003_add_loan_reservation_columns.sql @@ -0,0 +1,23 @@ +-- 2026-07-17 — Issue #81 (liquidity reservation layer) +-- +-- Records the reservation handle that backs each pending loan so the +-- transaction-status pipeline can locate and release it when the +-- on-chain funding transaction settles (success or failure). +-- +-- Both columns are nullable: legacy rows pre-dating the reservation +-- layer are unaffected, and loans in `under_review` (manual review) +-- have no reservation because the XDR is never built yet. + +ALTER TABLE loans + ADD COLUMN IF NOT EXISTS reservation_id TEXT NULL, + ADD COLUMN IF NOT EXISTS reservation_expires_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN loans.reservation_id IS + 'Stable handle returned by the liquidity reservation ledger (issue #81). Null when the loan predates the reservation layer or is in manual review.'; + +COMMENT ON COLUMN loans.reservation_expires_at IS + 'Auto-release deadline for the liquidity reservation backing this loan row. Null when no reservation was issued.'; + +CREATE INDEX IF NOT EXISTS idx_loans_reservation_id + ON loans (reservation_id) + WHERE reservation_id IS NOT NULL; diff --git a/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts b/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts new file mode 100644 index 0000000..66f5009 --- /dev/null +++ b/test/unit/jobs/transaction-status-checker/transaction-status-checker.service.spec.ts @@ -0,0 +1,172 @@ +/** + * Minimal spec for the reservation-release hook introduced in + * TransactionStatusCheckerService for issue #81. + * + * The full chain is exercised by the existing transaction-status + * spec; here we drive {@link TransactionStatusCheckerService.finalizeTransaction} + * directly with stubbed Supabase / Horizon responses so the only + * behaviour under test is "after a LOAN_CREATE finalises (success + * OR failed), the matching reservation is released". + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { TransactionStatusCheckerService } from '../../../../src/jobs/transaction-status-checker/transaction-status-checker.service'; +import { SupabaseService } from '../../../../src/database/supabase.client'; +import { LiquidityReservationService } from '../../../../src/modules/liquidity/reservations/liquidity-reservation.service'; +import { TransactionType } from '../../../../src/modules/transactions/dto/submit-transaction-request.dto'; + +// Minimal stub for the PendingTransaction DB shape the service touches. +const baseTx = { + id: 'tx-1', + user_wallet: 'GUSR', + transaction_hash: 'hash-1', + type: TransactionType.LOAN_CREATE, + status: 'pending' as const, + xdr: 'AAAAAgAAAAC...', + submitted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}; + +type TableMock = { [key: string]: jest.Mock | unknown }; + +function makeTxChainMock(): { update: jest.Mock; insert: jest.Mock } { + // The service writes by chaining update(...).eq(col, val).eq(col, val).select(...).single() + // so individual eq calls return further builders until single() resolves. + const chain = { + eq: jest.fn(), + select: jest.fn(), + }; + chain.eq.mockImplementation(() => chain); + chain.select.mockImplementation(() => ({ single: jest.fn().mockResolvedValue({ data: baseTx, error: null }) })); + return { + update: jest.fn().mockReturnValue(chain), + insert: jest.fn().mockResolvedValue({ error: null }), + }; +} + +function makeLoansMock(): { select: jest.Mock; eq: jest.Mock; update: jest.Mock; single: jest.Mock } { + const chain = { eq: jest.fn(), single: jest.fn() }; + chain.eq.mockReturnValue(chain); + chain.single.mockResolvedValue({ data: { loan_id: 'pending-1', status: 'pending' }, error: null }); + return { + select: jest.fn().mockReturnValue(chain), + eq: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + single: jest.fn(), + }; +} + +describe('TransactionStatusCheckerService \u2014 reservation lifecycle (#81)', () => { + let service: TransactionStatusCheckerService; + let mockReservationService: { releaseForLoan: jest.Mock; releaseReservation: jest.Mock }; + let supabaseHandler: { + transactions: { update: jest.Mock; insert: jest.Mock }; + loans: { select: jest.Mock; eq: jest.Mock; update: jest.Mock; single: jest.Mock }; + notifications: { insert: jest.Mock }; + }; + let mockSupabaseService: { getServiceRoleClient: jest.Mock }; + + beforeEach(async () => { + mockReservationService = { + releaseForLoan: jest.fn().mockResolvedValue(true), + releaseReservation: jest.fn().mockResolvedValue(true), + }; + + supabaseHandler = { + transactions: makeTxChainMock(), + loans: makeLoansMock(), + notifications: { insert: jest.fn().mockResolvedValue({ error: null }) }, + }; + + mockSupabaseService = { + getServiceRoleClient: jest.fn(() => ({ + from: (table: string) => + (supabaseHandler as unknown as Record)[table] ?? undefined, + })), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionStatusCheckerService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, fallback?: unknown) => { + if (key === 'STELLAR_HORIZON_URL') return 'http://horizon.test'; + if (key === 'STELLAR_NETWORK_PASSPHRASE') return StellarSdk.Networks.TESTNET; + return fallback; + }), + }, + }, + { provide: SupabaseService, useValue: mockSupabaseService }, + { provide: LiquidityReservationService, useValue: mockReservationService }, + ], + }).compile(); + + service = module.get(TransactionStatusCheckerService); + }); + + it('releases reservation when LOAN_CREATE transaction finalises successfully', async () => { + // The fixture xdr is a placeholder string; in production parseTransactionMetadata + // would resolve a real LoanID. Stub it here so the release path proceeds past + // its `if (!metadata?.loanId) return;` guard. + jest.spyOn( + service as unknown as { parseTransactionMetadata: (x?: string | null) => { loanId?: string } | null }, + 'parseTransactionMetadata', + ).mockReturnValue({ loanId: 'pending-1' }); + + await service['finalizeTransaction'](baseTx, 'success', { foo: 'bar' }); + expect(mockReservationService.releaseForLoan).toHaveBeenCalledWith('pending-1'); + }); + + it('releases reservation when LOAN_CREATE transaction finalises as FAILED', async () => { + jest.spyOn( + service as unknown as { parseTransactionMetadata: (x?: string | null) => { loanId?: string } | null }, + 'parseTransactionMetadata', + ).mockReturnValue({ loanId: 'pending-1' }); + + await service['finalizeTransaction'](baseTx, 'failed', { foo: 'bar' }, 'tx_bad_seq'); + expect(mockReservationService.releaseForLoan).toHaveBeenCalledWith('pending-1'); + }); + + it('does NOT release reservation for a LOAN_REPAY transaction', async () => { + const repayTx = { ...baseTx, type: TransactionType.LOAN_REPAY }; + await service['finalizeTransaction'](repayTx, 'success', { foo: 'bar' }); + expect(mockReservationService.releaseForLoan).not.toHaveBeenCalled(); + }); + + it('does NOT crash the pipeline if release-reservation throws (TTL is the backstop)', async () => { + jest.spyOn( + service as unknown as { parseTransactionMetadata: (x?: string | null) => { loanId?: string } | null }, + 'parseTransactionMetadata', + ).mockReturnValue({ loanId: 'pending-1' }); + mockReservationService.releaseForLoan.mockRejectedValueOnce(new Error('redis down')); + + await expect( + service['finalizeTransaction'](baseTx, 'success', { foo: 'bar' }), + ).resolves.not.toThrow(); + }); + + it('emits the notification AFTER the reservation has been released', async () => { + jest.spyOn( + service as unknown as { parseTransactionMetadata: (x?: string | null) => { loanId?: string } | null }, + 'parseTransactionMetadata', + ).mockReturnValue({ loanId: 'pending-1' }); + + const callOrder: string[] = []; + mockReservationService.releaseForLoan.mockImplementation(async () => { + callOrder.push('release'); + return true; + }); + supabaseHandler.notifications.insert.mockImplementation(async () => { + callOrder.push('notification'); + return { error: null }; + }); + + await service['finalizeTransaction'](baseTx, 'success', { foo: 'bar' }); + + expect(callOrder).toEqual(['release', 'notification']); + }); +}); diff --git a/test/unit/modules/liquidity/reservations/liquidity-reservation.service.spec.ts b/test/unit/modules/liquidity/reservations/liquidity-reservation.service.spec.ts new file mode 100644 index 0000000..fd8536a --- /dev/null +++ b/test/unit/modules/liquidity/reservations/liquidity-reservation.service.spec.ts @@ -0,0 +1,243 @@ +/** + * Spec for LiquidityReservationService — minimal Nest DI plumbing. + * + * Does NOT import {@link ReservationsModule} so we can wire every + * dependency explicitly via `providers:` and avoid the duplicate-provider + * collision (ReservationsModule transitively re-supplies + * LiquidityPoolContractClient via StellarModule). This keeps the + * test focused on LiquidityReservationService's behaviour without + * pulling in unrelated globals. + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { + BadRequestException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { + PrometheusModule, + makeGaugeProvider, + makeCounterProvider, +} from '@willsoto/nestjs-prometheus'; +import { + LiquidityReservationService, +} from '../../../../../src/modules/liquidity/reservations/liquidity-reservation.service'; +import { InMemoryReservationStore } from '../../../../../src/modules/liquidity/reservations/in-memory-reservation-store'; +import { LiquidityPoolContractClient } from '../../../../../src/stellar/contracts/clients/liquidity-pool.client'; +import { LIQUIDITY_RESERVATION_METRICS } from '../../../../../src/modules/liquidity/reservations/liquidity-reservation.metrics'; +import { RESERVATION_STORE } from '../../../../../src/modules/liquidity/reservations/reservations.constants'; + +const STROOPS = 10_000_000n; +const POOL_ID = 'default'; + +interface PoolMock { + getLpShares: jest.Mock; + getPoolStats: jest.Mock; + calculateWithdrawal: jest.Mock; + calculateDeposit: jest.Mock; + buildDepositTx: jest.Mock; + buildWithdrawTx: jest.Mock; +} + +function makePoolMock(): PoolMock { + return { + getLpShares: jest.fn(), + getPoolStats: jest.fn(), + calculateWithdrawal: jest.fn(), + calculateDeposit: jest.fn(), + buildDepositTx: jest.fn(), + buildWithdrawTx: jest.fn(), + }; +} + +async function makeModule(): Promise<{ + service: LiquidityReservationService; + store: InMemoryReservationStore; + pool: PoolMock; +}> { + const store = new InMemoryReservationStore(); + const pool = makePoolMock(); + + // Default mock pool state. + pool.getPoolStats.mockResolvedValue({ + totalLiquidity: 50_000n * STROOPS, + lockedLiquidity: 0n, + availableLiquidity: 10_000n * STROOPS, + totalShares: 25_000n * STROOPS, + sharePrice: 20_000n, + withdrawalFeeBps: 0n, + }); + + // Wire PrometheusModule without default metrics (defaultMetrics.enabled=false + // disables the lib's own collectors — our manual providers below are + // the only ones we need). makeGaugeProvider / makeCounterProvider + // pick up the registry that PrometheusModule provides internally + // via the PROMETHEUS_REGISTRY DI token, which is what @InjectMetric(name) + // resolves against. + const module: TestingModule = await Test.createTestingModule({ + imports: [PrometheusModule.register({ defaultMetrics: { enabled: false } })], + providers: [ + LiquidityReservationService, + InMemoryReservationStore, + { provide: RESERVATION_STORE, useValue: store }, + { provide: LiquidityPoolContractClient, useValue: pool }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, fallback?: unknown) => { + if (key === 'LIQUIDITY_RESERVATION_POOL_ID') return POOL_ID; + if (key === 'LIQUIDITY_RESERVATION_TTL_SECONDS') return 60; + return fallback; + }), + }, + }, + makeGaugeProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_INFLIGHT, + help: 'inflight reservations', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_ACQUIRED_TOTAL, + help: 'reservations acquired', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_RELEASED_TOTAL, + help: 'reservations released', + }), + makeCounterProvider({ + name: LIQUIDITY_RESERVATION_METRICS.RESERVATIONS_REJECTED_TOTAL, + help: 'reservations rejected', + labelNames: ['reason'], + }), + makeGaugeProvider({ + name: LIQUIDITY_RESERVATION_METRICS.DRIFT_DETECTED, + help: 'reservation drift stroops', + }), + ], + }).compile(); + + return { + service: module.get(LiquidityReservationService), + store, + pool, + }; +} + +describe('LiquidityReservationService', () => { + describe('reserveForLoan', () => { + it('acquires the reservation and surfaces the handle to the caller', async () => { + const { service, pool, store } = await makeModule(); + + const handle = await service.reserveForLoan({ + loanId: 'pending-1-abc', + wallet: 'GABC', + amountUsd: 400, + }); + + expect(handle.reservationId).toMatch(/^resv-pending-1-abc-/); + expect(pool.getPoolStats).toHaveBeenCalled(); + expect(await store.totalReserved(POOL_ID)).toBe(400n * STROOPS); + }); + + it('throws BadRequest with structured code when pool capacity is insufficient', async () => { + const { service, pool } = await makeModule(); + pool.getPoolStats.mockResolvedValue({ + totalLiquidity: 1_000n * STROOPS, + lockedLiquidity: 0n, + availableLiquidity: 100n * STROOPS, + totalShares: 0n, + sharePrice: 10_000n, + withdrawalFeeBps: 0n, + }); + + await expect( + service.reserveForLoan({ loanId: 'pending-ins', wallet: 'GABC', amountUsd: 500 }), + ).rejects.toBeInstanceOf(BadRequestException); + + await expect( + service.reserveForLoan({ loanId: 'pending-ins2', wallet: 'GABC', amountUsd: 500 }), + ).rejects.toMatchObject({ + response: { code: 'LIQUIDITY_RESERVATION_INSUFFICIENT' }, + }); + }); + + it('throws ServiceUnavailable when pool stats cannot be read', async () => { + const { service, pool } = await makeModule(); + pool.getPoolStats.mockRejectedValue(new Error('contract down')); + + await expect( + service.reserveForLoan({ loanId: 'pending-x', wallet: 'GABC', amountUsd: 50 }), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + }); + + describe('releaseForLoan', () => { + it('returns false when no reservation is attached to the loanId', async () => { + const { service } = await makeModule(); + const result = await service.releaseForLoan('never-reserved'); + expect(result).toBe(false); + }); + + it('releases by loanId after a previous acquire and zeroes the totalReserved', async () => { + const { service, store, pool } = await makeModule(); + pool.getPoolStats.mockResolvedValue({ + totalLiquidity: 50_000n * STROOPS, + lockedLiquidity: 0n, + availableLiquidity: 10_000n * STROOPS, + totalShares: 25_000n * STROOPS, + sharePrice: 20_000n, + withdrawalFeeBps: 0n, + }); + + await service.reserveForLoan({ loanId: 'pending-rel', wallet: 'GABC', amountUsd: 200 }); + expect(await store.totalReserved(POOL_ID)).toBe(200n * STROOPS); + + const ok = await service.releaseForLoan('pending-rel'); + expect(ok).toBe(true); + expect(await store.totalReserved(POOL_ID)).toBe(0n); + }); + }); + + /** + * **The acceptance test for issue #81**: 10 parallel acquirers against + * a pool whose capacity fits exactly 5 must produce exactly 5 acquires + * and 5 insufficient rejections — no double-spend. + */ + describe('concurrency: parallel acquirers must not double-spend', () => { + it('5 out of 10 parallel acquirers succeed when capacity fits exactly 5', async () => { + const { service, pool } = await makeModule(); + const SUBMITTERS = 10; + const EACH = 100n * STROOPS; + const CAPACITY = 5n * EACH; // exactly 5 fit + + pool.getPoolStats.mockResolvedValue({ + totalLiquidity: CAPACITY, + lockedLiquidity: 0n, + availableLiquidity: CAPACITY, + totalShares: 0n, + sharePrice: 10_000n, + withdrawalFeeBps: 0n, + }); + + const outcomes: Array<'ok' | 'fail'> = await Promise.all( + Array.from({ length: SUBMITTERS }, (_, i) => + service + .reserveForLoan({ + loanId: `loan-${String(i).padStart(3, '0')}`, + wallet: 'GABC', + amountUsd: 100, + }) + .then(() => 'ok' as const) + .catch(() => 'fail' as const), + ), + ); + + const okCount = outcomes.filter((o) => o === 'ok').length; + const failCount = outcomes.filter((o) => o === 'fail').length; + + expect(okCount).toBe(5); + expect(failCount).toBe(5); + expect(await service.getCurrentReservationTotal()).toBe(CAPACITY); + }); + }); +}); diff --git a/test/unit/modules/loans/loans.controller.spec.ts b/test/unit/modules/loans/loans.controller.spec.ts index c7d8b0a..1593b2c 100644 --- a/test/unit/modules/loans/loans.controller.spec.ts +++ b/test/unit/modules/loans/loans.controller.spec.ts @@ -39,8 +39,11 @@ describe('LoansController', () => { loanId: 'pending-1711180800000-ab12cd34', xdr: 'AAAAAgAAAAC...', description: 'Create BNPL loan for $500 at TechStore', - terms: mockQuoteResponse as any, + terms: mockQuoteResponse as CreateLoanResponseDto['terms'], assessment: null, + reservationId: 'resv-pending-1711180800000-ab12cd34-uuid', + reservationExpiresAt: '2026-07-17T13:34:56.000Z', + reservationPoolCapacityUsd: 10000, }; beforeEach(async () => { diff --git a/test/unit/modules/loans/loans.service.spec.ts b/test/unit/modules/loans/loans.service.spec.ts index 2f82922..ce47988 100644 --- a/test/unit/modules/loans/loans.service.spec.ts +++ b/test/unit/modules/loans/loans.service.spec.ts @@ -15,6 +15,7 @@ import { MockReputationContractClient } from '../../../../src/stellar/contracts/ import { LoanListStatusFilter } from '../../../../src/modules/loans/dto/loan-list-query.dto'; import { CreditScoringService } from '../../../../src/modules/credit-scoring/credit-scoring.service'; import { CreditAssessmentResultDto } from '../../../../src/modules/credit-scoring/dto/credit-scoring-response.dto'; +import { LiquidityReservationService } from '../../../../src/modules/liquidity/reservations/liquidity-reservation.service'; describe('LoansService', () => { let service: LoansService; @@ -51,6 +52,14 @@ describe('LoansService', () => { assess: jest.fn(), }; + const mockLiquidityReservationService: Partial = { + reserveForLoan: jest.fn(), + releaseForLoan: jest.fn(), + releaseReservation: jest.fn(), + getCurrentReservationTotal: jest.fn(), + reconcile: jest.fn(), + }; + function mockReputation(score: number, tier: string, interestRate: number, maxCredit: number) { mockReputationService.getReputationScore.mockResolvedValue({ wallet: validWallet, @@ -93,6 +102,7 @@ describe('LoansService', () => { { provide: CreditLineContractClient, useClass: MockCreditLineContractClient }, { provide: ReputationContractClient, useClass: MockReputationContractClient }, { provide: CreditScoringService, useValue: mockCreditScoringService }, + { provide: LiquidityReservationService, useValue: mockLiquidityReservationService }, ], }).compile(); @@ -111,6 +121,15 @@ describe('LoansService', () => { mockSupabaseFrom.update.mockReturnThis(); mockCreditLineContractClient.buildCreateLoanTransaction.mockResolvedValue('AAAAAgAAAAC...'); mockCreditLineContractClient.buildRepayLoanTx.mockResolvedValue('AAAAAgAAAAA...'); + + // Default mock reservation behaviour for createLoan paths. + (mockLiquidityReservationService.reserveForLoan as jest.Mock).mockResolvedValue({ + reservationId: 'resv-pending-1-uuid', + expiresAt: new Date(Date.now() + 900_000), + poolCapacityUsd: 10000, + }); + (mockLiquidityReservationService.releaseReservation as jest.Mock).mockResolvedValue(true); + (mockLiquidityReservationService.releaseForLoan as jest.Mock).mockResolvedValue(false); }); afterEach(() => { @@ -285,18 +304,80 @@ describe('LoansService', () => { expect(result.assessment.decision).toBe('approved'); expect(result.description).toBe('Create BNPL loan for $500 at TechStore'); expect(result.terms.guarantee).toBe(100); + expect(result.reservationId).toBe('resv-pending-1-uuid'); + expect(result.reservationPoolCapacityUsd).toBe(10000); expect(mockCreditLineContractClient.buildCreateLoanTransaction).toHaveBeenCalled(); expect(mockSupabaseClient.from).toHaveBeenCalledWith('loans'); + expect(mockLiquidityReservationService.reserveForLoan).toHaveBeenCalledWith( + expect.objectContaining({ wallet: validWallet, amountUsd: 400 }), + ); expect(mockSupabaseFrom.insert).toHaveBeenCalledWith( expect.objectContaining({ user_wallet: validWallet, vendor_id: vendorId, status: 'pending', next_payment_due: expect.any(String), + reservation_id: 'resv-pending-1-uuid', + reservation_expires_at: expect.any(String), }), ); }); + it('should surface LIQUIDITY_RESERVATION_INSUFFICIENT without building an XDR', async () => { + (mockLiquidityReservationService.reserveForLoan as jest.Mock).mockRejectedValueOnce( + new BadRequestException({ + code: 'LIQUIDITY_RESERVATION_INSUFFICIENT', + message: 'insufficient', + }), + ); + + await expect(service.createLoan(validWallet, baseDto)).rejects.toMatchObject({ + response: { code: 'LIQUIDITY_RESERVATION_INSUFFICIENT' }, + }); + expect(mockCreditLineContractClient.buildCreateLoanTransaction).not.toHaveBeenCalled(); + expect(mockSupabaseFrom.insert).not.toHaveBeenCalled(); + }); + + it('should release the reservation when XDR construction fails', async () => { + mockCreditLineContractClient.buildCreateLoanTransaction.mockRejectedValue( + new Error('Soroban unavailable'), + ); + + await expect(service.createLoan(validWallet, baseDto)).rejects.toThrow( + InternalServerErrorException, + ); + + expect(mockLiquidityReservationService.releaseReservation).toHaveBeenCalledWith( + 'resv-pending-1-uuid', + ); + }); + + it('should release the reservation when DB persistence fails', async () => { + mockSupabaseFrom.insert.mockResolvedValue({ + error: { message: 'insert failed' }, + }); + + await expect(service.createLoan(validWallet, baseDto)).rejects.toThrow( + InternalServerErrorException, + ); + + expect(mockLiquidityReservationService.releaseReservation).toHaveBeenCalledWith( + 'resv-pending-1-uuid', + ); + }); + + it('should NOT acquire a reservation for under_review (manual review) loans', async () => { + mockAssessment('manual_review'); + + const result = await service.createLoan(validWallet, baseDto); + + expect(result.reservationId).toBeNull(); + expect(result.reservationExpiresAt).toBeNull(); + expect(result.reservationPoolCapacityUsd).toBeNull(); + expect(mockLiquidityReservationService.reserveForLoan).not.toHaveBeenCalled(); + expect(mockLiquidityReservationService.releaseReservation).not.toHaveBeenCalled(); + }); + it('should reject loan creation when reputation is below minimum threshold', async () => { mockReputation(59, 'poor', 12, 500); mockVendorFound();