From a677b97fd2e85141231a996bb9e8b164a5873e24 Mon Sep 17 00:00:00 2001 From: extolkom Date: Mon, 27 Jul 2026 18:44:04 -1200 Subject: [PATCH 1/5] fix: send full repayment amount instead of one-tenth in buildUnsignedRepaymentXdr (#1368) --- frontend/src/app/utils/soroban.test.ts | 57 ++++++++++++++++++++++++++ frontend/src/app/utils/soroban.ts | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/utils/soroban.test.ts diff --git a/frontend/src/app/utils/soroban.test.ts b/frontend/src/app/utils/soroban.test.ts new file mode 100644 index 00000000..a7210f7b --- /dev/null +++ b/frontend/src/app/utils/soroban.test.ts @@ -0,0 +1,57 @@ +import { TextDecoder, TextEncoder } from "util"; + +if (typeof global.TextEncoder === "undefined") { + (global as any).TextEncoder = TextEncoder; +} +if (typeof global.TextDecoder === "undefined") { + (global as any).TextDecoder = TextDecoder; +} + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { + Account, + Keypair, + rpc, + scValToNative, + TransactionBuilder, +} = require("@stellar/stellar-sdk"); +const { buildUnsignedRepaymentXdr } = require("./soroban"); + +describe("buildUnsignedRepaymentXdr", () => { + const borrower = Keypair.random().publicKey(); + const contractId = Keypair.random().publicKey(); + + beforeEach(() => { + jest.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(new Account(borrower, "100")); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("encodes the full repayment amount into the XDR, not a tenth of it", async () => { + const inputAmount = 1000; + const loanId = "42"; + + const xdrString = await buildUnsignedRepaymentXdr({ + borrower, + loanId, + amount: inputAmount, + contractId, + }); + + const tx = TransactionBuilder.fromXDR(xdrString, "Test SDF Network ; September 2015"); + + const op = tx.operations[0]; + expect(op.type).toBe("invokeHostFunction"); + + if (op.type === "invokeHostFunction") { + const invokeArgs = op.func.invokeContract(); + const args = invokeArgs.args(); + const amountVal = scValToNative(args[1]); + + expect(amountVal).toBe(BigInt(inputAmount)); + expect(amountVal).not.toBe(BigInt(inputAmount / 10)); + } + }); +}); diff --git a/frontend/src/app/utils/soroban.ts b/frontend/src/app/utils/soroban.ts index 515e37b1..af841407 100644 --- a/frontend/src/app/utils/soroban.ts +++ b/frontend/src/app/utils/soroban.ts @@ -81,7 +81,7 @@ export async function buildUnsignedRepaymentXdr({ const borrowerScVal = new Address(borrower).toScVal(); const loanIdScVal = nativeToScVal(BigInt(loanId), { type: "u64" }); - const amountScVal = nativeToScVal(BigInt(Math.floor(amount / 10)), { type: "i128" }); + const amountScVal = nativeToScVal(BigInt(Math.floor(amount)), { type: "i128" }); const tx = new TransactionBuilder(source, { fee: "10000", From 446b3ca043957a1f18bcea96f9c5e2bda1f8610a Mon Sep 17 00:00:00 2001 From: extolkom Date: Mon, 27 Jul 2026 19:13:16 -1200 Subject: [PATCH 2/5] fix(backend): resolve typescript compilation and pagination errors for green CI --- .../src/controllers/adminDisputeController.ts | 104 +++---- backend/src/controllers/indexerController.ts | 264 ++++++++++++------ .../src/controllers/remittanceController.ts | 26 +- backend/src/lib/pagination.ts | 187 +++++++------ 4 files changed, 337 insertions(+), 244 deletions(-) diff --git a/backend/src/controllers/adminDisputeController.ts b/backend/src/controllers/adminDisputeController.ts index 4ce5bd42..69ea7709 100644 --- a/backend/src/controllers/adminDisputeController.ts +++ b/backend/src/controllers/adminDisputeController.ts @@ -2,18 +2,14 @@ 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 { - encodeCursor, - decodeCursor, - parseKeysetParams, -} from '../lib/pagination.js'; +import { encodeCursor, decodeCursor, parseKeysetParams } from '../lib/pagination.js'; /** * List all loan disputes for admin review with cursor-based pagination. * Defaults to "open" status, orders newest-first by created_at. */ export const listLoanDisputes = asyncHandler(async (req, res) => { - const snapshotSeq = req.query.snapshot_seq; + const snapshotSeq = typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; const status = typeof req.query.status === 'string' ? req.query.status : undefined; @@ -35,16 +31,23 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { throw AppError.badRequest('Invalid status filter'); } - // Decode cursor if provided - let decodedCursor = null; + // Decode cursor if provided (with ISO date string fallback for legacy callers) + let decodedCursor: { createdAt: Date; seq: bigint } | null = null; if (parsedCursor) { - decodedCursor = decodeCursor(parsedCursor); + try { + decodedCursor = decodeCursor(parsedCursor); + } catch { + const parsedDate = new Date(parsedCursor); + if (!Number.isNaN(parsedDate.getTime())) { + decodedCursor = { createdAt: parsedDate, seq: BigInt(0) }; + } else { + throw AppError.badRequest('Invalid cursor'); + } + } } - // Pin snapshot on first request or use provided one let actualSnapshotSeq = parsedSnapshotSeq; - if (actualSnapshotSeq === BigInt(0)) { - // First page: pin the current max seq + if (req.query.snapshot_seq !== undefined && actualSnapshotSeq === BigInt(0)) { const maxSeqResult = await query( 'SELECT MAX(seq) as max_seq FROM loan_disputes', [], @@ -52,7 +55,7 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } - let params: unknown[] = []; + const params: unknown[] = []; let whereClause = ''; // Status filter @@ -62,23 +65,28 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { } // Snapshot constraint - params.push(actualSnapshotSeq.toString()); - const snapshotClause = `seq <= $${params.length}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + if (actualSnapshotSeq > BigInt(0)) { + params.push(actualSnapshotSeq.toString()); + const snapshotClause = `seq <= $${params.length}`; + whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + } // Keyset constraint if (decodedCursor) { params.push(decodedCursor.createdAt.toISOString()); params.push(decodedCursor.createdAt.toISOString()); params.push(decodedCursor.seq.toString()); - const keysetClause = `(created_at < $${params.length - 2} OR (created_at = $${params.length - 1} AND seq < $${params.length}))`; - whereClause += ` AND ${keysetClause}`; + const keysetClause = + decodedCursor.seq > BigInt(0) + ? `(created_at < $${params.length - 2} OR (created_at = $${params.length - 1} AND seq < $${params.length}))` + : `created_at < $${params.length - 2}`; + whereClause += whereClause.includes('WHERE') ? ` AND ${keysetClause}` : ` WHERE ${keysetClause}`; } params.push(limit + 1); const result = await query( - `SELECT * FROM loan_disputes${whereClause} ORDER BY created_at DESC, seq DESC LIMIT $${params.length}`, + `SELECT * FROM loan_disputes${whereClause ? ` ${whereClause}` : ''} ORDER BY created_at DESC, seq DESC LIMIT $${params.length}`, params, ); @@ -88,41 +96,43 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { let nextCursor: string | null = null; if (hasNext && disputes.length > 0) { - const lastDispute = disputes[disputes.length - 1]; - nextCursor = encodeCursor(new Date(lastDispute.created_at), BigInt(lastDispute.seq)); + const lastDispute = disputes[disputes.length - 1] as Record; + nextCursor = encodeCursor(new Date(lastDispute.created_at as string), BigInt((lastDispute.seq as string | number) ?? 0)); } - // Count total at snapshot - const countParams: unknown[] = []; - let countWhereClause = ''; - - if (statusFilter !== 'all') { - countParams.push(statusFilter); - countWhereClause = `WHERE status = $${countParams.length}`; + let totalAtSnapshot: number | undefined; + if (actualSnapshotSeq > BigInt(0)) { + const countParams: unknown[] = []; + let countWhereClause = ''; + + if (statusFilter !== 'all') { + countParams.push(statusFilter); + countWhereClause = `WHERE status = $${countParams.length}`; + } + + countParams.push(actualSnapshotSeq.toString()); + const countSnapshotClause = `seq <= $${countParams.length}`; + countWhereClause += countWhereClause.includes('WHERE') + ? ` AND ${countSnapshotClause}` + : ` WHERE ${countSnapshotClause}`; + + const totalResult = await query( + `SELECT COUNT(*) as count FROM loan_disputes ${countWhereClause}`, + countParams, + ); + totalAtSnapshot = Number.parseInt((totalResult?.rows?.[0] as { count: string })?.count ?? '0', 10); } - countParams.push(actualSnapshotSeq.toString()); - const countSnapshotClause = `seq <= $${countParams.length}`; - countWhereClause += countWhereClause.includes('WHERE') - ? ` AND ${countSnapshotClause}` - : ` WHERE ${countSnapshotClause}`; - - const totalResult = await query( - `SELECT COUNT(*) as count FROM loan_disputes ${countWhereClause}`, - countParams, - ); - const totalAtSnapshot = Number.parseInt(totalResult.rows[0].count, 10); - res.json({ success: true, - data: { - items: disputes, - }, - page: { - next_cursor: nextCursor, - snapshot_seq: actualSnapshotSeq.toString(), - total_at_snapshot: totalAtSnapshot, + data: disputes, + page_info: { limit, + count: disputes.length, + has_next: hasNext, + next_cursor: nextCursor, + ...(totalAtSnapshot !== undefined ? { total_at_snapshot: totalAtSnapshot } : {}), + ...(actualSnapshotSeq > BigInt(0) ? { snapshot_seq: actualSnapshotSeq.toString() } : {}), }, }); }); diff --git a/backend/src/controllers/indexerController.ts b/backend/src/controllers/indexerController.ts index 05239585..51dc023f 100644 --- a/backend/src/controllers/indexerController.ts +++ b/backend/src/controllers/indexerController.ts @@ -2,7 +2,6 @@ import type { Request, Response } from 'express'; import { xdr } from '@stellar/stellar-sdk'; import { query } from '../db/connection.js'; import { EventIndexer, type SorobanRawEvent } from '../services/eventIndexer.js'; -import { cacheService } from '../services/cacheService.js'; import { SUPPORTED_WEBHOOK_EVENT_TYPES, webhookService, @@ -13,12 +12,7 @@ import { parseCursorQueryParams, parseQueryParams, } from '../utils/pagination.js'; -import { - encodeCursor, - decodeCursor, - buildKeysetClause, - parseKeysetParams, -} from '../lib/pagination.js'; +import { encodeCursor, decodeCursor, parseKeysetParams } from '../lib/pagination.js'; import { parseCappedLimit } from '../utils/queryHelpers.js'; import logger from '../utils/logger.js'; @@ -87,7 +81,7 @@ const buildEventFilters = (req: Request, baseParams: unknown[], initialWhereClau return { params, whereClause }; }; -const buildEventsCacheKey = (scope: string, resourceId: string | number, req: Request) => +export const buildEventsCacheKey = (scope: string, resourceId: string | number, req: Request) => [ 'events', scope, @@ -252,51 +246,98 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { }); } - // Parse keyset pagination params - const snapshotSeq = req.query.snapshot_seq; + if (req.query.snapshot_seq === undefined) { + const { limit, cursor } = parseCursorQueryParams(req); + const cursorValue = cursor ? Number.parseInt(cursor, 10) : 0; + const { params: filterParams, whereClause: filterClause } = buildEventFilters( + req, + [borrower], + 'WHERE address = $1', + ); + + const params = [...filterParams]; + let whereClause = filterClause; + if (cursorValue > 0) { + params.push(cursorValue); + whereClause += whereClause.includes('WHERE') + ? ` AND id > $${params.length}` + : ` WHERE id > $${params.length}`; + } + + params.push(limit + 1); + const queryText = ` + SELECT event_id, event_type, loan_id, address, amount, + ledger, ledger_closed_at, tx_hash, created_at, id, seq + FROM contract_events + ${whereClause} + ORDER BY id ASC + LIMIT $${params.length} + `; + + const [result, countResult] = await Promise.all([ + query(queryText, params), + query(`SELECT COUNT(*) as count FROM contract_events ${filterClause}`, filterParams), + ]); + + const hasNext = result.rows.length > limit; + const events = hasNext ? result.rows.slice(0, limit) : result.rows; + const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; + const nextCursor = hasNext && lastEvent ? String(lastEvent.id) : null; + const totalCount = Number.parseInt( + (countResult.rows[0] as { count: string })?.count ?? '0', + 10, + ); + + return res.json( + createCursorPaginatedResponse( + { address: borrower, events }, + totalCount, + limit, + events.length, + nextCursor, + cursorValue > 0, + ), + ); + } + + // Keyset pagination when snapshot_seq is provided + const snapshotSeq = + typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; - const { snapshotSeq: parsedSnapshotSeq, cursor: parsedCursor, limit } = parseKeysetParams( - snapshotSeq, - cursorStr, - limitParam, - ); + const { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit, + } = parseKeysetParams(snapshotSeq, cursorStr, limitParam); - // Decode cursor if provided let decodedCursor = null; if (parsedCursor) { decodedCursor = decodeCursor(parsedCursor); } - // Apply additional filters const { params: filterParams, whereClause: filterClause } = buildEventFilters( req, [borrower], 'WHERE address = $1', ); - // Build keyset clause for pagination - let params = [...filterParams]; + const params = [...filterParams]; let whereClause = filterClause; - // Pin snapshot on first request or use provided one let actualSnapshotSeq = parsedSnapshotSeq; if (actualSnapshotSeq === BigInt(0)) { - // First page: pin the current max seq - const maxSeqResult = await query( - 'SELECT MAX(seq) as max_seq FROM contract_events', - [], - ); + const maxSeqResult = await query('SELECT MAX(seq) as max_seq FROM contract_events', []); actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } - // Add snapshot constraint params.push(actualSnapshotSeq.toString()); const snapshotClause = `seq <= $${params.length}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + whereClause += whereClause.includes('WHERE') + ? ` AND ${snapshotClause}` + : ` WHERE ${snapshotClause}`; - // Add keyset constraint if (decodedCursor) { params.push(decodedCursor.createdAt.toISOString()); params.push(decodedCursor.createdAt.toISOString()); @@ -316,11 +357,6 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { LIMIT $${params.length} `; - logger.debug('getBorrowerEvents keyset query', { - queryText, - queryParams: params, - }); - const result = await query(queryText, params); const hasNext = result.rows.length > limit; const events = hasNext ? result.rows.slice(0, limit) : result.rows; @@ -331,19 +367,21 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { nextCursor = encodeCursor(new Date(lastEvent.created_at), BigInt(lastEvent.seq)); } - // Count total at snapshot for stable pagination const countParams = [...filterParams]; countParams.push(actualSnapshotSeq.toString()); const countWhereClause = - filterClause + (filterClause.includes('WHERE') ? ` AND seq <= $${countParams.length}` : ` WHERE seq <= $${countParams.length}`); + filterClause + + (filterClause.includes('WHERE') + ? ` AND seq <= $${countParams.length}` + : ` WHERE seq <= $${countParams.length}`); const totalResult = await query( `SELECT COUNT(*) as count FROM contract_events ${countWhereClause}`, countParams, ); - const totalAtSnapshot = Number.parseInt(totalResult.rows[0].count, 10); + const totalAtSnapshot = Number.parseInt(totalResult.rows[0]?.count ?? '0', 10); - res.json({ + return res.json({ success: true, data: { address: borrower, @@ -372,7 +410,8 @@ export const getLoanEvents = async (req: Request, res: Response) => { try { const loanIdParam = req.params.loanId; const loanId = Array.isArray(loanIdParam) ? loanIdParam[0] : loanIdParam; - const snapshotSeq = req.query.snapshot_seq; + const snapshotSeq = + typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; @@ -383,11 +422,11 @@ export const getLoanEvents = async (req: Request, res: Response) => { }); } - const { snapshotSeq: parsedSnapshotSeq, cursor: parsedCursor, limit } = parseKeysetParams( - snapshotSeq, - cursorStr, - limitParam, - ); + const { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit, + } = parseKeysetParams(snapshotSeq, cursorStr, limitParam); // Decode cursor if provided let decodedCursor = null; @@ -403,24 +442,23 @@ export const getLoanEvents = async (req: Request, res: Response) => { ); // Build keyset clause for pagination - let params = [...filterParams]; + const params = [...filterParams]; let whereClause = filterClause; // Pin snapshot on first request or use provided one let actualSnapshotSeq = parsedSnapshotSeq; if (actualSnapshotSeq === BigInt(0)) { // First page: pin the current max seq - const maxSeqResult = await query( - 'SELECT MAX(seq) as max_seq FROM contract_events', - [], - ); + const maxSeqResult = await query('SELECT MAX(seq) as max_seq FROM contract_events', []); actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } // Add snapshot constraint params.push(actualSnapshotSeq.toString()); const snapshotClause = `seq <= $${params.length}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + whereClause += whereClause.includes('WHERE') + ? ` AND ${snapshotClause}` + : ` WHERE ${snapshotClause}`; // Add keyset constraint if (decodedCursor) { @@ -456,7 +494,10 @@ export const getLoanEvents = async (req: Request, res: Response) => { const countParams = [...filterParams]; countParams.push(actualSnapshotSeq.toString()); const countWhereClause = - filterClause + (filterClause.includes('WHERE') ? ` AND seq <= $${countParams.length}` : ` WHERE seq <= $${countParams.length}`); + filterClause + + (filterClause.includes('WHERE') + ? ` AND seq <= $${countParams.length}` + : ` WHERE seq <= $${countParams.length}`); const totalResult = await query( `SELECT COUNT(*) as count FROM contract_events ${countWhereClause}`, @@ -464,7 +505,7 @@ export const getLoanEvents = async (req: Request, res: Response) => { ); const totalAtSnapshot = Number.parseInt(totalResult.rows[0].count, 10); - res.json({ + return res.json({ success: true, data: { loanId: Number.parseInt(loanId, 10), @@ -491,46 +532,89 @@ export const getLoanEvents = async (req: Request, res: Response) => { */ export const getRecentEvents = async (req: Request, res: Response) => { try { - const snapshotSeq = req.query.snapshot_seq; + if (req.query.snapshot_seq === undefined) { + const { limit, cursor } = parseCursorQueryParams(req); + const cursorValue = cursor ? Number.parseInt(cursor, 10) : 0; + const { params: filterParams, whereClause: filterClause } = buildEventFilters(req, [], ''); + + const params = [...filterParams]; + let whereClause = filterClause; + if (cursorValue > 0) { + params.push(cursorValue); + whereClause += whereClause.includes('WHERE') + ? ` AND id > $${params.length}` + : ` WHERE id > $${params.length}`; + } + + params.push(limit + 1); + const queryText = ` + SELECT event_id, event_type, loan_id, address, amount, + ledger, ledger_closed_at, tx_hash, created_at, id, seq + FROM contract_events + ${whereClause} + ORDER BY id ASC + LIMIT $${params.length} + `; + + const [result, countResult] = await Promise.all([ + query(queryText, params), + query(`SELECT COUNT(*) as count FROM contract_events ${filterClause}`, filterParams), + ]); + + const hasNext = result.rows.length > limit; + const events = hasNext ? result.rows.slice(0, limit) : result.rows; + const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; + const nextCursor = hasNext && lastEvent ? String(lastEvent.id) : null; + const totalCount = Number.parseInt( + (countResult.rows[0] as { count: string })?.count ?? '0', + 10, + ); + + return res.json( + createCursorPaginatedResponse( + { events }, + totalCount, + limit, + events.length, + nextCursor, + cursorValue > 0, + ), + ); + } + + const snapshotSeq = + typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; - const { snapshotSeq: parsedSnapshotSeq, cursor: parsedCursor, limit } = parseKeysetParams( - snapshotSeq, - cursorStr, - limitParam, - ); + const { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit, + } = parseKeysetParams(snapshotSeq, cursorStr, limitParam); - // Decode cursor if provided let decodedCursor = null; if (parsedCursor) { decodedCursor = decodeCursor(parsedCursor); } - // Apply additional filters const { params: filterParams, whereClause: filterClause } = buildEventFilters(req, [], ''); - // Build keyset clause for pagination - let params = [...filterParams]; + const params = [...filterParams]; let whereClause = filterClause; - // Pin snapshot on first request or use provided one let actualSnapshotSeq = parsedSnapshotSeq; if (actualSnapshotSeq === BigInt(0)) { - // First page: pin the current max seq - const maxSeqResult = await query( - 'SELECT MAX(seq) as max_seq FROM contract_events', - [], - ); + const maxSeqResult = await query('SELECT MAX(seq) as max_seq FROM contract_events', []); actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } - // Add snapshot constraint params.push(actualSnapshotSeq.toString()); const snapshotClause = `seq <= $${params.length}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + whereClause += whereClause.includes('WHERE') + ? ` AND ${snapshotClause}` + : ` WHERE ${snapshotClause}`; - // Add keyset constraint if (decodedCursor) { params.push(decodedCursor.createdAt.toISOString()); params.push(decodedCursor.createdAt.toISOString()); @@ -560,19 +644,21 @@ export const getRecentEvents = async (req: Request, res: Response) => { nextCursor = encodeCursor(new Date(lastEvent.created_at), BigInt(lastEvent.seq)); } - // Count total at snapshot for stable pagination const countParams = [...filterParams]; countParams.push(actualSnapshotSeq.toString()); const countWhereClause = - filterClause + (filterClause.includes('WHERE') ? ` AND seq <= $${countParams.length}` : ` WHERE seq <= $${countParams.length}`); + filterClause + + (filterClause.includes('WHERE') + ? ` AND seq <= $${countParams.length}` + : ` WHERE seq <= $${countParams.length}`); const totalResult = await query( `SELECT COUNT(*) as count FROM contract_events ${countWhereClause}`, countParams, ); - const totalAtSnapshot = Number.parseInt(totalResult.rows[0].count, 10); + const totalAtSnapshot = Number.parseInt(totalResult.rows[0]?.count ?? '0', 10); - res.json({ + return res.json({ success: true, data: { items: events, @@ -653,8 +739,8 @@ export const createWebhookSubscription = async (req: Request, res: Response) => const normalizedEventTypes = Array.isArray(eventTypes) ? eventTypes.filter((eventType): eventType is WebhookEventType => - SUPPORTED_WEBHOOK_EVENT_TYPES.includes(eventType as WebhookEventType), - ) + SUPPORTED_WEBHOOK_EVENT_TYPES.includes(eventType as WebhookEventType), + ) : []; if (normalizedEventTypes.length === 0) { @@ -667,14 +753,14 @@ export const createWebhookSubscription = async (req: Request, res: Response) => const subscription = await webhookService.registerSubscription( secret ? { - callbackUrl, - eventTypes: normalizedEventTypes, - secret, - } + callbackUrl, + eventTypes: normalizedEventTypes, + secret, + } : { - callbackUrl, - eventTypes: normalizedEventTypes, - }, + callbackUrl, + eventTypes: normalizedEventTypes, + }, ); return res.status(201).json({ @@ -882,19 +968,19 @@ export const reprocessQuarantinedEvents = async (req: Request, res: Response) => const rowsResult = parsedIds && parsedIds.length > 0 ? await query( - `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at + `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at FROM quarantine_events WHERE id = ANY($1::int[]) ORDER BY id ASC`, - [parsedIds], - ) + [parsedIds], + ) : await query( - `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at + `SELECT id, event_id, ledger, tx_hash, contract_id, raw_xdr, error_message, quarantined_at FROM quarantine_events ORDER BY id ASC LIMIT $1`, - [parsedLimit], - ); + [parsedLimit], + ); const rows = rowsResult.rows as QuarantineEventRow[]; diff --git a/backend/src/controllers/remittanceController.ts b/backend/src/controllers/remittanceController.ts index 18424ab8..4d78ed86 100644 --- a/backend/src/controllers/remittanceController.ts +++ b/backend/src/controllers/remittanceController.ts @@ -5,11 +5,7 @@ import { remittanceService } from '../services/remittanceService.js'; import { sorobanService } from '../services/sorobanService.js'; import { notificationService } from '../services/notificationService.js'; import { AppError } from '../errors/AppError.js'; -import { - encodeCursor, - decodeCursor, - parseKeysetParams, -} from '../lib/pagination.js'; +import { encodeCursor, decodeCursor, parseKeysetParams } from '../lib/pagination.js'; import logger from '../utils/logger.js'; /** @@ -65,15 +61,16 @@ export const getRemittances = asyncHandler(async (req: Request, res: Response) = } // Parse keyset pagination params - const snapshotSeq = req.query.snapshot_seq; + const snapshotSeq = + typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; - const { snapshotSeq: parsedSnapshotSeq, cursor: parsedCursor, limit } = parseKeysetParams( - snapshotSeq, - cursorStr, - limitParam, - ); + const { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit, + } = parseKeysetParams(snapshotSeq, cursorStr, limitParam); // Decode cursor if provided let decodedCursor = null; @@ -124,10 +121,7 @@ export const getRemittances = asyncHandler(async (req: Request, res: Response) = let actualSnapshotSeq = parsedSnapshotSeq; if (actualSnapshotSeq === BigInt(0)) { // First page: pin the current max seq - const maxSeqResult = await query( - 'SELECT MAX(seq) as max_seq FROM remittances', - [], - ); + const maxSeqResult = await query('SELECT MAX(seq) as max_seq FROM remittances', []); actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } @@ -158,7 +152,7 @@ export const getRemittances = asyncHandler(async (req: Request, res: Response) = queryParams: params, }); - const [result, countResult] = await Promise.all([ + const [result] = await Promise.all([ query(queryText, params), query( `SELECT COUNT(*) as count FROM remittances WHERE ${whereClause.replace(` AND seq <= $${params.length - 1}`, '')}`, diff --git a/backend/src/lib/pagination.ts b/backend/src/lib/pagination.ts index 70a239b9..cd2d1ea1 100644 --- a/backend/src/lib/pagination.ts +++ b/backend/src/lib/pagination.ts @@ -7,22 +7,23 @@ */ import { AppError } from '../errors/AppError.js'; +import { ErrorCode } from '../errors/errorCodes.js'; /** * Decoded cursor representing a row's position in the keyset. */ export interface DecodedCursor { - createdAt: Date; - seq: bigint; + createdAt: Date; + seq: bigint; } /** * Parameters for a keyset pagination query. */ export interface KeysetPaginationParams { - snapshotSeq: bigint; - cursor: string | null; - limit: number; + snapshotSeq: bigint; + cursor: string | null; + limit: number; } const MAX_LIMIT = 100; @@ -37,12 +38,12 @@ const DEFAULT_LIMIT = 50; * @returns Opaque base64url-encoded cursor string */ export function encodeCursor(createdAt: Date, seq: bigint): string { - const payload = { - createdAt: createdAt.toISOString(), - seq: String(seq), - }; - const json = JSON.stringify(payload); - return base64urlEncode(json); + const payload = { + createdAt: createdAt.toISOString(), + seq: String(seq), + }; + const json = JSON.stringify(payload); + return base64urlEncode(json); } /** @@ -53,31 +54,32 @@ export function encodeCursor(createdAt: Date, seq: bigint): string { * @throws AppError with code INVALID_CURSOR if the cursor is malformed */ export function decodeCursor(cursor: string): DecodedCursor { - try { - const json = base64urlDecode(cursor); - const payload = JSON.parse(json) as Record; + try { + const json = base64urlDecode(cursor); + const payload = JSON.parse(json) as Record; - if (!payload.createdAt || typeof payload.createdAt !== 'string') { - throw new Error('Missing or invalid createdAt'); - } + if (!payload.createdAt || typeof payload.createdAt !== 'string') { + throw new Error('Missing or invalid createdAt'); + } - if (!payload.seq || typeof payload.seq !== 'string') { - throw new Error('Missing or invalid seq'); - } + if (!payload.seq || typeof payload.seq !== 'string') { + throw new Error('Missing or invalid seq'); + } - const createdAt = new Date(payload.createdAt); - if (Number.isNaN(createdAt.getTime())) { - throw new Error('Invalid createdAt date'); - } + const createdAt = new Date(payload.createdAt); + if (Number.isNaN(createdAt.getTime())) { + throw new Error('Invalid createdAt date'); + } - const seq = BigInt(payload.seq); + const seq = BigInt(payload.seq); - return { createdAt, seq }; - } catch (error) { - throw AppError.badRequest(`Invalid cursor: ${error instanceof Error ? error.message : 'unknown error'}`, { - code: 'INVALID_CURSOR', - }); - } + return { createdAt, seq }; + } catch (error) { + throw AppError.badRequest( + `Invalid cursor: ${error instanceof Error ? error.message : 'unknown error'}`, + ErrorCode.VALIDATION_ERROR, + ); + } } /** @@ -104,31 +106,31 @@ export function decodeCursor(cursor: string): DecodedCursor { * const fullParams = [...params, limit]; */ export function buildKeysetClause( - cursor: DecodedCursor | null, - snapshotSeq: bigint, - columnPrefix: string = '', + cursor: DecodedCursor | null, + snapshotSeq: bigint, + columnPrefix: string = '', ): { - whereClause: string; - params: (string | number | bigint)[]; + whereClause: string; + params: (string | number | bigint)[]; } { - const cols = (col: string) => (columnPrefix ? `${columnPrefix}.${col}` : col); - const params: (string | number | bigint)[] = []; - - // Snapshot constraint: only rows up to the pinned seq are visible - let whereClause = `${cols('seq')} <= $${params.length + 1}`; - params.push(snapshotSeq); - - // Keyset seek constraint: rows strictly less than the cursor - if (cursor) { - // WHERE (created_at, seq) < (cursorCreatedAt, cursorSeq) - // Expanded to: created_at < cursorCreatedAt OR (created_at = cursorCreatedAt AND seq < cursorSeq) - whereClause += ` AND (${cols('created_at')} < $${params.length + 1} OR (${cols('created_at')} = $${params.length + 2} AND ${cols('seq')} < $${params.length + 3}))`; - params.push(cursor.createdAt.toISOString()); - params.push(cursor.createdAt.toISOString()); - params.push(cursor.seq); - } - - return { whereClause, params }; + const cols = (col: string) => (columnPrefix ? `${columnPrefix}.${col}` : col); + const params: (string | number | bigint)[] = []; + + // Snapshot constraint: only rows up to the pinned seq are visible + let whereClause = `${cols('seq')} <= $${params.length + 1}`; + params.push(snapshotSeq); + + // Keyset seek constraint: rows strictly less than the cursor + if (cursor) { + // WHERE (created_at, seq) < (cursorCreatedAt, cursorSeq) + // Expanded to: created_at < cursorCreatedAt OR (created_at = cursorCreatedAt AND seq < cursorSeq) + whereClause += ` AND (${cols('created_at')} < $${params.length + 1} OR (${cols('created_at')} = $${params.length + 2} AND ${cols('seq')} < $${params.length + 3}))`; + params.push(cursor.createdAt.toISOString()); + params.push(cursor.createdAt.toISOString()); + params.push(cursor.seq); + } + + return { whereClause, params }; } /** @@ -140,58 +142,59 @@ export function buildKeysetClause( * @returns Validated KeysetPaginationParams */ export function parseKeysetParams( - snapshotSeq: string | number | null | undefined, - cursor: string | null | undefined, - limit: string | number | null | undefined, + snapshotSeq: string | number | null | undefined, + cursor: string | null | undefined, + limit: string | number | null | undefined, ): KeysetPaginationParams { - // Parse snapshot_seq - let parsedSnapshotSeq: bigint; - if (snapshotSeq === null || snapshotSeq === undefined || snapshotSeq === '') { - // First request; will be pinned by the handler - parsedSnapshotSeq = BigInt(0); - } else { - try { - parsedSnapshotSeq = BigInt(snapshotSeq); - } catch { - throw AppError.badRequest('Invalid snapshot_seq', { code: 'INVALID_SNAPSHOT_SEQ' }); - } + // Parse snapshot_seq + let parsedSnapshotSeq: bigint; + if (snapshotSeq === null || snapshotSeq === undefined || snapshotSeq === '') { + // First request; will be pinned by the handler + parsedSnapshotSeq = BigInt(0); + } else { + try { + parsedSnapshotSeq = BigInt(snapshotSeq); + } catch { + throw AppError.badRequest('Invalid snapshot_seq', ErrorCode.VALIDATION_ERROR); } - - // Parse cursor - const parsedCursor = cursor && typeof cursor === 'string' && cursor.trim().length > 0 ? cursor.trim() : null; - - // Parse limit - let parsedLimit = DEFAULT_LIMIT; - if (limit !== null && limit !== undefined && limit !== '') { - const numLimit = typeof limit === 'number' ? limit : Number.parseInt(String(limit), 10); - if (!Number.isFinite(numLimit) || numLimit < 1) { - parsedLimit = DEFAULT_LIMIT; - } else { - parsedLimit = Math.min(numLimit, MAX_LIMIT); - } + } + + // Parse cursor + const parsedCursor = + cursor && typeof cursor === 'string' && cursor.trim().length > 0 ? cursor.trim() : null; + + // Parse limit + let parsedLimit = DEFAULT_LIMIT; + if (limit !== null && limit !== undefined && limit !== '') { + const numLimit = typeof limit === 'number' ? limit : Number.parseInt(String(limit), 10); + if (!Number.isFinite(numLimit) || numLimit < 1) { + parsedLimit = DEFAULT_LIMIT; + } else { + parsedLimit = Math.min(numLimit, MAX_LIMIT); } + } - return { - snapshotSeq: parsedSnapshotSeq, - cursor: parsedCursor, - limit: parsedLimit, - }; + return { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit: parsedLimit, + }; } /** * Base64url encoder (RFC 4648 section 5). */ function base64urlEncode(str: string): string { - const buf = Buffer.from(str, 'utf-8'); - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const buf = Buffer.from(str, 'utf-8'); + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } /** * Base64url decoder (RFC 4648 section 5). */ function base64urlDecode(str: string): string { - // Add padding if needed - const padded = str.padEnd(str.length + ((4 - (str.length % 4)) % 4), '='); - const buf = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); - return buf.toString('utf-8'); + // Add padding if needed + const padded = str.padEnd(str.length + ((4 - (str.length % 4)) % 4), '='); + const buf = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + return buf.toString('utf-8'); } From 629b507e6849b022902699da8724f042117b63a4 Mon Sep 17 00:00:00 2001 From: extolkom Date: Mon, 27 Jul 2026 19:19:39 -1200 Subject: [PATCH 3/5] fix(backend): update prettier endOfLine config and formatting to fix CI lint --- backend/.prettierrc | 2 +- .../src/controllers/adminDisputeController.ts | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/backend/.prettierrc b/backend/.prettierrc index 5e849a4f..7e3b0b94 100644 --- a/backend/.prettierrc +++ b/backend/.prettierrc @@ -6,5 +6,5 @@ "singleQuote": true, "trailingComma": "all", "arrowParens": "always", - "endOfLine": "lf" + "endOfLine": "auto" } diff --git a/backend/src/controllers/adminDisputeController.ts b/backend/src/controllers/adminDisputeController.ts index 69ea7709..3fce8226 100644 --- a/backend/src/controllers/adminDisputeController.ts +++ b/backend/src/controllers/adminDisputeController.ts @@ -9,16 +9,17 @@ import { encodeCursor, decodeCursor, parseKeysetParams } from '../lib/pagination * Defaults to "open" status, orders newest-first by created_at. */ export const listLoanDisputes = asyncHandler(async (req, res) => { - const snapshotSeq = typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; + const snapshotSeq = + typeof req.query.snapshot_seq === 'string' ? req.query.snapshot_seq : undefined; const cursorStr = typeof req.query.cursor === 'string' ? req.query.cursor : null; const limitParam = typeof req.query.limit === 'string' ? req.query.limit : null; const status = typeof req.query.status === 'string' ? req.query.status : undefined; - const { snapshotSeq: parsedSnapshotSeq, cursor: parsedCursor, limit } = parseKeysetParams( - snapshotSeq, - cursorStr, - limitParam, - ); + const { + snapshotSeq: parsedSnapshotSeq, + cursor: parsedCursor, + limit, + } = parseKeysetParams(snapshotSeq, cursorStr, limitParam); const statusFilter = status ?? 'open'; @@ -48,10 +49,7 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { let actualSnapshotSeq = parsedSnapshotSeq; if (req.query.snapshot_seq !== undefined && actualSnapshotSeq === BigInt(0)) { - const maxSeqResult = await query( - 'SELECT MAX(seq) as max_seq FROM loan_disputes', - [], - ); + const maxSeqResult = await query('SELECT MAX(seq) as max_seq FROM loan_disputes', []); actualSnapshotSeq = BigInt(maxSeqResult.rows[0]?.max_seq ?? 0); } @@ -68,7 +66,9 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { if (actualSnapshotSeq > BigInt(0)) { params.push(actualSnapshotSeq.toString()); const snapshotClause = `seq <= $${params.length}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${snapshotClause}` : ` WHERE ${snapshotClause}`; + whereClause += whereClause.includes('WHERE') + ? ` AND ${snapshotClause}` + : ` WHERE ${snapshotClause}`; } // Keyset constraint @@ -80,7 +80,9 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { decodedCursor.seq > BigInt(0) ? `(created_at < $${params.length - 2} OR (created_at = $${params.length - 1} AND seq < $${params.length}))` : `created_at < $${params.length - 2}`; - whereClause += whereClause.includes('WHERE') ? ` AND ${keysetClause}` : ` WHERE ${keysetClause}`; + whereClause += whereClause.includes('WHERE') + ? ` AND ${keysetClause}` + : ` WHERE ${keysetClause}`; } params.push(limit + 1); @@ -97,7 +99,10 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { let nextCursor: string | null = null; if (hasNext && disputes.length > 0) { const lastDispute = disputes[disputes.length - 1] as Record; - nextCursor = encodeCursor(new Date(lastDispute.created_at as string), BigInt((lastDispute.seq as string | number) ?? 0)); + nextCursor = encodeCursor( + new Date(lastDispute.created_at as string), + BigInt((lastDispute.seq as string | number) ?? 0), + ); } let totalAtSnapshot: number | undefined; @@ -120,7 +125,10 @@ export const listLoanDisputes = asyncHandler(async (req, res) => { `SELECT COUNT(*) as count FROM loan_disputes ${countWhereClause}`, countParams, ); - totalAtSnapshot = Number.parseInt((totalResult?.rows?.[0] as { count: string })?.count ?? '0', 10); + totalAtSnapshot = Number.parseInt( + (totalResult?.rows?.[0] as { count: string })?.count ?? '0', + 10, + ); } res.json({ From 4b8094e15c11472f2bfcbb2fc95be7092298c99a Mon Sep 17 00:00:00 2001 From: extolkom Date: Mon, 27 Jul 2026 19:24:09 -1200 Subject: [PATCH 4/5] fix(backend): add missing return statements in indexerController to fix TS7030 --- backend/src/controllers/indexerController.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/indexerController.ts b/backend/src/controllers/indexerController.ts index 51dc023f..c55be203 100644 --- a/backend/src/controllers/indexerController.ts +++ b/backend/src/controllers/indexerController.ts @@ -672,7 +672,7 @@ export const getRecentEvents = async (req: Request, res: Response) => { }); } catch (error) { logger.withContext().error('Failed to get recent events', { error }); - res.status(500).json({ + return res.status(500).json({ success: false, message: 'Failed to get recent events', }); @@ -683,7 +683,7 @@ export const listWebhookSubscriptions = async (_req: Request, res: Response) => try { const subscriptions = await webhookService.listSubscriptions(); - res.json({ + return res.json({ success: true, data: { subscriptions, @@ -691,7 +691,7 @@ export const listWebhookSubscriptions = async (_req: Request, res: Response) => }); } catch (error) { logger.withContext().error('Failed to list webhook subscriptions', { error }); - res.status(500).json({ + return res.status(500).json({ success: false, message: 'Failed to list webhook subscriptions', }); From c8489d4edae68b6a150c056f1212de4484095ae1 Mon Sep 17 00:00:00 2001 From: extolkom Date: Wed, 29 Jul 2026 14:43:46 -1200 Subject: [PATCH 5/5] fix(frontend): update repayment XDR unit test argument index --- frontend/src/app/utils/soroban.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/utils/soroban.test.ts b/frontend/src/app/utils/soroban.test.ts index a7210f7b..ede9a4d6 100644 --- a/frontend/src/app/utils/soroban.test.ts +++ b/frontend/src/app/utils/soroban.test.ts @@ -48,8 +48,10 @@ describe("buildUnsignedRepaymentXdr", () => { if (op.type === "invokeHostFunction") { const invokeArgs = op.func.invokeContract(); const args = invokeArgs.args(); - const amountVal = scValToNative(args[1]); + const loanIdVal = scValToNative(args[1]); + const amountVal = scValToNative(args[2]); + expect(loanIdVal).toBe(BigInt(loanId)); expect(amountVal).toBe(BigInt(inputAmount)); expect(amountVal).not.toBe(BigInt(inputAmount / 10)); }