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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ The backend exposes an interactive Swagger UI for exploring and testing API endp

Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`).

### Webhooks

RemitLend supports real-time event notifications via webhooks. See the
[Webhook Integration Guide](docs/webhooks.md) for details on subscribing,
event payloads, retry semantics, circuit-breaker behavior, and HMAC signature
verification.

- **Swagger UI**: [http://localhost:3001/docs](http://localhost:3001/docs)
- **OpenAPI JSON**: [http://localhost:3001/docs.json](http://localhost:3001/docs.json)

Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`).

## 🛠 Tech Stack

- **Blockchain**: [Stellar](https://stellar.org) (Soroban Smart Contracts)
Expand All @@ -66,7 +78,7 @@ Both endpoints are gated to non-production environments (`NODE_ENV !== "producti

1. **Clone the repository:**
```bash
git clone https://github.com/your-username/remitlend.git
git clone https://github.com/LabsCrypt/remitlend.git
cd remitlend
```

Expand Down
6 changes: 6 additions & 0 deletions backend/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,11 @@ module.exports = {
"no-useless-catch": "off",
},
},
{
files: ["src/utils/demo*.ts"],
rules: {
"no-console": "off",
},
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
pgm.addColumns("notifications", {
action_url: {
type: "varchar(500)",
notNull: false,
comment: "Deep-link URL to the relevant entity (loan, remittance, etc.)",
},
});
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropColumns("notifications", ["action_url"]);
};
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"start": "node dist/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
Expand Down
17 changes: 11 additions & 6 deletions backend/src/__tests__/adminDisputePagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ jest.unstable_mockModule("../db/connection.js", () => ({
getClient: jest.fn(),
}));

const { listLoanDisputes } = await import("../controllers/adminDisputeController.js");
const { listLoanDisputes } =
await import("../controllers/adminDisputeController.js");

const flushAsync = async (): Promise<void> =>
new Promise((resolve) => setImmediate(resolve));
Expand Down Expand Up @@ -86,7 +87,10 @@ describe("listLoanDisputes pagination", () => {
listLoanDisputes(req, res, next as unknown as NextFunction);
await flushAsync();

const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record<string, unknown>;
const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record<
string,
unknown
>;
expect(jsonCall.success).toBe(true);
const pageInfo = jsonCall.page_info as Record<string, unknown>;
expect(pageInfo.has_next).toBe(true);
Expand All @@ -111,16 +115,17 @@ describe("listLoanDisputes pagination", () => {
listLoanDisputes(req, res, next as unknown as NextFunction);
await flushAsync();

const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record<string, unknown>;
const jsonCall = (res.json as jest.Mock).mock.calls[0]?.[0] as Record<
string,
unknown
>;
const pageInfo = jsonCall.page_info as Record<string, unknown>;
// limit should be capped at 100
expect(pageInfo.limit).toBe(100);
});

it("filters by status correctly", async () => {
const rows = [
disputeRow(1, "resolved", "2026-05-28T10:00:00.000Z"),
];
const rows = [disputeRow(1, "resolved", "2026-05-28T10:00:00.000Z")];
mockQuery.mockResolvedValueOnce({ rows, rowCount: 1 });

const req = { query: { status: "resolved" } } as unknown as Request;
Expand Down
14 changes: 7 additions & 7 deletions backend/src/__tests__/apiKeyScopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("requireApiKey – scope support", () => {
next,
);
expect(next.calls.length).toBe(1);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});

it("is accepted on a route with admin:disputes scope", async () => {
Expand All @@ -72,7 +72,7 @@ describe("requireApiKey – scope support", () => {
next,
);
expect(next.calls.length).toBe(1);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});

it("is accepted on any admin scope (admin:loans)", async () => {
Expand All @@ -84,7 +84,7 @@ describe("requireApiKey – scope support", () => {
next,
);
expect(next.calls.length).toBe(1);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});
});

Expand All @@ -103,7 +103,7 @@ describe("requireApiKey – scope support", () => {
next,
);
expect(next.calls.length).toBe(1);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});

it("throws on a different scope (admin:loans)", async () => {
Expand Down Expand Up @@ -145,7 +145,7 @@ describe("requireApiKey – scope support", () => {
makeRes() as Response,
next,
);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});

it("accepts sec2 for admin:indexer", async () => {
Expand All @@ -156,7 +156,7 @@ describe("requireApiKey – scope support", () => {
makeRes() as Response,
next,
);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});

it("rejects sec1 for admin:indexer", async () => {
Expand All @@ -179,7 +179,7 @@ describe("requireApiKey – scope support", () => {
makeRes() as Response,
next,
);
expect(next.calls[0][0]).toBeUndefined();
expect(next.calls[0]![0]).toBeUndefined();
});
});

Expand Down
6 changes: 4 additions & 2 deletions backend/src/__tests__/apiV1Mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
getClient: jest.fn(),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

// ── notificationService mock ─────────────────────────────────────────────────
const mockGetNotificationsForUser = jest.fn();
const mockGetUnreadCount = jest.fn();
const mockGetNotificationsForUser =
jest.fn<(...args: unknown[]) => Promise<unknown[]>>();
const mockGetUnreadCount = jest.fn<(...args: unknown[]) => Promise<number>>();
const mockSubscribe = jest.fn();
jest.unstable_mockModule("../services/notificationService.js", () => ({
notificationService: {
Expand Down
32 changes: 28 additions & 4 deletions backend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, beforeAll } from "@jest/globals";
import { describe, it, expect, beforeAll } from "@jest/globals";
import request from "supertest";
import app from "../app.js";
import { Keypair } from "@stellar/stellar-sdk";
Expand Down Expand Up @@ -164,7 +164,15 @@ describe("Auth API", () => {
describe("Rate limiting", () => {
it("should return 429 after 10 challenge requests from same IP", async () => {
const keypair = Keypair.random();
let lastResponse: any;
let lastResponse: {
status: number;
body: { success: boolean };
headers: Record<string, string>;
} = undefined as unknown as {
status: number;
body: { success: boolean };
headers: Record<string, string>;
};
for (let i = 0; i < 11; i++) {
lastResponse = await request(app)
.post("/api/auth/challenge")
Expand All @@ -177,7 +185,15 @@ describe("Auth API", () => {

it("should return 429 and Retry-After after 5 login attempts from same IP", async () => {
const keypair = Keypair.random();
let lastResponse: any;
let lastResponse: {
status: number;
body: { success: boolean };
headers: Record<string, string>;
} = undefined as unknown as {
status: number;
body: { success: boolean };
headers: Record<string, string>;
};
for (let i = 0; i < 6; i++) {
lastResponse = await request(app)
.post("/api/auth/login")
Expand All @@ -194,7 +210,15 @@ describe("Auth API", () => {

it("should return 429 after 5 login attempts with same public key", async () => {
const keypair = Keypair.random();
let lastResponse: any;
let lastResponse: {
status: number;
body: { success: boolean };
headers: Record<string, string>;
} = undefined as unknown as {
status: number;
body: { success: boolean };
headers: Record<string, string>;
};
for (let i = 0; i < 6; i++) {
lastResponse = await request(app)
.post("/api/auth/login")
Expand Down
20 changes: 13 additions & 7 deletions backend/src/__tests__/cacheInvalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals";
* right key).
*/

const mockDelete = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
const mockSet = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
const mockGet = jest.fn<() => Promise<any>>().mockResolvedValue(null);
const mockDelete = jest
.fn<(key: string) => Promise<void>>()
.mockResolvedValue(undefined);
const mockSet = jest
.fn<(key: string, value: unknown) => Promise<void>>()
.mockResolvedValue(undefined);
const mockGet = jest
.fn<(key: string) => Promise<null>>()
.mockResolvedValue(null);

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
Expand Down Expand Up @@ -124,11 +130,11 @@ describe("cacheKeys helpers", () => {
[CacheKeys.borrowerLoans(BORROWER)]: { loans: [] },
};

mockGet.mockImplementation(async (key: unknown) => {
return (cache[key as string] as unknown) ?? null;
mockGet.mockImplementation(async (key: string) => {
return ((cache[key] as unknown) ?? null) as null;
});
mockDelete.mockImplementation(async (key: unknown) => {
delete cache[key as string];
mockDelete.mockImplementation(async (key: string) => {
delete cache[key];
});

// Confirm warm
Expand Down
53 changes: 29 additions & 24 deletions backend/src/__tests__/defaultChecker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,40 +31,45 @@ describe("DefaultChecker", () => {
.spyOn(logger, "warn")
.mockImplementation(() => logger as typeof logger);

(checker as any).acquireLock = async () => true;
(checker as any).releaseLock = async () => undefined;
(checker as any).assertConfigured = () => ({
(checker as unknown as Record<string, unknown>).acquireLock = async () =>
true;
(checker as unknown as Record<string, unknown>).releaseLock = async () =>
undefined;
(checker as unknown as Record<string, unknown>).assertConfigured = () => ({
signer: {},
server: {
getLatestLedger: async () => ({ sequence: 4321 }),
},
passphrase: "test-passphrase",
});
(checker as any).fetchOverdueStats = async () => ({
overdueCount: 2,
oldestDueLedger: 4200,
ledgersPastOldestDue: 121,
});
(checker as any).fetchOverdueLoanIds = async () => [101, 102];
(checker as unknown as Record<string, unknown>).fetchOverdueStats =
async () => ({
overdueCount: 2,
oldestDueLedger: 4200,
ledgersPastOldestDue: 121,
});
(checker as unknown as Record<string, unknown>).fetchOverdueLoanIds =
async () => [101, 102];

let submissionCount = 0;
(checker as any).submitCheckDefaults = async (
_server: unknown,
_signer: unknown,
_passphrase: string,
loanIds: number[],
) => {
submissionCount += 1;
if (submissionCount === 1) {
return new Promise<never>(() => undefined);
}
(checker as unknown as Record<string, unknown>).submitCheckDefaults =
async (
_server: unknown,
_signer: unknown,
_passphrase: string,
loanIds: number[],
) => {
submissionCount += 1;
if (submissionCount === 1) {
return new Promise<never>(() => undefined);
}

return {
loanIds,
txHash: "second-batch-hash",
submitStatus: "PENDING",
return {
loanIds,
txHash: "second-batch-hash",
submitStatus: "PENDING",
};
};
};

const result = await checker.checkOverdueLoans();

Expand Down
9 changes: 7 additions & 2 deletions backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const supportedWebhookEventTypes = [
"LoanRepaid",
"LoanDefaulted",
"CollateralLiquidated",
"LoanLiquidated",
"Deposit",
"Withdraw",
"YieldDistributed",
Expand Down Expand Up @@ -175,7 +176,11 @@ function makeRawEvent(params: {
case "LoanRequested":
return {
...base,
topic: [scSymbol("LoanRequested"), scAddress(borrower)],
topic: [
scSymbol("LoanRequested"),
scU32(params.loanId ?? 1),
scAddress(borrower),
],
value: scI128(params.amount ?? 500),
};
case "LoanApproved":
Expand Down Expand Up @@ -748,7 +753,7 @@ describe("EventIndexer", () => {
process.env.QUARANTINE_ALERT_THRESHOLD = "2";

mockQuery.mockImplementation(
async (sql: string, params: unknown[] = []) => {
async (sql: string, _params: unknown[] = []) => {
if (sql.includes("INSERT INTO quarantine_events")) {
return { rows: [], rowCount: 1 };
}
Expand Down
Loading
Loading