Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion backend/src/__tests__/integration/loanDisputeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@
},
}));

// 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<unknown>>();
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;

Check warning on line 44 in backend/src/__tests__/integration/loanDisputeFlow.test.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

const TEST_PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
const ADMIN_API_KEY = 'test-api-key';
Expand Down Expand Up @@ -87,6 +97,7 @@
mockQuery.mockReset();
mockNotifyAdmins.mockReset();
mockCreateNotification.mockReset();
mockReverseDefault.mockReset();
mockNotifyAdmins.mockResolvedValue(undefined);
mockCreateNotification.mockResolvedValue({
id: 1,
Expand Down Expand Up @@ -206,6 +217,14 @@
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([
Expand All @@ -231,6 +250,7 @@

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,
Expand Down Expand Up @@ -259,7 +279,8 @@
expect.any(String),
LOAN_ID,
TEST_PUBLIC_KEY,
`admin-dispute:${DISPUTE_ID}`,
777,
'reverse-tx-hash',
expect.any(String),
],
],
Expand Down
75 changes: 75 additions & 0 deletions backend/src/__tests__/loanDispute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@
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<unknown>>();
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;

Check warning on line 34 in backend/src/__tests__/loanDispute.test.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
// Dynamic imports after mocks
beforeAll(async () => {
({ default: request } = await import('supertest'));
Expand Down Expand Up @@ -106,6 +117,10 @@
jest.restoreAllMocks();
});

beforeEach(() => {
mockReverseDefault.mockReset();
});

// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Loan Dispute/Appeal Mechanism', () => {
it('should reject contest if loan is not defaulted', async () => {
Expand Down Expand Up @@ -246,10 +261,18 @@
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([
Expand All @@ -271,5 +294,57 @@

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:<id>` 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);
});
});
59 changes: 49 additions & 10 deletions backend/src/controllers/adminDisputeController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<id>` 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,
],
);
}

Expand Down
71 changes: 70 additions & 1 deletion backend/src/services/__tests__/defaultChecker.test.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading