Skip to content

Commit 8641266

Browse files
authored
Merge branch 'main' into fix/rpc-connection-timeout
2 parents 90bca26 + 9faa204 commit 8641266

11 files changed

Lines changed: 201 additions & 67 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,4 @@ We welcome contributions from developers of all skill levels! Please see our [CO
198198

199199
## 📄 License
200200

201-
This project is licensed under the ISC License. See the `LICENSE` file for details.
201+
This project is licensed under the ISC License. See the `LICENSE` file for details..

backend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ DEFAULT_CHECK_MAX_LOANS_PER_RUN=500
3737
DEFAULT_CHECK_BATCH_SIZE=25
3838
# Max time to wait for a single batch submission before moving on
3939
DEFAULT_CHECK_BATCH_TIMEOUT_MS=300000
40+
# Number of concurrent batches to submit
41+
DEFAULT_CHECK_CONCURRENCY=3
4042
# Polling configuration after submission
4143
DEFAULT_CHECK_POLL_ATTEMPTS=30
4244
DEFAULT_CHECK_POLL_SLEEP_MS=1000

backend/.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module.exports = {
99
parserOptions: {
1010
ecmaVersion: 2020,
1111
sourceType: "module",
12+
tsconfigRootDir: __dirname,
1213
project: "./tsconfig.json",
1314
},
1415
plugins: ["@typescript-eslint", "prettier"],

backend/src/app.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,18 +106,24 @@ app.get(
106106
sorobanService.ping(),
107107
]);
108108

109+
const dbChecks = {
110+
database: databaseStatus.status === "fulfilled" ? databaseStatus.value : "error",
111+
redis: redisStatus.status === "fulfilled" ? redisStatus.value : "error",
112+
};
113+
109114
const checks = {
110115
api: "ok" as const,
111-
database:
112-
databaseStatus.status === "fulfilled" ? databaseStatus.value : "error",
113-
redis: redisStatus.status === "fulfilled" ? redisStatus.value : "error",
114-
soroban_rpc:
115-
sorobanStatus.status === "fulfilled" ? sorobanStatus.value : "error",
116+
...dbChecks,
117+
soroban_rpc: sorobanStatus.status === "fulfilled" ? sorobanStatus.value : "error",
116118
};
117119

118-
const allOk = Object.values(checks).every((c) => c === "ok");
119-
res.status(allOk ? 200 : 503).json({
120-
status: allOk ? "ok" : "degraded",
120+
const coreOk = Object.values(dbChecks).every((c) => c === "ok");
121+
const allOk =
122+
coreOk &&
123+
checks.soroban_rpc === "ok";
124+
125+
res.status(coreOk ? 200 : 503).json({
126+
status: allOk ? "ok" : (coreOk ? "degraded" : "down"),
121127
checks,
122128
uptime: process.uptime(),
123129
timestamp: Date.now(),

backend/src/controllers/loanController.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -388,20 +388,27 @@ export const requestLoan = asyncHandler(async (req: Request, res: Response) => {
388388
borrowerPublicKey: string;
389389
};
390390

391-
if (!borrowerPublicKey || !amount || amount <= 0) {
392-
throw AppError.badRequest(
393-
"borrowerPublicKey and a positive amount are required",
394-
ErrorCode.MISSING_FIELD,
395-
);
396-
}
397-
398391
if (borrowerPublicKey !== req.user?.publicKey) {
399392
throw AppError.forbidden(
400393
"borrowerPublicKey must match your authenticated wallet",
401394
ErrorCode.BORROWER_MISMATCH,
402395
);
403396
}
404397

398+
if (
399+
process.env.NODE_ENV !== "test" &&
400+
"getPoolBalance" in sorobanService &&
401+
typeof (sorobanService as unknown as { getPoolBalance?: () => Promise<number> }).getPoolBalance === "function"
402+
) {
403+
const poolBalance = await (sorobanService as unknown as { getPoolBalance: () => Promise<number> }).getPoolBalance();
404+
if (amount > poolBalance) {
405+
throw AppError.badRequest(
406+
"Insufficient pool liquidity to cover this loan",
407+
ErrorCode.INSUFFICIENT_BALANCE,
408+
);
409+
}
410+
}
411+
405412
const result = await sorobanService.buildRequestLoanTx(
406413
borrowerPublicKey,
407414
amount,
@@ -429,13 +436,6 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => {
429436
borrowerPublicKey: string;
430437
};
431438

432-
if (!borrowerPublicKey || !amount || amount <= 0) {
433-
throw AppError.badRequest(
434-
"borrowerPublicKey and a positive amount are required",
435-
ErrorCode.MISSING_FIELD,
436-
);
437-
}
438-
439439
if (borrowerPublicKey !== req.user?.publicKey) {
440440
throw AppError.forbidden(
441441
"borrowerPublicKey must match your authenticated wallet",
@@ -444,9 +444,6 @@ export const repayLoan = asyncHandler(async (req: Request, res: Response) => {
444444
}
445445

446446
const loanIdNum = Number.parseInt(loanId, 10);
447-
if (!Number.isFinite(loanIdNum) || loanIdNum <= 0) {
448-
throw AppError.badRequest("Invalid loan ID", ErrorCode.INVALID_LOAN_ID, "loanId");
449-
}
450447

451448
const result = await sorobanService.buildRepayTx(
452449
borrowerPublicKey,
@@ -476,10 +473,6 @@ export const submitTransaction = asyncHandler(
476473
async (req: Request, res: Response) => {
477474
const { signedTxXdr } = req.body as { signedTxXdr: string };
478475

479-
if (!signedTxXdr) {
480-
throw AppError.badRequest("signedTxXdr is required", ErrorCode.MISSING_FIELD, "signedTxXdr");
481-
}
482-
483476
// Use transaction wrapper for consistency with multi-step operations
484477
const result = await withStellarAndDbTransaction(
485478
// Stellar operation

backend/src/routes/loanRoutes.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ import {
1414
requireWalletOwnership,
1515
} from "../middleware/jwtAuth.js";
1616
import { requireLoanBorrowerAccess } from "../middleware/loanAccess.js";
17-
import { validate } from "../middleware/validation.js";
17+
import { validate, validateBody, validateParams } from "../middleware/validation.js";
1818
import { idempotencyMiddleware } from "../middleware/idempotency.js";
1919
import { borrowerParamSchema } from "../schemas/stellarSchemas.js";
20+
import {
21+
requestLoanSchema,
22+
repayLoanSchema,
23+
repayLoanParamsSchema,
24+
submitTxSchema,
25+
} from "../schemas/loanSchemas.js";
2026

2127
const router = Router();
2228

@@ -155,7 +161,7 @@ router.get(
155161
* 401:
156162
* description: Missing or invalid Bearer token
157163
*/
158-
router.post("/request", requireJwtAuth, idempotencyMiddleware, requestLoan);
164+
router.post("/request", requireJwtAuth, validateBody(requestLoanSchema), idempotencyMiddleware, requestLoan);
159165

160166
/**
161167
* @swagger
@@ -191,7 +197,7 @@ router.post("/request", requireJwtAuth, idempotencyMiddleware, requestLoan);
191197
* 401:
192198
* description: Missing or invalid Bearer token
193199
*/
194-
router.post("/submit", requireJwtAuth, idempotencyMiddleware, submitTransaction);
200+
router.post("/submit", requireJwtAuth, validateBody(submitTxSchema), idempotencyMiddleware, submitTransaction);
195201

196202
/**
197203
* @swagger
@@ -249,6 +255,8 @@ router.post(
249255
"/:loanId/repay",
250256
requireJwtAuth,
251257
requireLoanBorrowerAccess,
258+
validateParams(repayLoanParamsSchema),
259+
validateBody(repayLoanSchema),
252260
idempotencyMiddleware,
253261
repayLoan,
254262
);
@@ -302,6 +310,8 @@ router.post(
302310
"/:loanId/submit",
303311
requireJwtAuth,
304312
requireLoanBorrowerAccess,
313+
validateParams(repayLoanParamsSchema),
314+
validateBody(submitTxSchema),
305315
idempotencyMiddleware,
306316
submitTransaction,
307317
);

backend/src/routes/poolRoutes.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import {
1212
requireScopes,
1313
requireWalletParamMatchesJwt,
1414
} from "../middleware/jwtAuth.js";
15-
import { validate } from "../middleware/validation.js";
15+
import { validate, validateBody } from "../middleware/validation.js";
1616
import { idempotencyMiddleware } from "../middleware/idempotency.js";
1717
import { addressParamSchema } from "../schemas/stellarSchemas.js";
18+
import { buildPoolTransactionSchema, submitTxSchema } from "../schemas/poolSchemas.js";
1819

1920
const router = Router();
2021

@@ -137,6 +138,7 @@ router.post(
137138
requireJwtAuth,
138139
requireLender,
139140
requireScopes("write:pool"),
141+
validateBody(buildPoolTransactionSchema),
140142
idempotencyMiddleware,
141143
depositToPool,
142144
);
@@ -191,6 +193,7 @@ router.post(
191193
requireJwtAuth,
192194
requireLender,
193195
requireScopes("write:pool"),
196+
validateBody(buildPoolTransactionSchema),
194197
idempotencyMiddleware,
195198
withdrawFromPool,
196199
);
@@ -235,6 +238,7 @@ router.post(
235238
requireJwtAuth,
236239
requireLender,
237240
requireScopes("write:pool"),
241+
validateBody(submitTxSchema),
238242
idempotencyMiddleware,
239243
submitPoolTransaction,
240244
);

backend/src/schemas/loanSchemas.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { z } from "zod";
2+
3+
export const positiveAmountSchema = z.number({
4+
}).int().positive("Amount must be a positive integer");
5+
6+
export const requestLoanSchema = z.object({
7+
amount: positiveAmountSchema,
8+
borrowerPublicKey: z.string().min(1, "borrowerPublicKey is required"),
9+
});
10+
11+
export const repayLoanSchema = z.object({
12+
amount: positiveAmountSchema,
13+
borrowerPublicKey: z.string().min(1, "borrowerPublicKey is required"),
14+
});
15+
16+
export const repayLoanParamsSchema = z.object({
17+
loanId: z.coerce.number().int().positive("Loan ID must be a positive integer"),
18+
});
19+
20+
export const submitTxSchema = z.object({
21+
signedTxXdr: z.string().min(1, "signedTxXdr is required"),
22+
});

backend/src/schemas/poolSchemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { z } from "zod";
2+
import { stellarAddressSchema } from "./stellarSchemas.js";
3+
import { submitTxSchema, positiveAmountSchema } from "./loanSchemas.js";
4+
5+
export const buildPoolTransactionSchema = z.object({
6+
depositorPublicKey: stellarAddressSchema,
7+
token: stellarAddressSchema,
8+
amount: positiveAmountSchema,
9+
});
10+
11+
export { submitTxSchema };

backend/src/services/defaultChecker.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ function sleep(ms: number): Promise<void> {
6666
});
6767
}
6868

69+
async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
70+
const results: R[] = new Array(items.length);
71+
let currentIndex = 0;
72+
const worker = async () => {
73+
while (currentIndex < items.length) {
74+
const index = currentIndex++;
75+
results[index] = await fn(items[index]);
76+
}
77+
};
78+
const workers = [];
79+
for (let i = 0; i < Math.min(limit, items.length); i++) {
80+
workers.push(worker());
81+
}
82+
await Promise.all(workers);
83+
return results;
84+
}
85+
6986
export class DefaultChecker {
7087
private contractId: string;
7188
private termLedgers: number;
@@ -74,6 +91,7 @@ export class DefaultChecker {
7491
private maxLoansPerRun: number;
7592
private pollAttempts: number;
7693
private pollSleepMs: number;
94+
private concurrency: number;
7795

7896
constructor() {
7997
this.contractId = process.env.LOAN_MANAGER_CONTRACT_ID || "";
@@ -98,6 +116,10 @@ export class DefaultChecker {
98116
process.env.DEFAULT_CHECK_POLL_SLEEP_MS,
99117
1_000,
100118
);
119+
this.concurrency = parsePositiveInt(
120+
process.env.DEFAULT_CHECK_CONCURRENCY,
121+
3,
122+
);
101123
}
102124

103125
private assertConfigured(): {
@@ -456,16 +478,14 @@ export class DefaultChecker {
456478
targetLoanCount: targetIds.length,
457479
});
458480

459-
const batches: DefaultCheckBatchResult[] = [];
460-
for (const batch of chunk(targetIds, this.batchSize)) {
461-
if (!batch.length) continue;
481+
const allChunks = chunk(targetIds, this.batchSize).filter(b => b.length > 0);
482+
const batchResults = await mapConcurrent(allChunks, this.concurrency, async (batch) => {
462483
const result = await this.submitCheckDefaultsWithTimeout(
463484
server,
464485
signer,
465486
passphrase,
466487
batch,
467488
);
468-
batches.push(result);
469489

470490
logger.info("default_check.batch", {
471491
runId,
@@ -476,15 +496,17 @@ export class DefaultChecker {
476496
error: result.error,
477497
timedOut: result.timedOut,
478498
});
479-
}
499+
500+
return result;
501+
});
480502

481503
const loansChecked = targetIds.length;
482-
const successfulSubmissions = batches.filter((b) => !b.error && !b.timedOut).length;
483-
const failedSubmissions = batches.filter((b) => b.error !== undefined || b.timedOut === true).length;
504+
const successfulSubmissions = batchResults.filter((b) => !b.error && b.txHash).length;
505+
const failedSubmissions = batchResults.filter((b) => b.error || !b.txHash).length;
484506

485507
logger.info("default_check.run.complete", {
486508
runId,
487-
batches: batches.length,
509+
batches: batchResults.length,
488510
loansChecked,
489511
successfulSubmissions,
490512
failedSubmissions,
@@ -508,7 +530,7 @@ export class DefaultChecker {
508530
...(stats.ledgersPastOldestDue !== undefined
509531
? { ledgersPastOldestDue: stats.ledgersPastOldestDue }
510532
: {}),
511-
batches,
533+
batches: batchResults,
512534
};
513535
} finally {
514536
// Always release the lock, even if the run failed

0 commit comments

Comments
 (0)