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
156 changes: 156 additions & 0 deletions backend/src/__tests__/poolSharePrice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import request from "supertest";
import { jest } from "@jest/globals";
import { Keypair } from "@stellar/stellar-sdk";
import { generateJwtToken } from "../services/authService.js";

const lenderPublicKey = Keypair.random().publicKey();
const borrowerPublicKey = Keypair.random().publicKey();
const tokenAddress = Keypair.random().publicKey();

process.env.JWT_SECRET = "test-jwt-secret-min-32-chars-long!!";
process.env.LENDER_WALLETS = lenderPublicKey;

const mockQuery =
jest.fn<
(
sql: string,
params?: unknown[],
) => Promise<{ rows: Record<string, unknown>[]; rowCount: number }>
>();
const mockGetPoolSharePrice = jest.fn<(token: string) => Promise<number>>();
const mockCacheGet = jest.fn<(key: string) => Promise<unknown | null>>();
const mockCacheSet =
jest.fn<
(key: string, value: unknown, ttlSeconds?: number) => Promise<void>
>();

jest.unstable_mockModule("../db/connection.js", () => ({
default: { query: mockQuery },
query: mockQuery,
getClient: jest.fn(),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
get: mockCacheGet,
set: mockCacheSet,
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: {
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
getScoreConfig: jest.fn(() => ({
repaymentDelta: 20,
defaultPenalty: 50,
latePenalty: 5,
})),
getPoolSharePrice: mockGetPoolSharePrice,
},
}));

const { default: app } = await import("../app.js");

const bearer = (publicKey: string) => ({
Authorization: `Bearer ${generateJwtToken(publicKey)}`,
});

function mockAggregateStats(): void {
mockQuery.mockImplementation(async (sql) => {
if (sql.includes("Deposit")) {
return {
rows: [{ total_deposits: "1000" }],
rowCount: 1,
};
}

return {
rows: [{ total_outstanding: "250", active_loans_count: "2" }],
rowCount: 1,
};
});
}

describe("GET /api/pool/:token/share-price", () => {
beforeEach(() => {
jest.clearAllMocks();
mockCacheGet.mockResolvedValue(null);
mockCacheSet.mockResolvedValue(undefined);
mockGetPoolSharePrice.mockResolvedValue(1_250_000);
mockAggregateStats();
});

afterAll(() => {
delete process.env.JWT_SECRET;
delete process.env.LENDER_WALLETS;
});

it("returns the scaled share price, ratio, utilization, and cache metadata", async () => {
const response = await request(app)
.get(`/api/pool/${tokenAddress}/share-price`)
.set(bearer(lenderPublicKey))
.expect(200);

expect(mockGetPoolSharePrice).toHaveBeenCalledWith(tokenAddress);
expect(mockCacheSet).toHaveBeenCalledWith(
`pool:share-price:${tokenAddress}`,
expect.objectContaining({
token: tokenAddress,
scaledSharePrice: 1_250_000,
sharePriceScale: 1_000_000,
sharePriceRatio: 1.25,
utilizationRate: 0.25,
cacheTtlSeconds: 30,
}),
30,
);
expect(response.body).toEqual({
success: true,
data: {
token: tokenAddress,
scaledSharePrice: 1_250_000,
sharePriceScale: 1_000_000,
sharePriceRatio: 1.25,
utilizationRate: 0.25,
cacheTtlSeconds: 30,
},
});
});

it("uses cached token-scoped share price data when available", async () => {
const cached = {
token: tokenAddress,
scaledSharePrice: 1_000_000,
sharePriceScale: 1_000_000,
sharePriceRatio: 1,
utilizationRate: 0,
cacheTtlSeconds: 30,
};
mockCacheGet.mockResolvedValueOnce(cached);

const response = await request(app)
.get(`/api/pool/${tokenAddress}/share-price`)
.set(bearer(lenderPublicKey))
.expect(200);

expect(mockGetPoolSharePrice).not.toHaveBeenCalled();
expect(mockQuery).not.toHaveBeenCalled();
expect(mockCacheSet).not.toHaveBeenCalled();
expect(response.body).toEqual({
success: true,
data: cached,
});
});

it("rejects authenticated borrowers without read:pool access", async () => {
await request(app)
.get(`/api/pool/${tokenAddress}/share-price`)
.set(bearer(borrowerPublicKey))
.expect(403);

expect(mockGetPoolSharePrice).not.toHaveBeenCalled();
});
});
6 changes: 2 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ import userRoutes from "./routes/userRoutes.js";
import notificationsRoutes from "./routes/notificationsRoutes.js";
import eventRoutes from "./routes/eventRoutes.js";
import remittanceRoutes from "./routes/remittanceRoutes.js";
import transactionRoutes from "./routes/transactionRoutes.js";
import { requireApiKey } from "./middleware/auth.js";
import { globalRateLimiter } from "./middleware/rateLimiter.js";
import { errorHandler } from "./middleware/errorHandler.js";
import {
metricsHandler,
metricsMiddleware,
} from "./middleware/metrics.js";
import { metricsHandler, metricsMiddleware } from "./middleware/metrics.js";
import { requestLogger } from "./middleware/requestLogger.js";
import { requestIdMiddleware } from "./middleware/requestId.js";
import { asyncHandler } from "./utils/asyncHandler.js";
Expand Down
27 changes: 27 additions & 0 deletions backend/src/config/swaggerSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,33 @@ export const swaggerSchemas = {
},
required: ["success", "data"],
},
PoolSharePrice: {
type: "object",
properties: {
token: { type: "string" },
scaledSharePrice: { type: "integer", example: 1250000 },
sharePriceScale: { type: "integer", example: 1000000 },
sharePriceRatio: { type: "number", example: 1.25 },
utilizationRate: { type: "number", example: 0.25 },
cacheTtlSeconds: { type: "integer", example: 30 },
},
required: [
"token",
"scaledSharePrice",
"sharePriceScale",
"sharePriceRatio",
"utilizationRate",
"cacheTtlSeconds",
],
},
PoolSharePriceResponse: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: { $ref: "#/components/schemas/PoolSharePrice" },
},
required: ["success", "data"],
},
DepositorPortfolio: {
type: "object",
properties: {
Expand Down
114 changes: 93 additions & 21 deletions backend/src/controllers/poolController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ import { AppError } from "../errors/AppError.js";
import { ErrorCode } from "../errors/errorCodes.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { sorobanService } from "../services/sorobanService.js";
import { cacheService } from "../services/cacheService.js";
import logger from "../utils/logger.js";

const ANNUAL_APY = 0.08; // 8% annual yield paid to depositors
const SHARE_PRICE_SCALE = 1_000_000;
const POOL_SHARE_PRICE_CACHE_TTL_SECONDS = 30;

interface AggregatePoolStats {
totalDeposits: number;
totalOutstanding: number;
utilizationRate: number;
activeLoansCount: number;
}

/**
* Parse a database value to a finite number, returning `fallback` (default 0)
Expand All @@ -19,22 +29,17 @@ function safeFloat(value: unknown, fallback = 0): number {
return Number.isFinite(n) ? n : fallback;
}

/**
* GET /api/pool/stats
* Returns aggregate pool statistics for the lender dashboard.
*/
export const getPoolStats = asyncHandler(
async (_req: Request, res: Response) => {
const [depositResult, loanResult] = await Promise.all([
query(`
async function readAggregatePoolStats(): Promise<AggregatePoolStats> {
const [depositResult, loanResult] = await Promise.all([
query(`
SELECT
COALESCE(SUM(CASE WHEN event_type = 'Deposit' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN event_type = 'Withdraw' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0)
AS total_deposits
FROM contract_events
WHERE event_type IN ('Deposit', 'Withdraw')
`),
query(`
query(`
SELECT
COALESCE(COUNT(DISTINCT loan_id) FILTER (
WHERE event_type = 'LoanApproved'
Expand All @@ -45,31 +50,98 @@ export const getPoolStats = asyncHandler(
FROM contract_events
WHERE event_type IN ('LoanApproved', 'LoanRepaid')
`),
]);
]);

const totalDeposits = safeFloat(depositResult.rows[0]?.total_deposits);
const totalOutstanding = safeFloat(loanResult.rows[0]?.total_outstanding);
const activeLoansCount = Math.trunc(
safeFloat(loanResult.rows[0]?.active_loans_count),
);

const utilizationRate =
totalDeposits > 0 ? Math.min(totalOutstanding / totalDeposits, 1) : 0;

return {
totalDeposits,
totalOutstanding,
utilizationRate: parseFloat(utilizationRate.toFixed(4)),
activeLoansCount,
};
}

const totalDeposits = safeFloat(depositResult.rows[0]?.total_deposits);
const totalOutstanding = safeFloat(loanResult.rows[0]?.total_outstanding);
const activeLoansCount = Math.trunc(
safeFloat(loanResult.rows[0]?.active_loans_count),
);
function poolSharePriceCacheKey(token: string): string {
return `pool:share-price:${token}`;
}

const utilizationRate =
totalDeposits > 0 ? Math.min(totalOutstanding / totalDeposits, 1) : 0;
/**
* GET /api/pool/stats
* Returns aggregate pool statistics for the lender dashboard.
*/
export const getPoolStats = asyncHandler(
async (_req: Request, res: Response) => {
const stats = await readAggregatePoolStats();

res.json({
success: true,
data: {
totalDeposits,
totalOutstanding,
utilizationRate: parseFloat(utilizationRate.toFixed(4)),
totalDeposits: stats.totalDeposits,
totalOutstanding: stats.totalOutstanding,
utilizationRate: stats.utilizationRate,
apy: ANNUAL_APY,
activeLoansCount,
activeLoansCount: stats.activeLoansCount,
poolTokenAddress: process.env.POOL_TOKEN_ADDRESS,
},
});
},
);

/**
* GET /api/pool/:token/share-price
* Returns the current scaled LP share price for a token plus pool utilization.
*/
export const getPoolSharePrice = asyncHandler(
async (req: Request, res: Response) => {
const token = req.params.token;
if (typeof token !== "string") {
throw AppError.badRequest("Token address is required");
}

const cacheKey = poolSharePriceCacheKey(token);
const cached = await cacheService.get(cacheKey);

if (cached) {
res.json({
success: true,
data: cached,
});
return;
}

const [scaledSharePrice, stats] = await Promise.all([
sorobanService.getPoolSharePrice(token),
readAggregatePoolStats(),
]);

const data = {
token,
scaledSharePrice,
sharePriceScale: SHARE_PRICE_SCALE,
sharePriceRatio: parseFloat(
(scaledSharePrice / SHARE_PRICE_SCALE).toFixed(6),
),
utilizationRate: stats.utilizationRate,
cacheTtlSeconds: POOL_SHARE_PRICE_CACHE_TTL_SECONDS,
};

await cacheService.set(cacheKey, data, POOL_SHARE_PRICE_CACHE_TTL_SECONDS);

res.json({
success: true,
data,
});
},
);

/**
* GET /api/pool/depositor/:address
* Returns portfolio details for a specific depositor address.
Expand Down
Loading
Loading