diff --git a/backend/src/__tests__/integration/loanDisputeFlow.test.ts b/backend/src/__tests__/integration/loanDisputeFlow.test.ts index e57797305..4119dee13 100644 --- a/backend/src/__tests__/integration/loanDisputeFlow.test.ts +++ b/backend/src/__tests__/integration/loanDisputeFlow.test.ts @@ -29,6 +29,16 @@ jest.unstable_mockModule('../../services/notificationService.js', () => ({ }, })); +// The on-chain leg of a reversed default (#1803). Mocked so the flow can be +// exercised without an RPC endpoint or an admin signing key. +const mockReverseDefault = jest.fn<(...args: unknown[]) => Promise>(); +jest.unstable_mockModule('../../services/defaultChecker.js', () => ({ + defaultChecker: { reverseDefault: mockReverseDefault }, + DefaultChecker: class {}, + startDefaultCheckerScheduler: jest.fn(), + stopDefaultCheckerScheduler: jest.fn(), +})); + let request: typeof import('supertest'); let jwt: typeof import('jsonwebtoken'); let app: any; @@ -87,6 +97,7 @@ beforeEach(() => { mockQuery.mockReset(); mockNotifyAdmins.mockReset(); mockCreateNotification.mockReset(); + mockReverseDefault.mockReset(); mockNotifyAdmins.mockResolvedValue(undefined); mockCreateNotification.mockResolvedValue({ id: 1, @@ -206,6 +217,14 @@ describe('loan dispute resolution integration flow', () => { expect(contestRes.body.success).toBe(true); expect(mockNotifyAdmins).toHaveBeenCalledTimes(1); + // The reversal must reach the ledger before anything is written. + mockReverseDefault.mockResolvedValueOnce({ + loanId: LOAN_ID, + txHash: 'reverse-tx-hash', + ledger: 777, + txStatus: 'SUCCESS', + }); + mockQuery .mockResolvedValueOnce( dbRows([ @@ -231,6 +250,7 @@ describe('loan dispute resolution integration flow', () => { expect(resolveRes.status).toBe(200); expect(resolveRes.body.success).toBe(true); + expect(mockReverseDefault).toHaveBeenCalledWith(LOAN_ID); expect(mockCreateNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: TEST_PUBLIC_KEY, @@ -259,7 +279,8 @@ describe('loan dispute resolution integration flow', () => { expect.any(String), LOAN_ID, TEST_PUBLIC_KEY, - `admin-dispute:${DISPUTE_ID}`, + 777, + 'reverse-tx-hash', expect.any(String), ], ], diff --git a/backend/src/__tests__/loanDispute.test.ts b/backend/src/__tests__/loanDispute.test.ts index d6fe08dbb..a9705278a 100644 --- a/backend/src/__tests__/loanDispute.test.ts +++ b/backend/src/__tests__/loanDispute.test.ts @@ -18,6 +18,17 @@ jest.unstable_mockModule('../db/transaction.js', () => ({ withStellarAndDbTransaction: jest.fn(), })); +// The on-chain leg of a dispute reversal (#1803) is submitted through +// `defaultChecker.reverseDefault`; mocking it keeps the suite off the network +// and lets the tests drive both the success and the failure path. +const mockReverseDefault = jest.fn<(...args: unknown[]) => Promise>(); +jest.unstable_mockModule('../services/defaultChecker.js', () => ({ + defaultChecker: { reverseDefault: mockReverseDefault }, + DefaultChecker: class {}, + startDefaultCheckerScheduler: jest.fn(), + stopDefaultCheckerScheduler: jest.fn(), +})); + let request: typeof import('supertest'); let jwt: typeof import('jsonwebtoken'); let app: any; @@ -106,6 +117,10 @@ afterAll(() => { jest.restoreAllMocks(); }); +beforeEach(() => { + mockReverseDefault.mockReset(); +}); + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('Loan Dispute/Appeal Mechanism', () => { it('should reject contest if loan is not defaulted', async () => { @@ -246,10 +261,18 @@ describe('Loan Dispute/Appeal Mechanism', () => { it('should allow admin to resolve dispute as reverse', async () => { /** * resolveLoanDispute: + * [0] defaultChecker.reverseDefault(loanId) → on-chain tx (mocked) * [1] SELECT loan_disputes WHERE id = disputeId AND status='open' → found * [2] UPDATE loan_disputes SET status='resolved' * [3] INSERT contract_events DefaultReversed (action = 'reverse') */ + mockReverseDefault.mockResolvedValueOnce({ + loanId: LOAN_ID, + txHash: 'c0ffee1234', + ledger: 4321, + txStatus: 'SUCCESS', + }); + mockQuery .mockResolvedValueOnce( dbRows([ @@ -271,5 +294,57 @@ describe('Loan Dispute/Appeal Mechanism', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(mockReverseDefault).toHaveBeenCalledWith(LOAN_ID); + + // The recorded event must carry the real on-chain tx hash, not the + // synthetic `admin-dispute:` marker this used to write (#1803). + const insertCall = mockQuery.mock.calls.find( + (call) => typeof call[0] === 'string' && (call[0] as string).includes('DefaultReversed'), + ); + expect(insertCall).toBeDefined(); + const insertParams = insertCall![1] as unknown[]; + expect(insertParams).toContain('c0ffee1234'); + expect(insertParams.some((p) => typeof p === 'string' && p.startsWith('admin-dispute:'))).toBe( + false, + ); + }); + + it('should not resolve a dispute when the on-chain reversal fails', async () => { + /** + * resolveLoanDispute with action='reverse', on-chain call fails: + * [1] SELECT loan_disputes WHERE id = disputeId AND status='open' → found + * → throws 503 before any UPDATE or contract_events INSERT, so the dispute + * stays open instead of being recorded as resolved. + */ + mockReverseDefault.mockResolvedValueOnce({ + loanId: LOAN_ID, + error: 'prepareTransaction failed: pool liquidity unavailable', + }); + + // Scope the call count to this test: earlier tests in the file share the + // same `mockQuery` instance. + mockQuery.mockClear(); + mockQuery.mockResolvedValueOnce( + dbRows([ + { + id: disputeId, + loan_id: LOAN_ID, + borrower: TEST_PUBLIC_KEY, + status: 'open', + }, + ]), + ); + + const res = await request(app) + .post(`/api/admin/loan-disputes/${disputeId}/resolve`) + .set('x-api-key', ADMIN_API_KEY) + .send({ action: 'reverse', resolution: 'Default was incorrect.' }); + + expect(res.status).toBe(503); + // Only the dispute lookup ran — nothing was marked resolved or logged. + expect(mockQuery).toHaveBeenCalledTimes(1); + const statements = mockQuery.mock.calls.map((call) => String(call[0])); + expect(statements.some((sql) => sql.includes('UPDATE loan_disputes'))).toBe(false); + expect(statements.some((sql) => sql.includes('DefaultReversed'))).toBe(false); }); }); diff --git a/backend/src/controllers/adminDisputeController.ts b/backend/src/controllers/adminDisputeController.ts index 1ffece33e..462817e61 100644 --- a/backend/src/controllers/adminDisputeController.ts +++ b/backend/src/controllers/adminDisputeController.ts @@ -3,7 +3,9 @@ import { query } from '../db/connection.js'; import { AppError } from '../errors/AppError.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import { notificationService, type NotificationType } from '../services/notificationService.js'; +import { defaultChecker } from '../services/defaultChecker.js'; import { encodeCursor, decodeCursor, parseKeysetParams } from '../utils/pagination.js'; +import logger from '../utils/logger.js'; /** * List all loan disputes for admin review with cursor-based pagination. @@ -170,28 +172,65 @@ export const resolveLoanDispute = asyncHandler(async (req, res) => { } const dispute = disputeResult.rows[0]; + // event_id, ledger, tx_hash, and contract_id are all NOT NULL on + // contract_events with no default — omitting or nulling them violates those + // constraints and crashes the insert. + const contractId = process.env.LOAN_MANAGER_CONTRACT_ID || 'admin-action'; + + // The on-chain leg runs FIRST, before anything is written (#1803). + // + // `reverse_default` is what actually moves the ledger: it puts the loan back + // to Approved, refunds the collateral seized into the pool, and repairs the + // borrower's NFT credit record. Recording the resolution before submitting + // (as this used to) left a disputed default as a database-only fiction when + // the call failed — the admin row said 'resolved' while the ledger kept the + // loan defaulted and the score still slashed. + let reversal: { txHash?: string; ledger?: number; error?: string } | null = null; + if (action === 'reverse') { + reversal = await defaultChecker.reverseDefault(Number(dispute.loan_id)); + if (reversal.error || !reversal.txHash) { + // Nothing was written: the dispute stays open and can be retried once the + // on-chain failure is fixed (pool liquidity, indexer lag, RPC). + logger.withContext().error('On-chain default reversal failed', { + disputeId, + loanId: dispute.loan_id, + error: reversal.error ?? 'no transaction hash returned', + }); + throw AppError.serviceUnavailable( + `Could not reverse the default on-chain for loan ${dispute.loan_id}: ${ + reversal.error ?? 'no transaction hash returned' + }`, + ); + } + } + // Mark dispute as resolved with admin note await query( `UPDATE loan_disputes SET status = 'resolved', resolution = $1, admin_note = $2, resolved_at = NOW() WHERE id = $3`, [resolution, adminNote || null, disputeId], ); - // These are synthetic, admin-generated events (no real on-chain ledger/tx - // behind them), but event_id, ledger, tx_hash, and contract_id are all - // NOT NULL on contract_events with no default — omitting or nulling them - // violates those constraints and crashes the insert. - const contractId = process.env.LOAN_MANAGER_CONTRACT_ID || 'admin-action'; if (action === 'confirm') { - // Leave loan as defaulted, optionally log event + // Leave loan as defaulted, optionally log event. This one is a synthetic + // admin-generated marker: confirming a default has no on-chain effect, so + // there is no real tx behind it. await query( `INSERT INTO contract_events (event_id, loan_id, address, event_type, amount, ledger, ledger_closed_at, tx_hash, contract_id) VALUES ($1, $2, $3, 'DefaultConfirmed', NULL, 0, NOW(), $4, $5)`, [randomUUID(), dispute.loan_id, dispute.borrower, `admin-dispute:${disputeId}`, contractId], ); - } else if (action === 'reverse') { - // Insert event to mark loan as active again + } else if (action === 'reverse' && reversal?.txHash) { + // A real `reverse_default` transaction landed, so the row carries its real + // tx hash and ledger instead of a synthetic `admin-dispute:` marker. await query( - `INSERT INTO contract_events (event_id, loan_id, address, event_type, amount, ledger, ledger_closed_at, tx_hash, contract_id) VALUES ($1, $2, $3, 'DefaultReversed', NULL, 0, NOW(), $4, $5)`, - [randomUUID(), dispute.loan_id, dispute.borrower, `admin-dispute:${disputeId}`, contractId], + `INSERT INTO contract_events (event_id, loan_id, address, event_type, amount, ledger, ledger_closed_at, tx_hash, contract_id) VALUES ($1, $2, $3, 'DefaultReversed', NULL, $4, NOW(), $5, $6)`, + [ + randomUUID(), + dispute.loan_id, + dispute.borrower, + reversal.ledger ?? 0, + reversal.txHash, + contractId, + ], ); } diff --git a/backend/src/services/__tests__/defaultChecker.test.ts b/backend/src/services/__tests__/defaultChecker.test.ts index 707f50221..82329553e 100644 --- a/backend/src/services/__tests__/defaultChecker.test.ts +++ b/backend/src/services/__tests__/defaultChecker.test.ts @@ -1,5 +1,5 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { Account, Keypair, StrKey } from '@stellar/stellar-sdk'; +import { Account, Keypair, Operation, StrKey, Transaction } from '@stellar/stellar-sdk'; type MockQueryResult = { rows: unknown[]; rowCount?: number }; @@ -350,4 +350,73 @@ describe('DefaultChecker', () => { // Without the fix it would be called 3 times (once per batch) }); }); + + describe('reverseDefault (#1803)', () => { + beforeEach(() => { + fakeServer.sendTransaction.mockResolvedValue({ hash: 'revhash', status: 'PENDING' }); + fakeServer.pollTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 4321 }); + }); + + it('submits reverse_default(loan_id) to the loan manager and returns the confirmed hash', async () => { + const preparedTxns: unknown[] = []; + fakeServer.prepareTransaction.mockImplementation(async (tx: unknown) => { + preparedTxns.push(tx); + return tx; + }); + + const checker = new DefaultChecker(); + const result = await checker.reverseDefault(42); + + expect(result.error).toBeUndefined(); + expect(result.txHash).toBe('revhash'); + expect(result.txStatus).toBe('SUCCESS'); + expect(result.ledger).toBe(4321); + expect(fakeServer.getAccount).toHaveBeenCalledTimes(1); + + // The submitted transaction really is a `reverse_default` call for the + // loan under dispute — not a `check_defaults` batch. + const tx = preparedTxns[0] as Transaction; + const op = tx.operations[0] as Operation.InvokeHostFunction; + const invoke = op.func.invokeContract(); + expect(invoke.functionName().toString()).toBe('reverse_default'); + expect(invoke.args()[0]!.u32()).toBe(42); + }); + + it('reports an error and no usable hash when the transaction fails on-chain', async () => { + fakeServer.prepareTransaction.mockImplementation(async (tx: unknown) => tx); + fakeServer.pollTransaction.mockResolvedValue({ status: 'FAILED' }); + + const checker = new DefaultChecker(); + const result = await checker.reverseDefault(42); + + expect(result.txHash).toBe('revhash'); + expect(result.txStatus).toBe('FAILED'); + expect(result.error).toContain('did not succeed on-chain'); + // A FAILED tx must never be reported as a completed reversal, which is + // what the controller keys off when deciding whether to record it. + expect(result.error).toBeTruthy(); + }); + + it('reports an error when the transaction cannot be confirmed', async () => { + fakeServer.prepareTransaction.mockImplementation(async (tx: unknown) => tx); + fakeServer.pollTransaction.mockRejectedValue(new Error('rpc timeout')); + + const checker = new DefaultChecker(); + const result = await checker.reverseDefault(42); + + expect(result.error).toContain('could not confirm reverse_default on-chain'); + expect(result.error).toContain('revhash'); + }); + + it('reports prepareTransaction failures without throwing', async () => { + fakeServer.prepareTransaction.mockRejectedValue(new Error('bad sequence')); + + const checker = new DefaultChecker(); + const result = await checker.reverseDefault(42); + + expect(result.txHash).toBeUndefined(); + expect(result.error).toContain('prepareTransaction failed: bad sequence'); + expect(fakeServer.sendTransaction).not.toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/services/defaultChecker.ts b/backend/src/services/defaultChecker.ts index 966506fd5..2562a1b8d 100644 --- a/backend/src/services/defaultChecker.ts +++ b/backend/src/services/defaultChecker.ts @@ -672,6 +672,126 @@ export class DefaultChecker { await this.releaseLock(); } } + + /** + * Submits `reverse_default(loan_id)` to the LoanManager contract (#1803). + * + * The on-chain counterpart of `check_defaults`, signed with the same + * `LOAN_MANAGER_ADMIN_SECRET` key the checker already uses, and the only way + * a disputed default stops being a database-only fiction: + * `adminDisputeController.resolveLoanDispute('reverse')` calls this and + * refuses to record the resolution unless it comes back with a tx hash. + * + * Unlike `checkOverdueLoans` this takes no distributed lock and is not + * batched — it runs inside an admin HTTP request, for a single loan, and the + * caller reports the outcome to an operator who must see the failure rather + * than a best-effort batch summary. + * + * A returned `error` means nothing reached the ledger. The caller must not + * treat the reversal as done in that case; the dispute stays open in the + * database so it can be retried. + */ + async reverseDefault(loanId: number): Promise<{ + loanId: number; + txHash?: string; + ledger?: number; + submitStatus?: string; + txStatus?: string; + error?: string; + }> { + const { signer, server, passphrase } = this.assertConfigured(); + + const tx = new TransactionBuilder(await server.getAccount(signer.publicKey()), { + fee: BASE_FEE, + networkPassphrase: passphrase, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: this.contractId, + function: 'reverse_default', + args: [nativeToScVal(loanId, { type: 'u32' })], + }), + ) + .setTimeout(30) + .build(); + + let prepared; + try { + prepared = await server.prepareTransaction(tx); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { loanId, error: `prepareTransaction failed: ${message}` }; + } + + prepared.sign(signer); + + let send; + try { + send = await server.sendTransaction(prepared); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { loanId, error: `sendTransaction failed: ${message}` }; + } + + const txHash = send.hash; + if (!txHash) { + return { + loanId, + ...(send.status !== undefined ? { submitStatus: send.status } : {}), + error: 'sendTransaction returned no hash', + }; + } + + let txStatus: string | undefined; + let ledger: number | undefined; + try { + const polled = await server.pollTransaction(txHash, { + attempts: this.pollAttempts, + sleepStrategy: (_attempt: number) => this.pollSleepMs, + }); + txStatus = polled.status; + const polledLedger = (polled as { ledger?: unknown }).ledger; + if (typeof polledLedger === 'number') { + ledger = polledLedger; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.withContext().warn('Default reversal transaction polling failed', { + loanId, + txHash, + message, + }); + // Submitted but unconfirmed: the reversal may or may not have landed, so + // it is reported as an error with the hash for the operator to check. The + // database must never claim a reversal the ledger has not confirmed. + return { + loanId, + txHash, + error: `could not confirm reverse_default on-chain (tx ${txHash}): ${message}`, + }; + } + + // A submitted-but-not-confirmed transaction is not a reversal: the ledger + // may still reject it (e.g. LoanNotDefaulted, InsufficientPoolLiquidity). + // Only SUCCESS is reported as a usable hash so the caller never records a + // resolution that did not land. + if (txStatus !== 'SUCCESS') { + return { + loanId, + txHash, + ...(txStatus !== undefined ? { txStatus } : {}), + error: `reverse_default did not succeed on-chain (status: ${txStatus ?? 'unknown'})`, + }; + } + + return { + loanId, + txHash, + ...(ledger !== undefined ? { ledger } : {}), + ...(send.status !== undefined ? { submitStatus: send.status } : {}), + txStatus, + }; + } } export const defaultChecker = new DefaultChecker(); diff --git a/contracts/loan_manager/src/events.rs b/contracts/loan_manager/src/events.rs index b01b486a9..dac20c361 100644 --- a/contracts/loan_manager/src/events.rs +++ b/contracts/loan_manager/src/events.rs @@ -21,6 +21,7 @@ //! | `InterestRateUpdated` | `("InterestRateUpdated",)` | `(old_rate: u32, new_rate: u32)` | //! | `DefaultTermUpdated` | `("DefaultTermUpdated",)` | `(old_term: u32, new_term: u32)` | //! | `LoanDefaulted` | `("LoanDefaulted", loan_id: u32)` | `borrower: Address` | +//! | `LoanDefaultReversed` | `("LoanDefaultReversed", loan_id: u32)` | `borrower: Address` | //! | `TermLimitsUpdated` | `("TermLimitsUpdated",)` | `(min_term: u32, max_term: u32)` | //! | `RateOracleUpdated` | `("RateOracleUpdated",)` | `(old_oracle: Option
, new_oracle: Address)` | //! | `CollateralReturned` | `("CollateralReturned", borrower: Address, loan_id: u32)` | `amount: i128` | @@ -204,6 +205,16 @@ pub fn loan_defaulted(env: &Env, loan_id: u32, borrower: Address) { env.events().publish(topics, borrower); } +/// Emits event when an administratively disputed default is reversed and the +/// loan is reinstated (#1803). +/// +/// - **Topics**: `("LoanDefaultReversed", loan_id: u32)` +/// - **Data**: `borrower: Address` +pub fn loan_default_reversed(env: &Env, loan_id: u32, borrower: Address) { + let topics = (Symbol::new(env, "LoanDefaultReversed"), loan_id); + env.events().publish(topics, borrower); +} + /// Emits event when allowed loan term limits are updated. /// /// - **Topics**: `("TermLimitsUpdated",)` diff --git a/contracts/loan_manager/src/lib.rs b/contracts/loan_manager/src/lib.rs index 36d9b56cd..969a0fac9 100644 --- a/contracts/loan_manager/src/lib.rs +++ b/contracts/loan_manager/src/lib.rs @@ -13,6 +13,7 @@ pub trait RemittanceNftInterface { fn seize_collateral(env: Env, user: Address, minter: Option
); fn is_seized(env: Env, user: Address) -> bool; fn record_default(env: Env, user: Address, minter: Option
); + fn clear_default(env: Env, user: Address, minter: Option
); fn is_authorized_minter(env: Env, minter: Address) -> bool; fn is_paused(env: Env) -> bool; } @@ -64,6 +65,9 @@ pub enum LoanError { InsufficientCollateral = 26, LoanNotLiquidatable = 27, LoanNotPurgable = 28, + /// Raised by `reverse_default` when the loan is not in `Defaulted` state + /// (only a default can be reversed — #1803). + LoanNotDefaulted = 29, } #[contracttype] @@ -135,6 +139,11 @@ pub enum DataKey { MinRateBps, MaxRateBps, MigratedVersion, + /// Amount of collateral moved to the lending pool when a loan was marked + /// defaulted. `Loan::collateral_amount` is zeroed by the seize, so without + /// this the amount would be unrecoverable and an administrative reversal + /// could not refund it (#1803). + SeizedCollateral(u32), } #[contract] @@ -896,6 +905,13 @@ impl LoanManager { env.storage().persistent().set(&loan_key, &loan); Self::bump_persistent_ttl(env, &loan_key); + // Remember what was seized before zeroing it off the loan record: + // `reverse_default` (#1803) needs the amount to refund the borrower + // when a default is administratively overturned. + let seized_key = DataKey::SeizedCollateral(loan_id); + env.storage().persistent().set(&seized_key, &collateral); + Self::bump_persistent_ttl(env, &seized_key); + let token: Address = env .storage() .instance() @@ -2848,6 +2864,138 @@ impl LoanManager { Ok(()) } + /// Reverse a default that an administrative dispute process has overturned + /// (#1803). + /// + /// Admin-only, and the counterpart of [`Self::check_default`]: it is the + /// only path back out of [`LoanStatus::Defaulted`]. Without it + /// `adminDisputeController.resolveLoanDispute('reverse')` was a + /// database-only fiction — the ledger kept the loan, the seized collateral + /// and the borrower's credit record defaulted for good. + /// + /// What it restores, in one atomic call: + /// + /// - `status` returns to [`LoanStatus::Approved`] and the outstanding + /// principal that `check_default` removed is added back. + /// - The borrower's active-loan slot, freed by `check_default`, is reused. + /// - `due_date` is pushed out by the loan's own term (falling back to the + /// configured default term) and the interest/late-fee clocks restart, so + /// the reinstated loan is neither instantly re-defaultable nor charged + /// interest for the window in which it was wrongly defaulted. Debt + /// accrued up to the default is untouched — this reverses the default, not + /// the debt. + /// - The collateral seized into the lending pool is transferred back to the + /// borrower, reinstated on the loan, and the `SeizedCollateral` record is + /// cleared. + /// - The NFT credit record is repaired: the default count is decremented, + /// the seized flag cleared, and the score penalty refunded. + /// + /// Two deliberate asymmetries with `check_default`: + /// + /// - Pausing does **not** block a reversal. An emergency pause freezes new + /// activity; it must not freeze remediation of a wrongful default. + /// - [`LoanStatus::Liquidated`] loans are rejected. Their collateral has + /// already been auctioned to a third party and cannot be unwound. + /// + /// Returns [`LoanError::LoanNotFound`] for an unknown loan, + /// [`LoanError::LoanNotDefaulted`] when the loan is not `Defaulted`, and + /// [`LoanError::InsufficientPoolLiquidity`] when the pool no longer holds + /// enough of the token to refund the seized collateral. Nothing is mutated + /// in any of those cases, so a failed reversal leaves the default exactly as + /// it was and can be retried once the pool is funded. + pub fn reverse_default(env: Env, loan_id: u32) -> Result<(), LoanError> { + use soroban_sdk::token::TokenClient; + + Self::admin(&env).require_auth(); + + let loan_key = DataKey::Loan(loan_id); + let mut loan: Loan = env + .storage() + .persistent() + .get(&loan_key) + .ok_or(LoanError::LoanNotFound)?; + Self::bump_persistent_ttl(&env, &loan_key); + + if loan.status != LoanStatus::Defaulted { + return Err(LoanError::LoanNotDefaulted); + } + + let token: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .expect("token not set"); + let lending_pool: Address = env + .storage() + .instance() + .get(&DataKey::LendingPool) + .expect("lending pool not set"); + + let seized_key = DataKey::SeizedCollateral(loan_id); + let seized: i128 = env + .storage() + .persistent() + .get(&seized_key) + .unwrap_or(0); + + // ── CHECKS ────────────────────────────────────────────────────────── + // Fail before mutating anything if the refund cannot be covered: a + // half-applied reversal would be worse than the default it corrects. + if seized > 0 { + let pool_client = PoolClient::new(&env, &lending_pool); + if pool_client.pool_balance(&token) < seized { + return Err(LoanError::InsufficientPoolLiquidity); + } + } + + // ── EFFECTS (all state mutations before any external call) ────────── + let borrower = loan.borrower.clone(); + let restored_principal = Self::remaining_principal(&loan); + let term = if loan.term_ledgers > 0 { + loan.term_ledgers + } else { + Self::read_default_term(&env) + }; + let reinstated_at = env.ledger().sequence(); + let due_date = reinstated_at + .checked_add(term) + .expect("reinstated due date overflow"); + + loan.status = LoanStatus::Approved; + loan.due_date = due_date; + loan.last_interest_ledger = reinstated_at; + loan.last_late_fee_ledger = due_date + .checked_add(Self::grace_period_ledgers(&env)) + .expect("grace period overflow"); + loan.collateral_amount = seized; + + Self::adjust_total_outstanding(&env, &token, restored_principal); + Self::increment_borrower_loan_count(&env, &borrower); + + env.storage().persistent().set(&loan_key, &loan); + Self::bump_persistent_ttl(&env, &loan_key); + + // ── INTERACTIONS (external calls last) ────────────────────────────── + if seized > 0 { + let token_client = TokenClient::new(&env, &token); + token_client.transfer(&lending_pool, &borrower, &seized); + + env.storage().persistent().remove(&seized_key); + events::collateral_returned(&env, borrower.clone(), loan_id, seized); + } + + let nft_client = NftClient::new(&env, &Self::nft_contract(&env)); + nft_client.apply_score_delta( + &borrower, + &(Self::DEFAULT_SCORE_PENALTY_POINTS as i32), + &Some(env.current_contract_address()), + ); + nft_client.clear_default(&borrower, &Some(env.current_contract_address())); + + events::loan_default_reversed(&env, loan_id, borrower); + Ok(()) + } + /// Extend a loan's due date by the specified number of ledgers. /// Only callable by the borrower while the loan is in Approved status. /// Rejects extension if loan is defaulted, repaid, or has exceeded max extensions. diff --git a/contracts/loan_manager/src/test.rs b/contracts/loan_manager/src/test.rs index b71bf4941..f64fc701e 100644 --- a/contracts/loan_manager/src/test.rs +++ b/contracts/loan_manager/src/test.rs @@ -5850,6 +5850,272 @@ fn test_uncollateralized_loan_follows_default_path_after_default_window() { assert!(nft_client.is_seized(&borrower)); } +// ── reverse_default (#1803 — administrative reversal of a default) ─────────── + +#[test] +fn test_reverse_default_restores_loan_state_score_and_collateral() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, nft_client, pool_address, token_id, _admin) = setup_test(&env); + let borrower = Address::generate(&env); + + let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]); + nft_client.mint( + &borrower, + &600, + &history_hash, + &String::from_str(&env, "ipfs://QmTest"), + &create_test_commitment(&env, 1), + &None, + ); + + let token_client = TokenClient::new(&env, &token_id); + let stellar_token = StellarAssetClient::new(&env, &token_id); + stellar_token.mint(&pool_address, &10_400); + stellar_token.mint(&borrower, &1_000); + + let loan_id = manager.request_loan(&borrower, &1_000, &17_280); + manager.approve_loan(&loan_id); + manager.deposit_collateral(&loan_id, &400); + + let borrower_balance_before_default = token_client.balance(&borrower); + let pool_balance_before_default = token_client.balance(&pool_address); + assert_eq!(manager.get_collateral(&loan_id), 400); + + let due_date = manager.get_loan(&loan_id).due_date; + let default_window = manager.get_default_window_ledgers(); + env.ledger() + .set_sequence_number(due_date + default_window + 1); + + manager.check_default(&loan_id); + assert_eq!(manager.get_loan(&loan_id).status, LoanStatus::Defaulted); + assert_eq!(nft_client.get_score(&borrower), 550); + assert_eq!(nft_client.get_default_count(&borrower), 1); + assert!(nft_client.is_seized(&borrower)); + // The seize moved the collateral out of the loan and into the pool. + assert_eq!(manager.get_collateral(&loan_id), 0); + assert_eq!( + token_client.balance(&pool_address), + pool_balance_before_default + 400 + ); + assert_eq!(manager.get_total_outstanding(&token_id), 0); + + manager.reverse_default(&loan_id); + + // Loan is live again, with its collateral claim and accounting restored. + let reinstated = manager.get_loan(&loan_id); + assert_eq!(reinstated.status, LoanStatus::Approved); + assert_eq!(reinstated.collateral_amount, 400); + assert_eq!(manager.get_total_outstanding(&token_id), 1_000); + assert_eq!(manager.get_borrower_loan_count(&borrower), 1); + // The reinstated loan must not be instantly re-defaultable: its due date is + // pushed one full term past the reversal. + assert_eq!(reinstated.due_date, env.ledger().sequence() + 17_280); + + // Collateral came back out of the pool to the borrower. + assert_eq!(token_client.balance(&borrower), borrower_balance_before_default + 400); + assert_eq!(token_client.balance(&pool_address), pool_balance_before_default); + + // Credit record repaired: penalty refunded, default count cleared, seize lifted. + assert_eq!(nft_client.get_score(&borrower), 600); + assert_eq!(nft_client.get_default_count(&borrower), 0); + assert!(!nft_client.is_seized(&borrower)); +} + +#[test] +fn test_reverse_default_rejects_loan_that_is_not_defaulted() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, nft_client, pool_address, token_id, _admin) = setup_test(&env); + let borrower = Address::generate(&env); + + let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]); + nft_client.mint( + &borrower, + &600, + &history_hash, + &String::from_str(&env, "ipfs://QmTest"), + &create_test_commitment(&env, 1), + &None, + ); + + let stellar_token = StellarAssetClient::new(&env, &token_id); + stellar_token.mint(&pool_address, &10_000); + + let loan_id = manager.request_loan(&borrower, &1_000, &17_280); + manager.approve_loan(&loan_id); + + // Only a default can be reversed — an active loan is rejected outright. + assert_eq!( + manager.try_reverse_default(&loan_id), + Err(Ok(LoanError::LoanNotDefaulted)) + ); + assert_eq!(manager.get_loan(&loan_id).status, LoanStatus::Approved); + + // ... and so is a rejected (terminal) loan. + let rejected_loan_id = manager.request_loan(&borrower, &500, &17_280); + manager.reject_loan(&rejected_loan_id, &String::from_str(&env, "no collateral")); + assert_eq!( + manager.get_loan(&rejected_loan_id).status, + LoanStatus::Rejected + ); + assert_eq!( + manager.try_reverse_default(&rejected_loan_id), + Err(Ok(LoanError::LoanNotDefaulted)) + ); +} + +#[test] +fn test_reverse_default_without_collateral_reinstates_the_loan() { + // Uncapped-collateral loan: nothing was seized, so the reversal must still + // repair state and credit record without touching token balances. + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, nft_client, pool_address, token_id, _admin) = setup_test(&env); + let borrower = Address::generate(&env); + + let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]); + nft_client.mint( + &borrower, + &600, + &history_hash, + &String::from_str(&env, "ipfs://QmTest"), + &create_test_commitment(&env, 1), + &None, + ); + + let token_client = TokenClient::new(&env, &token_id); + let stellar_token = StellarAssetClient::new(&env, &token_id); + stellar_token.mint(&pool_address, &10_000); + + let loan_id = manager.request_loan(&borrower, &1_000, &17_280); + manager.approve_loan(&loan_id); + + let pool_balance_before_default = token_client.balance(&pool_address); + + let due_date = manager.get_loan(&loan_id).due_date; + let default_window = manager.get_default_window_ledgers(); + env.ledger() + .set_sequence_number(due_date + default_window + 1); + manager.check_default(&loan_id); + + manager.reverse_default(&loan_id); + + let reinstated = manager.get_loan(&loan_id); + assert_eq!(reinstated.status, LoanStatus::Approved); + assert_eq!(reinstated.collateral_amount, 0); + assert_eq!(token_client.balance(&pool_address), pool_balance_before_default); + assert_eq!(nft_client.get_score(&borrower), 600); + assert_eq!(nft_client.get_default_count(&borrower), 0); + assert!(!nft_client.is_seized(&borrower)); +} + +#[test] +fn test_reverse_default_fails_when_pool_cannot_refund_the_seized_collateral() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, nft_client, pool_address, token_id, _admin) = setup_test(&env); + let borrower = Address::generate(&env); + let other_borrower = Address::generate(&env); + + let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]); + for b in [&borrower, &other_borrower] { + nft_client.mint( + b, + &600, + &history_hash, + &String::from_str(&env, "ipfs://QmTest"), + &create_test_commitment(&env, 1), + &None, + ); + } + + let token_client = TokenClient::new(&env, &token_id); + let stellar_token = StellarAssetClient::new(&env, &token_id); + stellar_token.mint(&pool_address, &10_400); + stellar_token.mint(&borrower, &1_000); + + let loan_id = manager.request_loan(&borrower, &1_000, &17_280); + manager.approve_loan(&loan_id); + manager.deposit_collateral(&loan_id, &400); + + let due_date = manager.get_loan(&loan_id).due_date; + let default_window = manager.get_default_window_ledgers(); + env.ledger() + .set_sequence_number(due_date + default_window + 1); + manager.check_default(&loan_id); + + // Drain the pool below the 400 the reversal has to refund. + let big_loan_id = manager.request_loan(&other_borrower, &9_750, &17_280); + manager.approve_loan(&big_loan_id); + assert!(token_client.balance(&pool_address) < 400); + + assert_eq!( + manager.try_reverse_default(&loan_id), + Err(Ok(LoanError::InsufficientPoolLiquidity)) + ); + + // Nothing moved: the default is left exactly as it was, so the reversal can + // be retried once the pool is funded again. + let still_defaulted = manager.get_loan(&loan_id); + assert_eq!(still_defaulted.status, LoanStatus::Defaulted); + assert_eq!(still_defaulted.collateral_amount, 0); + assert_eq!(nft_client.get_score(&borrower), 550); + assert_eq!(nft_client.get_default_count(&borrower), 1); + assert!(nft_client.is_seized(&borrower)); + + // Fund the pool and the same call now succeeds. + stellar_token.mint(&pool_address, &400); + manager.reverse_default(&loan_id); + assert_eq!(manager.get_loan(&loan_id).status, LoanStatus::Approved); + assert_eq!(nft_client.get_default_count(&borrower), 0); +} + +#[test] +fn test_reverse_default_is_allowed_while_paused() { + // A pause freezes new activity; it must not freeze remediation of a + // wrongful default (documented asymmetry with check_default). + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let (manager, nft_client, pool_address, token_id, _admin) = setup_test(&env); + let borrower = Address::generate(&env); + + let history_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]); + nft_client.mint( + &borrower, + &600, + &history_hash, + &String::from_str(&env, "ipfs://QmTest"), + &create_test_commitment(&env, 1), + &None, + ); + + let stellar_token = StellarAssetClient::new(&env, &token_id); + stellar_token.mint(&pool_address, &10_000); + + let loan_id = manager.request_loan(&borrower, &1_000, &17_280); + manager.approve_loan(&loan_id); + + let due_date = manager.get_loan(&loan_id).due_date; + let default_window = manager.get_default_window_ledgers(); + env.ledger() + .set_sequence_number(due_date + default_window + 1); + manager.check_default(&loan_id); + + manager.pause(); + assert!(manager.is_paused()); + + manager.reverse_default(&loan_id); + + assert_eq!(manager.get_loan(&loan_id).status, LoanStatus::Approved); + assert_eq!(nft_client.get_default_count(&borrower), 0); +} + // ─── Tests for Issue #1875: LoanManager::get_loan_status discriminant mapping ─── /// Asserts get_loan_status returns 0/1/2/3/4/5/6 for Pending/Approved/Repaid/Defaulted/Liquidated/Cancelled/Rejected @@ -6067,5 +6333,4 @@ fn test_extend_loan_rejects_seized_borrower() { // Extending loan 2 for the seized borrower must fail with SeizedBorrower let res = manager.try_extend_loan(&borrower, &loan_2_id, &100); assert_eq!(res, Err(Ok(LoanError::SeizedBorrower))); -} - +} \ No newline at end of file diff --git a/contracts/remittance_nft/src/lib.rs b/contracts/remittance_nft/src/lib.rs index 5e1b1df0e..734246f9b 100644 --- a/contracts/remittance_nft/src/lib.rs +++ b/contracts/remittance_nft/src/lib.rs @@ -1024,6 +1024,58 @@ impl RemittanceNFT { Ok(()) } + /// Undo the record of a default that an administrative dispute process has + /// overturned (#1803). + /// + /// Admin or authorized-minter only; the counterpart of + /// [`Self::record_default`]. Decrements the user's default count + /// (saturating at zero) and lifts the seized flag so the borrower can + /// transfer and refinance again. The score points lost to the default + /// penalty are restored separately by the caller + /// (`LoanManager::reverse_default` -> `apply_score_delta`), because the + /// penalty size is loan-manager policy, not NFT policy. + /// + /// A burned account is not resurrected: once the default count reached the + /// burn threshold the record is gone, and returning `Ok(())` without + /// touching storage is the only honest outcome for a reversal that arrives + /// too late. Nothing here can be used to mint or restore a burned NFT. + pub fn clear_default(env: Env, user: Address, minter: Option
) -> Result<(), NftError> { + Self::require_admin_or_authorized_minter(&env, minter)?; + + if !Self::has_active_nft(&env, &user) { + return Ok(()); + } + + let default_key = DataKey::DefaultCount(user.clone()); + let recorded = env + .storage() + .persistent() + .get::(&default_key) + .unwrap_or(0); + let remaining = recorded.saturating_sub(1); + + if remaining == 0 { + // Matches `burn_internal`, which drops the key rather than storing + // a zero; `get_default_count` reports 0 for a missing key. + env.storage().persistent().remove(&default_key); + } else { + env.storage().persistent().set(&default_key, &remaining); + Self::bump_persistent_ttl(&env, &default_key); + } + + let seized_key = DataKey::Seized(user.clone()); + if env.storage().persistent().has(&seized_key) { + env.storage().persistent().remove(&seized_key); + env.events() + .publish((symbol_short!("Unseized"), user.clone()), ()); + } + + env.events() + .publish((symbol_short!("DefClr"), user), remaining); + + Ok(()) + } + pub fn burn(env: Env, user: Address, minter: Option
) -> Result<(), NftError> { Self::assert_not_paused(&env)?; Self::require_admin_or_authorized_minter(&env, minter)?;