diff --git a/src/app/api/stellar/liquidity/__tests__/route.test.ts b/src/app/api/stellar/liquidity/__tests__/route.test.ts new file mode 100644 index 0000000..d3e8a7e --- /dev/null +++ b/src/app/api/stellar/liquidity/__tests__/route.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockGetCurrentUser, mockGetLiquidityPool } = vi.hoisted(() => ({ + mockGetCurrentUser: vi.fn(), + mockGetLiquidityPool: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getCurrentUser: mockGetCurrentUser, +})); + +vi.mock("@/lib/stellar", () => ({ + getLiquidityPool: mockGetLiquidityPool, +})); + +import { GET } from "../route"; + +function makeRequest(query: string) { + return new Request(`http://localhost/api/stellar/liquidity${query}`) as never; +} + +function fakeUser() { + return { id: "user-1", email: "user@example.com" }; +} + +beforeEach(() => { + mockGetCurrentUser.mockReset(); + mockGetLiquidityPool.mockReset(); + mockGetCurrentUser.mockResolvedValue(fakeUser()); +}); + +describe("GET /api/stellar/liquidity", () => { + it("returns the pool with a private, short-TTL Cache-Control", async () => { + mockGetLiquidityPool.mockResolvedValue({ + result: { pool: { id: "pool-1", reserves: [], totalShares: "1000" } }, + cached: false, + }); + + const response = await GET(makeRequest("?asset=XLM")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.pool.id).toBe("pool-1"); + expect(response.headers.get("Cache-Control")).toBe( + "private, max-age=60, stale-while-revalidate=120" + ); + }); + + it("uses the same Cache-Control whether the lib layer served it from cache or not", async () => { + mockGetLiquidityPool.mockResolvedValue({ + result: { pool: { id: "pool-1", reserves: [], totalShares: "1000" } }, + cached: true, + }); + + const response = await GET(makeRequest("?asset=XLM")); + + expect(response.headers.get("Cache-Control")).toBe( + "private, max-age=60, stale-while-revalidate=120" + ); + }); + + it("still caches a { pool: null, reason } answer - that's a stable, real result", async () => { + mockGetLiquidityPool.mockResolvedValue({ + result: { pool: null, reason: "No issuer configured for USDC" }, + cached: false, + }); + + const response = await GET(makeRequest("?asset=USDC")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.data.pool).toBeNull(); + expect(response.headers.get("Cache-Control")).toBe( + "private, max-age=60, stale-while-revalidate=120" + ); + }); + + it("rejects an unauthenticated request with no-store, without calling getLiquidityPool", async () => { + mockGetCurrentUser.mockResolvedValue(null); + + const response = await GET(makeRequest("?asset=XLM")); + + expect(response.status).toBe(401); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(mockGetLiquidityPool).not.toHaveBeenCalled(); + }); + + it("rejects a missing 'asset' param with no-store, without calling getLiquidityPool", async () => { + const response = await GET(makeRequest("")); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(mockGetLiquidityPool).not.toHaveBeenCalled(); + }); + + it("marks an unexpected getLiquidityPool failure as no-store", async () => { + mockGetLiquidityPool.mockRejectedValue(new Error("Horizon is down")); + + const response = await GET(makeRequest("?asset=XLM")); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.success).toBe(false); + expect(body.error).toBe("Horizon is down"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); +}); diff --git a/src/app/api/stellar/liquidity/route.ts b/src/app/api/stellar/liquidity/route.ts index e83b2dc..4669942 100644 --- a/src/app/api/stellar/liquidity/route.ts +++ b/src/app/api/stellar/liquidity/route.ts @@ -1,54 +1,44 @@ import { NextRequest } from "next/server"; -import { Asset } from "@stellar/stellar-sdk"; import { getCurrentUser } from "@/lib/auth"; -import { server } from "@/lib/stellar"; +import { getLiquidityPool } from "@/lib/stellar"; import { successResponse, errorResponse, unauthorizedResponse } from "@/lib/api-response"; +// getLiquidityPool() already caches per-asset for 60s (see +// LIQUIDITY_CACHE_TTL_MS in src/lib/stellar.ts), so the browser/CDN layer +// here matches that window. `private` (not `public`) - this route requires +// a session, so a shared/CDN cache must never serve one user's response to +// a different, unauthenticated request. +const LIQUIDITY_CACHE_CONTROL = "private, max-age=60, stale-while-revalidate=120"; + /** Real Horizon liquidity pool reserves for a given asset code (top pool by * reserve size). Returns null data if the asset has no configured issuer or * no pool exists - never a fabricated figure. */ export async function GET(request: NextRequest) { try { const user = await getCurrentUser(); - if (!user) return unauthorizedResponse(); + if (!user) { + const response = unauthorizedResponse(); + response.headers.set("Cache-Control", "no-store"); + return response; + } const { searchParams } = new URL(request.url); const assetCode = searchParams.get("asset"); - if (!assetCode) return errorResponse("asset query parameter is required", 400); - - const upper = assetCode.toUpperCase(); - let asset: Asset; - if (upper === "XLM") { - asset = Asset.native(); - } else { - const issuer = process.env[`STELLAR_${upper}_ISSUER`]; - if (!issuer) { - return successResponse({ pool: null, reason: `No issuer configured for ${upper}` }); - } - asset = new Asset(upper, issuer); + if (!assetCode) { + const response = errorResponse("asset query parameter is required", 400); + response.headers.set("Cache-Control", "no-store"); + return response; } - const pools = await server - .liquidityPools() - .forAssets(asset) - .limit(1) - .order("desc") - .call(); - - const top = pools.records[0]; - if (!top) { - return successResponse({ pool: null, reason: `No liquidity pool found for ${upper}` }); - } + const { result } = await getLiquidityPool(assetCode); - return successResponse({ - pool: { - id: top.id, - reserves: top.reserves.map((r) => ({ asset: r.asset, amount: r.amount })), - totalShares: top.total_shares, - }, - }); + const response = successResponse(result); + response.headers.set("Cache-Control", LIQUIDITY_CACHE_CONTROL); + return response; } catch (err: unknown) { console.error("Liquidity fetch error:", err); - return errorResponse(err instanceof Error ? err.message : "Failed to fetch liquidity", 500); + const response = errorResponse(err instanceof Error ? err.message : "Failed to fetch liquidity", 500); + response.headers.set("Cache-Control", "no-store"); + return response; } } diff --git a/src/lib/__tests__/stellar-liquidity-cache.test.ts b/src/lib/__tests__/stellar-liquidity-cache.test.ts new file mode 100644 index 0000000..28c8f65 --- /dev/null +++ b/src/lib/__tests__/stellar-liquidity-cache.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockCall, mockLiquidityPools } = vi.hoisted(() => { + const mockCall = vi.fn(); + const mockOrder = vi.fn(() => ({ call: mockCall })); + const mockLimit = vi.fn(() => ({ order: mockOrder })); + const mockForAssets = vi.fn(() => ({ limit: mockLimit })); + const mockLiquidityPools = vi.fn(() => ({ forAssets: mockForAssets })); + return { mockCall, mockLiquidityPools }; +}); + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: vi.fn().mockImplementation(() => ({ + liquidityPools: mockLiquidityPools, + })), + }, + }; +}); + +function poolRecord(id: string, shares = "1000") { + return { + id, + reserves: [ + { asset: "native", amount: "500.0000000" }, + { asset: "USDC:GISSUER", amount: "480.0000000" }, + ], + total_shares: shares, + }; +} + +// Each test imports a fresh copy of src/lib/stellar.ts (vi.resetModules() in +// beforeEach) so the module-private liquidityCache Map starts empty every +// time - without this, the cache from an earlier test would leak into the +// next one and every "does this actually skip Horizon" assertion would be +// meaningless (it'd pass for the wrong reason). +async function freshGetLiquidityPool() { + const mod = await import("../stellar"); + return mod.getLiquidityPool; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + delete process.env.STELLAR_USDC_ISSUER; + mockCall.mockResolvedValue({ records: [] }); +}); + +describe("getLiquidityPool", () => { + it("fetches from Horizon and returns the top pool for a known asset (XLM)", async () => { + mockCall.mockResolvedValueOnce({ records: [poolRecord("pool-xlm")] }); + const getLiquidityPool = await freshGetLiquidityPool(); + + const { result, cached } = await getLiquidityPool("xlm"); + + expect(cached).toBe(false); + expect(result.pool?.id).toBe("pool-xlm"); + expect(result.pool?.totalShares).toBe("1000"); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + + it("serves a second lookup for the same asset from cache without calling Horizon again", async () => { + mockCall.mockResolvedValueOnce({ records: [poolRecord("pool-xlm")] }); + const getLiquidityPool = await freshGetLiquidityPool(); + + const first = await getLiquidityPool("XLM"); + const second = await getLiquidityPool("xlm"); // different case, same asset + + expect(first.cached).toBe(false); + expect(second.cached).toBe(true); + expect(second.result.pool?.id).toBe("pool-xlm"); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + + it("caches independently per asset code", async () => { + mockCall + .mockResolvedValueOnce({ records: [poolRecord("pool-xlm")] }) + .mockResolvedValueOnce({ records: [] }); + process.env.STELLAR_USDC_ISSUER = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + const getLiquidityPool = await freshGetLiquidityPool(); + + const xlm = await getLiquidityPool("XLM"); + const usdc = await getLiquidityPool("USDC"); + + expect(xlm.result.pool?.id).toBe("pool-xlm"); + expect(usdc.result.pool).toBeNull(); + expect(usdc.result.reason).toMatch(/No liquidity pool found/); + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it("returns pool: null without ever calling Horizon when no issuer is configured", async () => { + const getLiquidityPool = await freshGetLiquidityPool(); + + const { result, cached } = await getLiquidityPool("NOTCONFIGURED"); + + expect(cached).toBe(false); + expect(result.pool).toBeNull(); + expect(result.reason).toMatch(/No issuer configured/); + expect(mockLiquidityPools).not.toHaveBeenCalled(); + }); + + it("also caches the 'no issuer configured' answer so it isn't re-derived every call", async () => { + const getLiquidityPool = await freshGetLiquidityPool(); + + await getLiquidityPool("NOTCONFIGURED"); + const second = await getLiquidityPool("NOTCONFIGURED"); + + expect(second.cached).toBe(true); + expect(second.result.pool).toBeNull(); + }); + + it("returns pool: null with a reason when Horizon has no pool for the asset", async () => { + mockCall.mockResolvedValueOnce({ records: [] }); + const getLiquidityPool = await freshGetLiquidityPool(); + + const { result } = await getLiquidityPool("XLM"); + + expect(result.pool).toBeNull(); + expect(result.reason).toMatch(/No liquidity pool found for XLM/); + }); +}); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 0b3a67b..92ce65f 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -193,3 +193,81 @@ export async function submitTransaction(signedXdr: string): Promise<{ }; } } + +// --------------------------------------------------------------------------- +// Liquidity pool lookups - short-TTL in-memory cache +// +// Pool reserves don't need per-request freshness, but every lookup used to +// hit Horizon's liquidityPools API live. Same pattern as the rate cache in +// src/lib/rates.ts: cache the resolved result per asset code for a short +// window so repeated requests (e.g. a dashboard polling every few seconds) +// don't each pay a real network round-trip to Horizon. +// --------------------------------------------------------------------------- + +const LIQUIDITY_CACHE_TTL_MS = 60_000; // 1 minute + +export interface LiquidityPoolResult { + pool: { + id: string; + reserves: { asset: string; amount: string }[]; + totalShares: string; + } | null; + reason?: string; +} + +interface LiquidityCacheEntry { + result: LiquidityPoolResult; + fetchedAt: number; +} + +/** In-memory liquidity cache: key = uppercased asset code. */ +const liquidityCache = new Map(); + +/** + * Look up the top liquidity pool for an asset code. + * + * Returns `{ pool: null, reason }` (never throws) for an asset with no + * configured issuer or no pool - same graceful behavior the route handler + * had inline before this cache existed. That "no pool" answer is itself + * cached too: it won't change until an issuer is configured or a pool is + * created, so there's no reason to re-ask Horizon for it every request. + */ +export async function getLiquidityPool( + assetCode: string +): Promise<{ result: LiquidityPoolResult; cached: boolean }> { + const upper = assetCode.toUpperCase(); + + const cached = liquidityCache.get(upper); + if (cached && Date.now() - cached.fetchedAt < LIQUIDITY_CACHE_TTL_MS) { + return { result: cached.result, cached: true }; + } + + let asset: Asset; + if (upper === "XLM") { + asset = Asset.native(); + } else { + const issuer = process.env[`STELLAR_${upper}_ISSUER`]; + if (!issuer) { + const result: LiquidityPoolResult = { pool: null, reason: `No issuer configured for ${upper}` }; + liquidityCache.set(upper, { result, fetchedAt: Date.now() }); + return { result, cached: false }; + } + asset = new Asset(upper, issuer); + } + + const pools = await server.liquidityPools().forAssets(asset).limit(1).order("desc").call(); + const top = pools.records[0]; + + const result: LiquidityPoolResult = top + ? { + pool: { + id: top.id, + reserves: top.reserves.map((r) => ({ asset: r.asset, amount: r.amount })), + totalShares: top.total_shares, + }, + } + : { pool: null, reason: `No liquidity pool found for ${upper}` }; + + liquidityCache.set(upper, { result, fetchedAt: Date.now() }); + return { result, cached: false }; +}