From c72b5ef469aee62d6194930505d6af9f00af056e Mon Sep 17 00:00:00 2001 From: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Date: Thu, 28 May 2026 00:48:23 +0700 Subject: [PATCH 1/2] Add pool share price endpoint --- backend/src/__tests__/poolSharePrice.test.ts | 154 +++++++++++++++++++ backend/src/app.ts | 1 + backend/src/config/swaggerSchemas.ts | 27 ++++ backend/src/controllers/poolController.ts | 118 +++++++++++--- backend/src/routes/poolRoutes.ts | 42 +++++ backend/src/schemas/poolSchemas.ts | 14 ++ backend/src/services/sorobanService.ts | 50 ++++++ 7 files changed, 385 insertions(+), 21 deletions(-) create mode 100644 backend/src/__tests__/poolSharePrice.test.ts diff --git a/backend/src/__tests__/poolSharePrice.test.ts b/backend/src/__tests__/poolSharePrice.test.ts new file mode 100644 index 00000000..151918d0 --- /dev/null +++ b/backend/src/__tests__/poolSharePrice.test.ts @@ -0,0 +1,154 @@ +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[]; rowCount: number }> + >(); +const mockGetPoolSharePrice = jest.fn<(token: string) => Promise>(); +const mockCacheGet = jest.fn<(key: string) => Promise>(); +const mockCacheSet = + jest.fn<(key: string, value: unknown, ttlSeconds?: number) => Promise>(); + +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>().mockResolvedValue("ok"), + }, +})); + +jest.unstable_mockModule("../services/sorobanService.js", () => ({ + sorobanService: { + ping: jest.fn<() => Promise>().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(); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 55d0812e..6b1dd709 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,7 @@ 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"; diff --git a/backend/src/config/swaggerSchemas.ts b/backend/src/config/swaggerSchemas.ts index 0e1fa37e..8c1c2f93 100644 --- a/backend/src/config/swaggerSchemas.ts +++ b/backend/src/config/swaggerSchemas.ts @@ -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: { diff --git a/backend/src/controllers/poolController.ts b/backend/src/controllers/poolController.ts index 2a308f22..b455fa8e 100644 --- a/backend/src/controllers/poolController.ts +++ b/backend/src/controllers/poolController.ts @@ -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) @@ -19,14 +29,9 @@ 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 { + 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) @@ -34,7 +39,7 @@ export const getPoolStats = asyncHandler( FROM contract_events WHERE event_type IN ('Deposit', 'Withdraw') `), - query(` + query(` SELECT COALESCE(COUNT(DISTINCT loan_id) FILTER ( WHERE event_type = 'LoanApproved' @@ -45,31 +50,102 @@ 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. diff --git a/backend/src/routes/poolRoutes.ts b/backend/src/routes/poolRoutes.ts index 720ae21b..139d1cdc 100644 --- a/backend/src/routes/poolRoutes.ts +++ b/backend/src/routes/poolRoutes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { getPoolStats, + getPoolSharePrice, getDepositorPortfolio, depositToPool, withdrawFromPool, @@ -17,6 +18,7 @@ import { idempotencyMiddleware } from "../middleware/idempotency.js"; import { addressParamSchema } from "../schemas/stellarSchemas.js"; import { buildPoolTransactionSchema, + poolSharePriceParamSchema, submitTxSchema, } from "../schemas/poolSchemas.js"; @@ -51,6 +53,46 @@ router.get( getPoolStats, ); +/** + * @swagger + * /pool/{token}/share-price: + * get: + * summary: Get current lending pool share price for a token + * description: > + * Reads the LendingPool `get_share_price(token)` value and returns both + * the raw 1e6-scaled value and a human-readable ratio. The response also + * includes current aggregate utilization and the short cache TTL. + * tags: [Pool] + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: token + * required: true + * schema: + * type: string + * description: Stellar token address or contract address + * responses: + * 200: + * description: Pool share price retrieved successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PoolSharePriceResponse' + * 401: + * description: Missing or invalid Bearer token + * 403: + * description: Lender role or read:pool scope required + */ +router.get( + "/:token/share-price", + requireJwtAuth, + requireLender, + requireScopes("read:pool"), + validate(poolSharePriceParamSchema), + getPoolSharePrice, +); + /** * @swagger * /pool/depositor/{address}: diff --git a/backend/src/schemas/poolSchemas.ts b/backend/src/schemas/poolSchemas.ts index bf61a895..1009a490 100644 --- a/backend/src/schemas/poolSchemas.ts +++ b/backend/src/schemas/poolSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { Address } from "@stellar/stellar-sdk"; import { stellarAddressSchema } from "./stellarSchemas.js"; import { submitTxSchema, positiveAmountSchema } from "./loanSchemas.js"; @@ -8,4 +9,17 @@ export const buildPoolTransactionSchema = z.object({ amount: positiveAmountSchema, }); +export const poolSharePriceParamSchema = z.object({ + params: z.object({ + token: z.string().min(1, "Token address is required").refine((value) => { + try { + Address.fromString(value); + return true; + } catch { + return false; + } + }, "Invalid Stellar token address format"), + }), +}); + export { submitTxSchema }; diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index d88a65c1..5334a3f0 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -954,6 +954,56 @@ class SorobanService { return balance; } + /** + * Reads the current scaled LP share price for a token from the LendingPool. + * The contract returns a value scaled by 1e6. + */ + async getPoolSharePrice(tokenAddress: string): Promise { + const server = this.getRpcServer(); + const contractId = this.getLendingPoolContractId(); + const passphrase = this.getNetworkPassphrase(); + const source = this.getScoreReadSourceKeypair(); + + const account = await server.getAccount(source.publicKey()); + const tokenScVal = nativeToScVal(Address.fromString(tokenAddress), { + type: "address", + }); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: passphrase, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: contractId, + function: "get_share_price", + args: [tokenScVal], + }), + ) + .setTimeout(30) + .build(); + + const simulation = await server.simulateTransaction(tx); + if ("error" in simulation) { + throw AppError.internal( + `Failed to simulate pool share price: ${simulation.error}`, + ); + } + + const retval = simulation.result?.retval; + if (!retval) { + throw AppError.internal("No share price returned by lending pool"); + } + + const nativeSharePrice = scValToNative(retval); + const sharePrice = Number(nativeSharePrice); + if (!Number.isFinite(sharePrice)) { + throw AppError.internal("Invalid on-chain share price returned"); + } + + return sharePrice; + } + /** * Returns score adjustment constants for indexing. * Values are sourced from environment variables so they stay in sync From d4d3610803f905724fc041433a821dddd061879e Mon Sep 17 00:00:00 2001 From: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Date: Thu, 28 May 2026 01:20:58 +0700 Subject: [PATCH 2/2] Format pool share price changes --- backend/src/__tests__/poolSharePrice.test.ts | 4 +++- backend/src/app.ts | 5 +---- backend/src/controllers/poolController.ts | 6 +----- backend/src/schemas/poolSchemas.ts | 19 +++++++++++-------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/backend/src/__tests__/poolSharePrice.test.ts b/backend/src/__tests__/poolSharePrice.test.ts index 151918d0..a33a8c28 100644 --- a/backend/src/__tests__/poolSharePrice.test.ts +++ b/backend/src/__tests__/poolSharePrice.test.ts @@ -20,7 +20,9 @@ const mockQuery = const mockGetPoolSharePrice = jest.fn<(token: string) => Promise>(); const mockCacheGet = jest.fn<(key: string) => Promise>(); const mockCacheSet = - jest.fn<(key: string, value: unknown, ttlSeconds?: number) => Promise>(); + jest.fn< + (key: string, value: unknown, ttlSeconds?: number) => Promise + >(); jest.unstable_mockModule("../db/connection.js", () => ({ default: { query: mockQuery }, diff --git a/backend/src/app.ts b/backend/src/app.ts index 6b1dd709..f1c42e58 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -29,10 +29,7 @@ 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"; diff --git a/backend/src/controllers/poolController.ts b/backend/src/controllers/poolController.ts index b455fa8e..1ff50e84 100644 --- a/backend/src/controllers/poolController.ts +++ b/backend/src/controllers/poolController.ts @@ -133,11 +133,7 @@ export const getPoolSharePrice = asyncHandler( cacheTtlSeconds: POOL_SHARE_PRICE_CACHE_TTL_SECONDS, }; - await cacheService.set( - cacheKey, - data, - POOL_SHARE_PRICE_CACHE_TTL_SECONDS, - ); + await cacheService.set(cacheKey, data, POOL_SHARE_PRICE_CACHE_TTL_SECONDS); res.json({ success: true, diff --git a/backend/src/schemas/poolSchemas.ts b/backend/src/schemas/poolSchemas.ts index 1009a490..cc08d862 100644 --- a/backend/src/schemas/poolSchemas.ts +++ b/backend/src/schemas/poolSchemas.ts @@ -11,14 +11,17 @@ export const buildPoolTransactionSchema = z.object({ export const poolSharePriceParamSchema = z.object({ params: z.object({ - token: z.string().min(1, "Token address is required").refine((value) => { - try { - Address.fromString(value); - return true; - } catch { - return false; - } - }, "Invalid Stellar token address format"), + token: z + .string() + .min(1, "Token address is required") + .refine((value) => { + try { + Address.fromString(value); + return true; + } catch { + return false; + } + }, "Invalid Stellar token address format"), }), });