diff --git a/context/progress-tracker.md b/context/progress-tracker.md index a55ec14..3a5dd01 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -16,6 +16,15 @@ pure chore/docs commits). Direct pushes to main must also be logged here. - Updated existing `test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts` to mock the new `LiquidityContractClient` structure. - Updated `test/unit/modules/liquidity/liquidity.service.spec.ts` unit tests. +## 2026-07-18 + +- Added scheduled state reconciliation across indexed on-chain loan, + liquidity, reputation, and transaction state. The idempotent Cron job + resolves provisional loan IDs, repairs stale database state, backfills + missed transaction records, marks orphaned pending transactions, exports + drift metrics, and logs a structured report without making on-chain writes. + Cron is used instead of BullMQ per the API's post-Upstash architecture. + ## 2026-07-16 - Added wallet-bound user roles (sponsor/vendor/mentor): `role` column diff --git a/src/app.module.ts b/src/app.module.ts index 5f76bf0..65d297a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { MetricsModule } from './modules/metrics/metrics.module'; import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.module'; import { AdminModule } from './modules/admin/admin.module'; import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware'; +import { StateReconciliationModule } from './jobs/state-reconciliation/state-reconciliation.module'; @Module({ imports: [ @@ -60,6 +61,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa TransactionStatusCheckerModule, NonceCleanupModule, SupabaseKeepAliveModule, + StateReconciliationModule, CreditScoringModule, AdminModule, StellarModule, diff --git a/src/indexer/authoritative-state.reader.ts b/src/indexer/authoritative-state.reader.ts new file mode 100644 index 0000000..47d970b --- /dev/null +++ b/src/indexer/authoritative-state.reader.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { SupabaseService } from '../database/supabase.client'; + +export interface AuthoritativeLoan { + loanId: string; + userWallet: string; + status: string; + principalAmount: number; + transactionHash: string | null; +} + +export interface AuthoritativeLiquidityPosition { + userWallet: string; + amount: number; +} + +export interface AuthoritativeReputation { + wallet: string; + score: number; +} + +export interface AuthoritativeState { + loans: AuthoritativeLoan[]; + liquidityPositions: AuthoritativeLiquidityPosition[]; + reputations: AuthoritativeReputation[]; + transactionHashes: string[]; +} + +interface LoanIndexRow { + loan_id: string; + user_wallet: string; + status: string; + principal_amount: number | string; + transaction_hash: string | null; +} + +interface InvestmentIndexRow { + user_wallet: string; + amount: number | string; +} + +interface ReputationHistoryRow { + user_wallet: string; + new_score: number; + transaction_hash: string | null; + ledger_sequence: number | null; +} + +interface PaymentIndexRow { + tx_hash: string; +} + +@Injectable() +export class AuthoritativeStateReader { + constructor(private readonly supabaseService: SupabaseService) {} + + async read(): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const [loanResult, investmentResult, reputationResult, paymentResult] = await Promise.all([ + db.from('loan_index').select('loan_id, user_wallet, status, principal_amount, transaction_hash'), + db.from('investments_index').select('user_wallet, amount'), + db + .from('reputation_history') + .select('user_wallet, new_score, transaction_hash, ledger_sequence') + .order('ledger_sequence', { ascending: false }), + db.from('payment_index').select('tx_hash'), + ]); + + const error = loanResult.error ?? investmentResult.error ?? reputationResult.error ?? paymentResult.error; + if (error) { + throw new Error(`Failed to read authoritative indexed state: ${error.message}`); + } + + const loans = (loanResult.data ?? []) as LoanIndexRow[]; + const investments = (investmentResult.data ?? []) as InvestmentIndexRow[]; + const reputationRows = (reputationResult.data ?? []) as ReputationHistoryRow[]; + const payments = (paymentResult.data ?? []) as PaymentIndexRow[]; + const latestReputation = new Map(); + for (const row of reputationRows) { + if (!latestReputation.has(row.user_wallet)) { + latestReputation.set(row.user_wallet, { wallet: row.user_wallet, score: row.new_score }); + } + } + + const transactionHashes = new Set(); + for (const row of loans) if (row.transaction_hash) transactionHashes.add(row.transaction_hash); + for (const row of reputationRows) if (row.transaction_hash) transactionHashes.add(row.transaction_hash); + for (const row of payments) if (row.tx_hash) transactionHashes.add(row.tx_hash); + + const liquidityByWallet = new Map(); + for (const row of investments) { + liquidityByWallet.set( + row.user_wallet, + (liquidityByWallet.get(row.user_wallet) ?? 0) + Number(row.amount), + ); + } + + return { + loans: loans.map((row) => ({ + loanId: row.loan_id, + userWallet: row.user_wallet, + status: row.status, + principalAmount: Number(row.principal_amount), + transactionHash: row.transaction_hash, + })), + liquidityPositions: [...liquidityByWallet].map(([userWallet, amount]) => ({ + userWallet, + amount, + })), + reputations: [...latestReputation.values()], + transactionHashes: [...transactionHashes], + }; + } +} diff --git a/src/indexer/indexer.module.ts b/src/indexer/indexer.module.ts index 03a8a17..1ced51d 100644 --- a/src/indexer/indexer.module.ts +++ b/src/indexer/indexer.module.ts @@ -6,6 +6,7 @@ import { SupabaseService } from '../database/supabase.client'; import { StellarModule } from '../stellar/stellar.module'; import { IndexerController } from './indexer.controller'; import { IndexerStatusService } from './indexer-status.service'; +import { AuthoritativeStateReader } from './authoritative-state.reader'; @Module({ imports: [ConfigModule, StellarModule], @@ -15,6 +16,8 @@ import { IndexerStatusService } from './indexer-status.service'; EventParserService, SupabaseService, IndexerStatusService, + AuthoritativeStateReader, ], + exports: [AuthoritativeStateReader], }) export class IndexerModule {} diff --git a/src/indexer/indexer.service.ts b/src/indexer/indexer.service.ts index c054b90..1a2c28e 100644 --- a/src/indexer/indexer.service.ts +++ b/src/indexer/indexer.service.ts @@ -254,6 +254,8 @@ export class IndexerService { interest_amount: payload.interestAmount, due_date: payload.dueDate, event_id: event.eventId, + transaction_hash: event.txHash, + ledger_sequence: event.ledgerSequence, last_synced_at: new Date().toISOString(), }, { onConflict: 'event_id', ignoreDuplicates: true }, diff --git a/src/jobs/state-reconciliation/state-reconciliation.module.ts b/src/jobs/state-reconciliation/state-reconciliation.module.ts new file mode 100644 index 0000000..5c96862 --- /dev/null +++ b/src/jobs/state-reconciliation/state-reconciliation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { IndexerModule } from '../../indexer/indexer.module'; +import { LoansModule } from '../../modules/loans/loans.module'; +import { TransactionsModule } from '../../modules/transactions/transactions.module'; +import { SupabaseService } from '../../database/supabase.client'; +import { StateReconciliationProcessor } from './state-reconciliation.processor'; + +@Module({ + imports: [IndexerModule, LoansModule, TransactionsModule], + providers: [StateReconciliationProcessor, SupabaseService], +}) +export class StateReconciliationModule {} diff --git a/src/jobs/state-reconciliation/state-reconciliation.processor.ts b/src/jobs/state-reconciliation/state-reconciliation.processor.ts new file mode 100644 index 0000000..166f2fb --- /dev/null +++ b/src/jobs/state-reconciliation/state-reconciliation.processor.ts @@ -0,0 +1,194 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { SupabaseService } from '../../database/supabase.client'; +import { + AuthoritativeState, + AuthoritativeStateReader, +} from '../../indexer/authoritative-state.reader'; +import { LoansRepository, ReconciliationLoanRow } from '../../modules/loans/loans.repository'; +import { MetricsService } from '../../modules/metrics/metrics.service'; +import { + ReconciliationTransaction, + TransactionsRepository, +} from '../../modules/transactions/transactions.repository'; +import { DriftItem, DriftReport, DriftType } from './state-reconciliation.types'; + +interface LiquidityRow { + id: string; + provider_wallet: string; + deposited_amount: number | string; +} + +interface ReputationRow { + wallet_address: string; + score: number; +} + +const DRIFT_TYPES: DriftType[] = [ + 'missing_loan_row', + 'stale_loan_status', + 'provisional_loan_id', + 'orphaned_pending_transaction', + 'missing_transaction_row', + 'stale_liquidity_position', + 'stale_reputation', +]; + +@Injectable() +export class StateReconciliationProcessor { + private readonly logger = new Logger(StateReconciliationProcessor.name); + private isRunning = false; + + constructor( + private readonly authoritativeStateReader: AuthoritativeStateReader, + private readonly loansRepository: LoansRepository, + private readonly transactionsRepository: TransactionsRepository, + private readonly supabaseService: SupabaseService, + private readonly metricsService: MetricsService, + ) {} + + @Cron('0 */15 * * * *') + async runScheduled(): Promise { + if (this.isRunning) { + this.logger.debug('State reconciliation already running; skipping overlapping cycle'); + return; + } + this.isRunning = true; + try { + const report = await this.reconcile(); + this.logger.log({ event: 'state_reconciliation_completed', report }, 'State reconciliation completed'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ event: 'state_reconciliation_failed', error: message }, 'State reconciliation failed'); + } finally { + this.isRunning = false; + } + } + + async reconcile(): Promise { + const startedAt = new Date().toISOString(); + const [chain, loans, transactions] = await Promise.all([ + this.authoritativeStateReader.read(), + this.loansRepository.findForReconciliation(), + this.transactionsRepository.findForReconciliation(), + ]); + const items: DriftItem[] = []; + await this.reconcileLoans(chain, loans, items); + await this.reconcileTransactions(chain, transactions, items); + await this.reconcileLiquidity(chain, items); + await this.reconcileReputation(chain, items); + + const byType = Object.fromEntries(DRIFT_TYPES.map((type) => [type, 0])) as Record; + for (const item of items) byType[item.type] += 1; + for (const type of DRIFT_TYPES) this.metricsService.setReconciliationDrift(type, byType[type]); + + return { + startedAt, + completedAt: new Date().toISOString(), + driftCount: items.length, + repairedCount: items.filter((item) => item.repaired).length, + byType, + items, + }; + } + + private async reconcileLoans( + chain: AuthoritativeState, + rows: ReconciliationLoanRow[], + items: DriftItem[], + ): Promise { + const rowsByLoanId = new Map(rows.map((row) => [row.loanId, row])); + const provisional = rows.filter((row) => + row.loanId.startsWith('loan_') || + row.loanId.startsWith('pending_') || + row.loanId.startsWith('pending-'), + ); + for (const chainLoan of chain.loans) { + const row = rowsByLoanId.get(chainLoan.loanId); + if (row) { + if (row.status !== chainLoan.status) { + await this.loansRepository.updateStatus(row.id, chainLoan.status); + items.push({ type: 'stale_loan_status', key: chainLoan.loanId, repaired: true, details: { from: row.status, to: chainLoan.status } }); + } + continue; + } + const candidates = provisional.filter((candidate) => candidate.userWallet === chainLoan.userWallet); + const match = candidates.find((candidate) => + Math.abs(candidate.amount - chainLoan.principalAmount) < 0.000001 || + Math.abs(candidate.amount * 0.8 - chainLoan.principalAmount) < 0.000001, + ) ?? (candidates.length === 1 ? candidates[0] : undefined); + if (match) { + await this.loansRepository.resolveProvisionalId(match.id, chainLoan); + items.push({ type: 'provisional_loan_id', key: match.loanId, repaired: true, details: { chainLoanId: chainLoan.loanId } }); + } else { + items.push({ type: 'missing_loan_row', key: chainLoan.loanId, repaired: false, details: { userWallet: chainLoan.userWallet } }); + } + } + } + + private async reconcileTransactions( + chain: AuthoritativeState, + rows: ReconciliationTransaction[], + items: DriftItem[], + ): Promise { + const chainHashes = new Set(chain.transactionHashes.map((hash) => hash.toLowerCase())); + const rowsByHash = new Map(rows.map((row) => [row.hash.toLowerCase(), row])); + for (const hash of chainHashes) { + const row = rowsByHash.get(hash); + if (!row) { + await this.transactionsRepository.backfill(hash); + items.push({ type: 'missing_transaction_row', key: hash, repaired: true, details: {} }); + } else if (row.status === 'pending') { + await this.transactionsRepository.markSucceeded(row.id); + items.push({ type: 'orphaned_pending_transaction', key: hash, repaired: true, details: { resolution: 'success' } }); + } + } + const cutoff = Date.now() - 30 * 60 * 1000; + for (const row of rows) { + if (row.status === 'pending' && !chainHashes.has(row.hash.toLowerCase()) && Date.parse(row.submittedAt) < cutoff) { + await this.transactionsRepository.markOrphaned(row.id); + items.push({ type: 'orphaned_pending_transaction', key: row.hash, repaired: true, details: { resolution: 'failed' } }); + } + } + } + + private async reconcileLiquidity(chain: AuthoritativeState, items: DriftItem[]): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const { data, error } = await db.from('liquidity_positions').select('id, provider_wallet, deposited_amount'); + if (error) throw new Error(`Failed to read liquidity positions: ${error.message}`); + const rows = (data ?? []) as LiquidityRow[]; + const byWallet = new Map(rows.map((row) => [row.provider_wallet, row])); + for (const position of chain.liquidityPositions) { + const row = byWallet.get(position.userWallet); + if (!row || Number(row.deposited_amount) !== position.amount) { + const payload = { provider_wallet: position.userWallet, deposited_amount: position.amount, updated_at: new Date().toISOString() }; + const result = row + ? await db.from('liquidity_positions').update(payload).eq('id', row.id) + : await db.from('liquidity_positions').insert(payload); + if (result.error) throw new Error(`Failed to reconcile liquidity position: ${result.error.message}`); + items.push({ type: 'stale_liquidity_position', key: position.userWallet, repaired: true, details: { amount: position.amount } }); + } + } + } + + private async reconcileReputation(chain: AuthoritativeState, items: DriftItem[]): Promise { + const db = this.supabaseService.getServiceRoleClient(); + const { data, error } = await db.from('reputation_cache').select('wallet_address, score'); + if (error) throw new Error(`Failed to read reputation cache: ${error.message}`); + const rows = (data ?? []) as ReputationRow[]; + const byWallet = new Map(rows.map((row) => [row.wallet_address, row])); + for (const reputation of chain.reputations) { + const row = byWallet.get(reputation.wallet); + if (!row) { + items.push({ type: 'stale_reputation', key: reputation.wallet, repaired: false, details: { score: reputation.score, reason: 'cache row requires user relation' } }); + } else if (row.score !== reputation.score) { + const { error: updateError } = await db + .from('reputation_cache') + .update({ score: reputation.score, last_synced_at: new Date().toISOString() }) + .eq('wallet_address', reputation.wallet); + if (updateError) throw new Error(`Failed to reconcile reputation: ${updateError.message}`); + items.push({ type: 'stale_reputation', key: reputation.wallet, repaired: true, details: { score: reputation.score } }); + } + } + } +} diff --git a/src/jobs/state-reconciliation/state-reconciliation.types.ts b/src/jobs/state-reconciliation/state-reconciliation.types.ts new file mode 100644 index 0000000..fa3964a --- /dev/null +++ b/src/jobs/state-reconciliation/state-reconciliation.types.ts @@ -0,0 +1,24 @@ +export type DriftType = + | 'missing_loan_row' + | 'stale_loan_status' + | 'provisional_loan_id' + | 'orphaned_pending_transaction' + | 'missing_transaction_row' + | 'stale_liquidity_position' + | 'stale_reputation'; + +export interface DriftItem { + type: DriftType; + key: string; + repaired: boolean; + details: Record; +} + +export interface DriftReport { + startedAt: string; + completedAt: string; + driftCount: number; + repairedCount: number; + byType: Record; + items: DriftItem[]; +} diff --git a/src/modules/loans/loans.module.ts b/src/modules/loans/loans.module.ts index 7a3f140..e52d3a9 100644 --- a/src/modules/loans/loans.module.ts +++ b/src/modules/loans/loans.module.ts @@ -8,6 +8,7 @@ import { BlockchainModule } from '../blockchain/blockchain.module'; import { SupabaseService } from '../../database/supabase.client'; import { StellarModule } from '../../stellar/stellar.module'; import { CreditScoringModule } from '../credit-scoring/credit-scoring.module'; +import { LoansRepository } from './loans.repository'; @Module({ imports: [ConfigModule, AuthModule, ReputationModule, BlockchainModule, StellarModule, CreditScoringModule], @@ -15,8 +16,9 @@ import { CreditScoringModule } from '../credit-scoring/credit-scoring.module'; providers: [ LoansService, SupabaseService, + LoansRepository, ], - exports: [LoansService], + exports: [LoansService, LoansRepository], }) export class LoansModule {} diff --git a/src/modules/loans/loans.repository.ts b/src/modules/loans/loans.repository.ts new file mode 100644 index 0000000..85b8049 --- /dev/null +++ b/src/modules/loans/loans.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { SupabaseService } from '../../database/supabase.client'; +import { AuthoritativeLoan } from '../../indexer/authoritative-state.reader'; + +export interface ReconciliationLoanRow { + id: string; + loanId: string; + userWallet: string; + amount: number; + status: string; +} + +interface LoanRow { + id: string; + loan_id: string; + user_wallet: string; + amount: number | string; + status: string; +} + +@Injectable() +export class LoansRepository { + constructor(private readonly supabaseService: SupabaseService) {} + + async findForReconciliation(): Promise { + const { data, error } = await this.supabaseService + .getServiceRoleClient() + .from('loans') + .select('id, loan_id, user_wallet, amount, status'); + if (error) throw new Error(`Failed to read loans for reconciliation: ${error.message}`); + return ((data ?? []) as LoanRow[]).map((row) => ({ + id: row.id, + loanId: row.loan_id, + userWallet: row.user_wallet, + amount: Number(row.amount), + status: row.status, + })); + } + + async resolveProvisionalId(rowId: string, chainLoan: AuthoritativeLoan): Promise { + const { error } = await this.supabaseService + .getServiceRoleClient() + .from('loans') + .update({ loan_id: chainLoan.loanId, status: chainLoan.status, updated_at: new Date().toISOString() }) + .eq('id', rowId); + if (error) throw new Error(`Failed to resolve provisional loan ID: ${error.message}`); + } + + async updateStatus(rowId: string, status: string): Promise { + const { error } = await this.supabaseService + .getServiceRoleClient() + .from('loans') + .update({ status, updated_at: new Date().toISOString() }) + .eq('id', rowId); + if (error) throw new Error(`Failed to reconcile loan status: ${error.message}`); + } +} diff --git a/src/modules/metrics/metrics.service.ts b/src/modules/metrics/metrics.service.ts index f654173..5f35f5b 100644 --- a/src/modules/metrics/metrics.service.ts +++ b/src/modules/metrics/metrics.service.ts @@ -12,6 +12,7 @@ export const HTTP_REQUEST_DURATION_SECONDS = 'http_request_duration_seconds'; export const INDEXER_LAG = 'indexer_lag_ledgers'; export const HORIZON_HEALTH = 'horizon_up'; export const DB_POOL_OPEN = 'db_pool_open'; +export const RECONCILIATION_DRIFT = 'state_reconciliation_drift'; export const metricProviders = [ makeCounterProvider({ @@ -37,6 +38,11 @@ export const metricProviders = [ name: DB_POOL_OPEN, help: 'Number of open database connections', }), + makeGaugeProvider({ + name: RECONCILIATION_DRIFT, + help: 'Current reconciliation drift count by mismatch type', + labelNames: ['type'] as const, + }), ]; @Injectable() @@ -52,6 +58,8 @@ export class MetricsService { private readonly horizonHealth: Gauge, @InjectMetric(DB_POOL_OPEN) private readonly dbPoolOpen: Gauge, + @InjectMetric(RECONCILIATION_DRIFT) + private readonly reconciliationDrift: Gauge, ) {} async getMetrics(): Promise { @@ -78,4 +86,8 @@ export class MetricsService { setDbPoolOpen(count: number): void { this.dbPoolOpen.set(count); } + + setReconciliationDrift(type: string, count: number): void { + this.reconciliationDrift.labels(type).set(count); + } } diff --git a/src/modules/transactions/transactions.module.ts b/src/modules/transactions/transactions.module.ts index 8aa42b8..9521268 100644 --- a/src/modules/transactions/transactions.module.ts +++ b/src/modules/transactions/transactions.module.ts @@ -6,6 +6,7 @@ import { TransactionsService } from './transactions.service'; import { AuthModule } from '../auth/auth.module'; import { SupabaseService } from '../../database/supabase.client'; import { getRedisConfig } from '../../config/redis.config'; +import { TransactionsRepository } from './transactions.repository'; @Module({ imports: [ @@ -18,7 +19,7 @@ import { getRedisConfig } from '../../config/redis.config'; }), ], controllers: [TransactionsController], - providers: [TransactionsService, SupabaseService], - exports: [TransactionsService], + providers: [TransactionsService, TransactionsRepository, SupabaseService], + exports: [TransactionsService, TransactionsRepository], }) export class TransactionsModule {} diff --git a/src/modules/transactions/transactions.repository.ts b/src/modules/transactions/transactions.repository.ts new file mode 100644 index 0000000..393218b --- /dev/null +++ b/src/modules/transactions/transactions.repository.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { SupabaseService } from '../../database/supabase.client'; + +export interface ReconciliationTransaction { + id: string; + hash: string; + status: string; + submittedAt: string; +} + +interface TransactionRow { + id: string; + transaction_hash: string | null; + hash: string | null; + status: string; + submitted_at: string; +} + +@Injectable() +export class TransactionsRepository { + constructor(private readonly supabaseService: SupabaseService) {} + + async findForReconciliation(): Promise { + const { data, error } = await this.supabaseService + .getServiceRoleClient() + .from('transactions') + .select('id, transaction_hash, hash, status, submitted_at'); + if (error) throw new Error(`Failed to read transactions for reconciliation: ${error.message}`); + return ((data ?? []) as TransactionRow[]).flatMap((row) => { + const hash = row.transaction_hash ?? row.hash; + return hash ? [{ id: row.id, hash, status: row.status, submittedAt: row.submitted_at }] : []; + }); + } + + async markSucceeded(id: string): Promise { + await this.update(id, { status: 'success', completed_at: new Date().toISOString(), error: null }); + } + + async markOrphaned(id: string): Promise { + await this.update(id, { + status: 'failed', + completed_at: new Date().toISOString(), + error: 'Reconciliation: transaction not found in authoritative indexed events', + }); + } + + async backfill(hash: string): Promise { + const now = new Date().toISOString(); + const { error } = await this.supabaseService.getServiceRoleClient().from('transactions').upsert( + { + transaction_hash: hash, + user_wallet: 'indexer', + type: 'indexed_event', + status: 'success', + xdr: '', + submitted_at: now, + completed_at: now, + updated_at: now, + }, + { onConflict: 'transaction_hash', ignoreDuplicates: true }, + ); + if (error) throw new Error(`Failed to backfill indexed transaction: ${error.message}`); + } + + private async update(id: string, values: Record): Promise { + const { error } = await this.supabaseService + .getServiceRoleClient() + .from('transactions') + .update({ ...values, updated_at: new Date().toISOString() }) + .eq('id', id); + if (error) throw new Error(`Failed to reconcile transaction: ${error.message}`); + } +} diff --git a/supabase/migrations/20260718000000_add_loan_index_chain_metadata.sql b/supabase/migrations/20260718000000_add_loan_index_chain_metadata.sql new file mode 100644 index 0000000..6eb6e92 --- /dev/null +++ b/supabase/migrations/20260718000000_add_loan_index_chain_metadata.sql @@ -0,0 +1,7 @@ +ALTER TABLE public.loan_index + ADD COLUMN IF NOT EXISTS transaction_hash TEXT, + ADD COLUMN IF NOT EXISTS ledger_sequence BIGINT; + +CREATE INDEX IF NOT EXISTS loan_index_transaction_hash_idx + ON public.loan_index (transaction_hash) + WHERE transaction_hash IS NOT NULL; diff --git a/test/unit/jobs/state-reconciliation/state-reconciliation.processor.spec.ts b/test/unit/jobs/state-reconciliation/state-reconciliation.processor.spec.ts new file mode 100644 index 0000000..64632c5 --- /dev/null +++ b/test/unit/jobs/state-reconciliation/state-reconciliation.processor.spec.ts @@ -0,0 +1,132 @@ +import { StateReconciliationProcessor } from '../../../../src/jobs/state-reconciliation/state-reconciliation.processor'; +import { AuthoritativeState } from '../../../../src/indexer/authoritative-state.reader'; + +const emptyState: AuthoritativeState = { + loans: [], + liquidityPositions: [], + reputations: [], + transactionHashes: [], +}; + +function createSupabaseMock( + liquidity: Array<{ id: string; provider_wallet: string; deposited_amount: number }> = [], + reputation: Array<{ wallet_address: string; score: number }> = [], +) { + const eq = jest.fn().mockResolvedValue({ error: null }); + const update = jest.fn().mockReturnValue({ eq }); + const insert = jest.fn().mockResolvedValue({ error: null }); + return { + client: { + from: jest.fn((table: string) => ({ + select: jest.fn().mockResolvedValue({ + data: table === 'liquidity_positions' ? liquidity : reputation, + error: null, + }), + update, + insert, + })), + }, + update, + insert, + }; +} + +function createProcessor(state: AuthoritativeState, options: { + loans?: Array<{ id: string; loanId: string; userWallet: string; amount: number; status: string }>; + transactions?: Array<{ id: string; hash: string; status: string; submittedAt: string }>; + liquidity?: Array<{ id: string; provider_wallet: string; deposited_amount: number }>; + reputation?: Array<{ wallet_address: string; score: number }>; +} = {}) { + const supabase = createSupabaseMock(options.liquidity, options.reputation); + const loansRepository = { + findForReconciliation: jest.fn().mockResolvedValue(options.loans ?? []), + resolveProvisionalId: jest.fn().mockResolvedValue(undefined), + updateStatus: jest.fn().mockResolvedValue(undefined), + }; + const transactionsRepository = { + findForReconciliation: jest.fn().mockResolvedValue(options.transactions ?? []), + markSucceeded: jest.fn().mockResolvedValue(undefined), + markOrphaned: jest.fn().mockResolvedValue(undefined), + backfill: jest.fn().mockResolvedValue(undefined), + }; + const metrics = { setReconciliationDrift: jest.fn() }; + const processor = new StateReconciliationProcessor( + { read: jest.fn().mockResolvedValue(state) } as never, + loansRepository as never, + transactionsRepository as never, + { getServiceRoleClient: () => supabase.client } as never, + metrics as never, + ); + return { processor, loansRepository, transactionsRepository, metrics, supabase }; +} + +describe('StateReconciliationProcessor', () => { + it('classifies and repairs provisional IDs and stale loan status, and reports missing rows', async () => { + const state: AuthoritativeState = { + ...emptyState, + loans: [ + { loanId: 'chain-1', userWallet: 'GA', status: 'active', principalAmount: 100, transactionHash: null }, + { loanId: 'chain-2', userWallet: 'GB', status: 'paid', principalAmount: 200, transactionHash: null }, + { loanId: 'chain-3', userWallet: 'GC', status: 'active', principalAmount: 300, transactionHash: null }, + ], + }; + const context = createProcessor(state, { + loans: [ + { id: '1', loanId: 'loan_provisional', userWallet: 'GA', amount: 100, status: 'pending' }, + { id: '2', loanId: 'chain-2', userWallet: 'GB', amount: 200, status: 'active' }, + ], + }); + + const report = await context.processor.reconcile(); + + expect(context.loansRepository.resolveProvisionalId).toHaveBeenCalledTimes(1); + expect(context.loansRepository.updateStatus).toHaveBeenCalledWith('2', 'paid'); + expect(report.byType.provisional_loan_id).toBe(1); + expect(report.byType.stale_loan_status).toBe(1); + expect(report.byType.missing_loan_row).toBe(1); + }); + + it('backfills missed events and resolves successful and orphaned pending transactions', async () => { + const state: AuthoritativeState = { ...emptyState, transactionHashes: ['known', 'missing'] }; + const context = createProcessor(state, { + transactions: [ + { id: '1', hash: 'known', status: 'pending', submittedAt: new Date().toISOString() }, + { id: '2', hash: 'orphan', status: 'pending', submittedAt: '2020-01-01T00:00:00.000Z' }, + ], + }); + + const report = await context.processor.reconcile(); + + expect(context.transactionsRepository.markSucceeded).toHaveBeenCalledWith('1'); + expect(context.transactionsRepository.backfill).toHaveBeenCalledWith('missing'); + expect(context.transactionsRepository.markOrphaned).toHaveBeenCalledWith('2'); + expect(report.byType.missing_transaction_row).toBe(1); + expect(report.byType.orphaned_pending_transaction).toBe(2); + }); + + it('repairs stale liquidity and reputation projections and exports drift gauges', async () => { + const state: AuthoritativeState = { + ...emptyState, + liquidityPositions: [{ userWallet: 'GA', amount: 50 }], + reputations: [{ wallet: 'GA', score: 700 }], + }; + const context = createProcessor(state, { + liquidity: [{ id: 'lp-1', provider_wallet: 'GA', deposited_amount: 10 }], + reputation: [{ wallet_address: 'GA', score: 600 }], + }); + + const report = await context.processor.reconcile(); + + expect(report.byType.stale_liquidity_position).toBe(1); + expect(report.byType.stale_reputation).toBe(1); + expect(context.supabase.update).toHaveBeenCalledTimes(2); + expect(context.metrics.setReconciliationDrift).toHaveBeenCalledWith('stale_reputation', 1); + }); + + it('is idempotent when indexed state and database projections agree', async () => { + const context = createProcessor(emptyState); + const report = await context.processor.reconcile(); + expect(report.driftCount).toBe(0); + expect(report.repairedCount).toBe(0); + }); +}); diff --git a/test/unit/modules/health/health.service.spec.ts b/test/unit/modules/health/health.service.spec.ts index 6dc82bc..8973a26 100644 --- a/test/unit/modules/health/health.service.spec.ts +++ b/test/unit/modules/health/health.service.spec.ts @@ -49,6 +49,14 @@ describe('HealthService', () => { }); it('should return health status', async () => { + jest.spyOn(service, 'checkDatabase').mockResolvedValue({ + status: 'ok', + database: 'connected', + message: 'Supabase reachable', + }); + jest.spyOn(service, 'checkHorizon').mockResolvedValue({ status: 'ok' }); + jest.spyOn(service, 'checkIndexerLag').mockResolvedValue({ status: 'ok' }); + const result = await service.check(); expect(result).toHaveProperty('status');