Skip to content
Open
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
110 changes: 110 additions & 0 deletions src/app/api/stellar/liquidity/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
58 changes: 24 additions & 34 deletions src/app/api/stellar/liquidity/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
125 changes: 125 additions & 0 deletions src/lib/__tests__/stellar-liquidity-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@stellar/stellar-sdk")>("@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/);
});
});
78 changes: 78 additions & 0 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, LiquidityCacheEntry>();

/**
* 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 };
}