Skip to content
Closed
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
19 changes: 17 additions & 2 deletions backend/src/__tests__/integration/loanDisputeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@
const mockNotifyAdmins = jest.fn<(...args: unknown[]) => Promise<unknown>>();
const mockCreateNotification = jest.fn<(...args: unknown[]) => Promise<unknown>>();

// Run the transaction callback on the shared mock query, matching the shape a
// real `withTransaction` gives its callback.
const mockWithTransaction = jest.fn(
async (fn: (client: { query: typeof mockQuery }) => Promise<unknown>) => fn({ query: mockQuery }),
);
jest.unstable_mockModule('../../db/connection.ts', () => ({
query: mockQuery,
default: { query: mockQuery, connect: jest.fn(), end: jest.fn() },
withTransaction: jest.fn(),
withTransaction: mockWithTransaction,
}));
jest.unstable_mockModule('../../db/connection.js', () => ({
query: mockQuery,
default: { query: mockQuery, connect: jest.fn(), end: jest.fn() },
withTransaction: jest.fn(),
withTransaction: mockWithTransaction,
}));
jest.unstable_mockModule('../../db/transaction.js', () => ({
withTransaction: jest.fn(),
Expand All @@ -41,7 +46,7 @@

let request: typeof import('supertest');
let jwt: typeof import('jsonwebtoken');
let app: any;

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

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

Check warning on line 49 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 @@ -176,6 +181,16 @@
expect.stringContaining('INSERT INTO loan_disputes'),
[String(LOAN_ID), TEST_PUBLIC_KEY, 'Indexer lag caused an incorrect default event.'],
],
[
expect.stringContaining('LoanDisputed'),
[
expect.any(String),
String(LOAN_ID),
TEST_PUBLIC_KEY,
`dispute:${DISPUTE_ID}`,
expect.any(String),
],
],
[
expect.stringContaining("UPDATE loan_disputes SET status = 'resolved'"),
[
Expand Down
26 changes: 24 additions & 2 deletions backend/src/__tests__/loanDispute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@

// ESM-compatible mocking
const mockQuery = jest.fn<(...args: unknown[]) => Promise<unknown>>();
// Run the transaction callback on the shared mock query, so a test observes
// the same statement ordering a real transaction would produce.
const mockWithTransaction = jest.fn(
async (fn: (client: { query: typeof mockQuery }) => Promise<unknown>) => fn({ query: mockQuery }),
);
jest.unstable_mockModule('../db/connection.js', () => ({
query: mockQuery,
default: { query: mockQuery, connect: jest.fn(), end: jest.fn() },
withTransaction: jest.fn(),
withTransaction: mockWithTransaction,
}));
jest.unstable_mockModule('../db/transaction.js', () => ({
withTransaction: jest.fn(),
Expand All @@ -31,7 +36,7 @@

let request: typeof import('supertest');
let jwt: typeof import('jsonwebtoken');
let app: any;

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

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

Check warning on line 39 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 @@ -119,6 +124,7 @@

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

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -147,7 +153,7 @@
* requireLoanBorrowerAccess:
* [1] SELECT address FROM contract_events WHERE loan_id = 42 → TEST_PUBLIC_KEY ✓
*
* contestDefault:
* contestDefault (inside withTransaction):
* [2] SELECT contract_events WHERE event_type='LoanDefaulted' → found (address = borrower)
* [3] INSERT loan_disputes RETURNING id → disputeId
* [4] INSERT contract_events LoanDisputed
Expand All @@ -167,6 +173,22 @@
expect(res.body.success).toBe(true);

disputeId = res.body.disputeId ?? disputeId;

// Both inserts must share one transaction, and the event must satisfy the
// NOT NULL columns on contract_events (#140).
expect(mockWithTransaction).toHaveBeenCalledTimes(1);
const eventInsert = mockQuery.mock.calls.find(
(call) => typeof call[0] === 'string' && (call[0] as string).includes('LoanDisputed'),
);
expect(eventInsert).toBeDefined();
expect(eventInsert![0]).toContain('event_id');
expect(eventInsert![1]).toEqual([
expect.any(String),
String(LOAN_ID),
TEST_PUBLIC_KEY,
`dispute:${DISPUTE_ID}`,
process.env.LOAN_MANAGER_CONTRACT_ID || 'offchain_dispute',
]);
});

it('should reject a contest when the defaulted loan belongs to another borrower', async () => {
Expand Down
49 changes: 49 additions & 0 deletions backend/src/__tests__/loanEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ const mockSubmitSignedTx =
jest.fn<
(signedTxXdr: string) => Promise<{ txHash: string; status: string; resultXdr?: string }>
>();
// Raw stroops, as the real service returns them.
const mockGetPoolBalance = jest.fn<() => Promise<number>>().mockResolvedValue(100_000_000_000); // 10,000 tokens
await jest.unstable_mockModule('../services/sorobanService.js', () => ({
sorobanService: {
buildRequestLoanTx: mockBuildRequestLoanTx,
Expand All @@ -114,6 +116,7 @@ await jest.unstable_mockModule('../services/sorobanService.js', () => ({
buildExtendLoanTx: mockBuildExtendLoanTx,
buildLiquidateTx: mockBuildLiquidateTx,
submitSignedTx: mockSubmitSignedTx,
getPoolBalance: mockGetPoolBalance,
},
}));

Expand Down Expand Up @@ -262,6 +265,52 @@ describe('POST /api/loans/request', () => {
.send({ borrowerPublicKey: TEST_BORROWER });
expect(response.status).toBe(400);
});

describe('pool liquidity pre-flight check (#139)', () => {
const originalNodeEnv = process.env.NODE_ENV;

beforeEach(() => {
// The pre-flight check is skipped under NODE_ENV=test.
process.env.NODE_ENV = 'development';
mockGetPoolBalance.mockResolvedValue(100_000_000_000);
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
mockGetPoolBalance.mockReset();
mockGetPoolBalance.mockResolvedValue(100_000_000_000);
});

it('rejects a request larger than the pool balance measured in tokens', async () => {
// 1,000 stroops is 0.0001 tokens — far below the 1,000 token request.
mockGetPoolBalance.mockResolvedValueOnce(1_000);

const response = await request(app)
.post('/api/loans/request')
.set(bearer(TEST_BORROWER))
.send({ amount: 1000, borrowerPublicKey: TEST_BORROWER });

expect(response.status).toBe(400);
expect(response.body.message).toContain('0.0001 tokens');
expect(mockBuildRequestLoanTx).not.toHaveBeenCalled();
});

it('accepts a request covered by the pool balance', async () => {
mockGetPoolBalance.mockResolvedValueOnce(10_000_000_000); // 1,000 tokens
mockBuildRequestLoanTx.mockResolvedValueOnce({
unsignedTxXdr: 'AAAA...base64xdr',
networkPassphrase: 'Test SDF Network ; September 2015',
});

const response = await request(app)
.post('/api/loans/request')
.set(bearer(TEST_BORROWER))
.send({ amount: 1000, borrowerPublicKey: TEST_BORROWER });

expect(response.status).toBe(200);
expect(mockGetPoolBalance).toHaveBeenCalledTimes(1);
});
});
});

// ---------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions backend/src/__tests__/scoreBreakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,43 @@ describe('GET /api/score/:userId/breakdown', () => {
expect(response.body.history).toHaveLength(3);
});

it('aggregates the current score so a user without a scores row still gets their event stats', async () => {
mockedQuery.mockResolvedValueOnce({
rows: [
{
current_score: 500,
total_loans: 2,
repaid_count: 1,
defaulted_count: 0,
total_repaid: 250,
on_time_count: 1,
late_count: 0,
avg_repayment_ledgers: 8640,
},
],
command: 'SELECT',
rowCount: 1,
oid: 0,
fields: [],
});
mockedQuery.mockResolvedValueOnce({
rows: [{ event_type: 'LoanRepaid', ledger_closed_at: '2026-03-01T10:00:00Z' }],
command: 'SELECT',
rowCount: 1,
oid: 0,
fields: [],
});

await request(app).get('/api/score/newuser/breakdown').set(bearer('newuser'));

// Without an aggregate the CTE yields 0 rows and the CROSS JOINs below
// discard every computed statistic (#141).
const breakdownSql = String(mockedQuery.mock.calls[0][0]);
expect(breakdownSql).toMatch(
/current_score_cte AS \(\s*SELECT COALESCE\(MAX\(current_score\), 500\)/,
);
});

it('should return default values for a user with no history', async () => {
// Mock empty breakdown and history queries
mockedQuery
Expand Down
27 changes: 27 additions & 0 deletions backend/src/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ await jest.unstable_mockModule('../services/cacheService.js', () => ({
await import('../db/connection.js');
const { default: app } = await import('../app.js');
const { generateJwtToken } = await import('../services/authService.js');
const { buildPoolTransactionSchema } = await import('../schemas/poolSchemas.js');

const TEST_WALLET = Keypair.random().publicKey();
const OTHER_WALLET = Keypair.random().publicKey();
Expand Down Expand Up @@ -189,4 +190,30 @@ describe('Input Validation', () => {
expect(response.status).toBe(404);
});
});

describe('buildPoolTransactionSchema', () => {
// The deposit route previously stripped minSharesOut before it reached the
// controller, so the slippage bound was never validated (#142).
const baseDeposit = {
depositorPublicKey: TEST_WALLET,
token: OTHER_WALLET,
amount: 100,
};

it('accepts a deposit carrying minSharesOut', () => {
expect(
buildPoolTransactionSchema.safeParse({ ...baseDeposit, minSharesOut: 95 }).success,
).toBe(true);
});

it('allows minSharesOut to be omitted', () => {
expect(buildPoolTransactionSchema.safeParse(baseDeposit).success).toBe(true);
});

it('rejects a negative minSharesOut', () => {
expect(
buildPoolTransactionSchema.safeParse({ ...baseDeposit, minSharesOut: -1 }).success,
).toBe(false);
});
});
});
54 changes: 37 additions & 17 deletions backend/src/controllers/loanController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { query } from '../db/connection.js';
import { query, withTransaction } from '../db/connection.js';
import { withStellarAndDbTransaction } from '../db/transaction.js';
import { AppError } from '../errors/AppError.js';
import { asyncHandler } from '../utils/asyncHandler.js';
Expand All @@ -12,7 +13,7 @@
import { cacheService } from '../services/cacheService.js';
import { notificationService } from '../services/notificationService.js';
import { invalidateOnRepay, invalidateOnLoanRequest } from '../utils/cacheKeys.js';
import { roundToCents } from '../money/decimal.js';
import { fromStroops, roundToCents, toStroops } from '../money/decimal.js';
import {
parseStroopAmount,
remainingPrincipal,
Expand Down Expand Up @@ -63,7 +64,7 @@
try {
const { loanId } = req.params;

const borrower = (req as any).user?.publicKey as string;

Check warning on line 67 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

Check warning on line 67 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

const result = await query('SELECT * FROM loans WHERE id = $1', [loanId]);
const loan = result.rows[0] as Record<string, unknown> | undefined;
Expand Down Expand Up @@ -189,18 +190,28 @@
throw AppError.forbidden('You are not the borrower of this loan');
}

// Insert dispute record and return disputeId
const disputeResult = await query(
`INSERT INTO loan_disputes (loan_id, borrower, reason, status) VALUES ($1, $2, $3, 'open') RETURNING id`,
[loanId, borrower, reason],
);
// The dispute row and its marker event must land together: writing the
// dispute first left an orphaned `loan_disputes` row behind when the event
// insert failed (#140).
//
// `contract_events` declares event_id, ledger, tx_hash and contract_id as
// NOT NULL, so this synthetic off-chain marker has to supply all of them
// (same shape as the admin dispute endpoints).
const contractId = process.env.LOAN_MANAGER_CONTRACT_ID || 'offchain_dispute';
const disputeRow = await withTransaction(async (client) => {
const disputeResult = await client.query(
`INSERT INTO loan_disputes (loan_id, borrower, reason, status) VALUES ($1, $2, $3, 'open') RETURNING id`,
[loanId, borrower, reason],
);
const disputeId = disputeResult.rows[0].id as number;

// Optionally: update loan status to 'disputed' in your loan status tracking (if applicable)
// If you have a loan status table/column, update it here. If only events, you may want to insert a new event:
await query(
`INSERT INTO contract_events (loan_id, address, event_type, amount, ledger, ledger_closed_at) VALUES ($1, $2, 'LoanDisputed', NULL, NULL, NOW())`,
[loanId, borrower],
);
await client.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, 'LoanDisputed', NULL, 0, NOW(), $4, $5)`,
[randomUUID(), loanId, borrower, `dispute:${disputeId}`, contractId],
);

return { disputeId };
});

logger.withContext().info('Loan default contested', { loanId, borrower, reason });

Expand All @@ -214,7 +225,7 @@

res.json({
success: true,
disputeId: disputeResult.rows[0].id,
disputeId: disputeRow.disputeId,
message: 'Loan default contested. Admins will review your dispute.',
});
},
Expand Down Expand Up @@ -856,6 +867,9 @@
});
});

/** Format stroops as a token amount without trailing settlement zeros. */
const formatTokenAmount = (stroops: bigint): string => fromStroops(stroops).replace(/\.?0+$/, '');

/**
* POST /api/loans/request
*/
Expand All @@ -881,12 +895,18 @@
typeof (sorobanService as unknown as { getPoolBalance?: () => Promise<number> })
.getPoolBalance === 'function'
) {
const poolBalance = await (
const poolBalanceStroops = await (
sorobanService as unknown as { getPoolBalance: () => Promise<number> }
).getPoolBalance();
if (amount > poolBalance) {
// `amount` is a human-readable token amount (e.g. 1000 USDC) while
// `getPoolBalance()` returns raw stroops (10^7 per token), so the comparison
// has to happen in a single unit — stroops — instead of comparing the two
// numbers directly (#139).
const requestedStroops = toStroops(amount.toString());
const availableStroops = BigInt(Math.floor(poolBalanceStroops));
if (requestedStroops > availableStroops) {
throw AppError.badRequest(
'Insufficient pool liquidity to cover this loan',
`Insufficient pool liquidity to cover this loan: requested ${amount} tokens, pool holds ${formatTokenAmount(availableStroops)} tokens`,
ErrorCode.INSUFFICIENT_BALANCE,
);
}
Expand Down
5 changes: 4 additions & 1 deletion backend/src/controllers/scoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,11 @@ export const getScoreBreakdown = asyncHandler(async (req: Request, res: Response
const breakdownResult = await query(
`WITH
-- Current score from scores table
-- MAX() keeps this CTE at exactly one row: a plain COALESCE(current_score, 500)
-- returned zero rows for users without a scores row yet, and the CROSS
-- JOINs below then discarded every computed loan/timing statistic (#141).
current_score_cte AS (
SELECT COALESCE(current_score, 500) AS current_score
SELECT COALESCE(MAX(current_score), 500) AS current_score
FROM scores
WHERE user_id = $1
),
Expand Down
1 change: 1 addition & 0 deletions backend/src/schemas/poolSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const buildPoolTransactionSchema = z.object({
depositorPublicKey: stellarAddressSchema,
token: stellarAddressSchema,
amount: positiveAmountSchema,
minSharesOut: z.number().nonnegative().optional(),
minAssetsOut: z.number().int().nonnegative().optional(),
});

Expand Down
18 changes: 18 additions & 0 deletions backend/src/services/__tests__/sorobanService.stroops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ describe('SorobanService token amounts are scaled to stroops (10^7)', () => {

expect(functionName).toBe('deposit');
expect(scValToNative(args[2])).toBe(125_000_000n);
expect(scValToNative(args[3])).toBe(0n);
});

it('buildDepositTx scales minSharesOut to stroops', async () => {
const { functionName, args } = await invokeArgsFor(() =>
sorobanService.buildDepositTx(userAddress, tokenAddress, 12.5, 95),
);

expect(functionName).toBe('deposit');
expect(scValToNative(args[3])).toBe(950_000_000n);
});

it('buildDepositTx accepts a fractional minSharesOut instead of throwing', async () => {
const { args } = await invokeArgsFor(() =>
sorobanService.buildDepositTx(userAddress, tokenAddress, 12.5, 95.5),
);

expect(scValToNative(args[3])).toBe(955_000_000n);
});

it('buildRefinanceLoanTx scales the new amount to stroops', async () => {
Expand Down
Loading
Loading