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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ CACHE_TTL_BASE_FEE_MS=5000
CACHE_TTL_VALIDATORS_MS=300000
CACHE_TTL_ASSET_MS=30000
CACHE_TTL_ASSET_PRICE_MS=5000
CACHE_TTL_TRUSTLINES_MS=15000

# API Key authentication
# If set to "true", the API key middleware will require a valid API key
Expand Down
19 changes: 13 additions & 6 deletions src/config/cacheConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
* env configuration.
*
* Environment variables (all in milliseconds):
* CACHE_TTL_NETWORK_STATUS_MS — /network-status (default: 5 000 ms)
* CACHE_TTL_FEE_ESTIMATE_MS — /fee-estimate & surge-status (default: 5 000 ms)
* CACHE_TTL_BASE_FEE_MS — /network/base-fee (default: 5 000 ms)
* CACHE_TTL_VALIDATORS_MS — /network/validators (default: 300 000 ms)
* CACHE_TTL_ASSET_MS — /asset/:code/:issuer (default: 30 000 ms)
* CACHE_TTL_ASSET_PRICE_MS — /asset price endpoint (default: 5 000 ms)
* CACHE_TTL_NETWORK_STATUS_MS — /network-status (default: 5 000 ms)
* CACHE_TTL_FEE_ESTIMATE_MS — /fee-estimate & surge-status (default: 5 000 ms)
* CACHE_TTL_BASE_FEE_MS — /network/base-fee (default: 5 000 ms)
* CACHE_TTL_VALIDATORS_MS — /network/validators (default: 300 000 ms)
* CACHE_TTL_ASSET_MS — /asset/:code/:issuer (default: 30 000 ms)
* CACHE_TTL_ASSET_PRICE_MS — /asset price endpoint (default: 5 000 ms)
* CACHE_TTL_TRUSTLINES_MS — /account/:id/trustlines (default: 15 000 ms)
*
* The legacy CACHE_TTL_MS variable is still respected as a global fallback so
* existing deployments are not broken.
Expand Down Expand Up @@ -61,6 +62,12 @@ const cacheTTL = {
process.env.CACHE_TTL_ASSET_PRICE_MS,
globalFallbackMs
),

/** /account/:id/trustlines — trustline data is stable; 15 s default */
trustlines: msToSeconds(
process.env.CACHE_TTL_TRUSTLINES_MS,
15000
),
};

module.exports = cacheTTL;
29 changes: 26 additions & 3 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const {

const { parsePaginationParams } = require("../utils/pagination");
const { buildAccountAgeResponse } = require("../utils/accountAge");
const cacheService = require("../services/cache");
const cacheTTL = require("../config/cacheConfig");


const axios = require("axios");
Expand Down Expand Up @@ -124,6 +126,20 @@ router.get("/:id/trustlines", async (req, res, next) => {
const { id } = req.params;
validateAccountId(id);

const fresh = req.query.fresh === "true";
const { assetCode } = req.query;
const cacheKey = `trustlines:${id}`;

// Only read from cache for unfiltered requests; filtered results are subsets
// of the full list and must not be served from the full-list cache entry.
if (!fresh && !assetCode) {
const cached = cacheService.get(cacheKey);
if (cached) {
res.set("X-Cache", "HIT");
return success(res, cached);
}
}

const account = await server.loadAccount(id);

const issuerCache = new Map();
Expand All @@ -139,20 +155,27 @@ router.get("/:id/trustlines", async (req, res, next) => {
),
);

const { assetCode } = req.query;
if (assetCode) {
const filterLower = assetCode.toLowerCase();
trustlines = trustlines.filter(
(t) => t.asset.code.toLowerCase() === filterLower,
);
}

return success(res, {
const data = {
items: trustlines,
total: trustlines.length,
limit: null,
cursor: null,
});
};

// Only cache unfiltered results (assetCode filter produces a subset)
if (!assetCode) {
cacheService.set(cacheKey, data, cacheTTL.trustlines);
}

res.set("X-Cache", "MISS");
return success(res, data);
} catch (err) {
handleAccountNotFound(err, next, req.params.id);
}
Expand Down
2 changes: 2 additions & 0 deletions tests/account.trustlineFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.mock("../src/config/stellar", () => ({

const app = require("../src/index");
const { server } = require("../src/config/stellar");
const cacheService = require("../src/services/cache");

const ACCOUNT_ID = "GBB67CMSCMGPROSFIVENXMRQ3KJWELDIUYITQI7YCKMSOPR2SNZB5NQ5";
const ISSUER_A = "GD62SRSGF4XVUHZYLZNAMTUTOH7CKJ2WZWX6HNUTZ4G5SFKNAM6G2OXD";
Expand Down Expand Up @@ -62,6 +63,7 @@ function makeAccount() {
describe("GET /account/:id/trustlines?assetCode=", () => {
beforeEach(() => {
jest.clearAllMocks();
cacheService.flush();
server.loadAccount.mockImplementation(async (id) => {
if (id === ACCOUNT_ID) return makeAccount();
return { id, home_domain: null };
Expand Down
2 changes: 2 additions & 0 deletions tests/accountTrustlines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jest.mock("../src/config/stellar", () => ({

const app = require("../src/index");
const { server } = require("../src/config/stellar");
const cacheService = require("../src/services/cache");

const ACCOUNT_ID =
"GDU5LH56CZ7NVKRHYI72QVJC6BS7GAYEIO34HDMICG3H5NSFJJJFHFWL";
Expand Down Expand Up @@ -70,6 +71,7 @@ function accountWithBalances() {
describe("GET /account/:id/trustlines", () => {
beforeEach(() => {
jest.clearAllMocks();
cacheService.flush();
});

it("returns non-native balances with resolved stellar.toml metadata", async () => {
Expand Down
67 changes: 67 additions & 0 deletions tests/cache.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,73 @@ describe("Cache Integration", () => {
});
});

describe("GET /account/:id/trustlines", () => {
const accountId =
"GDU5LH56CZ7NVKRHYI72QVJC6BS7GAYEIO34HDMICG3H5NSFJJJFHFWL";

it("should return X-Cache: MISS on first request", async () => {
const res = await request(app).get(`/account/${accountId}/trustlines`);
// A 404 from Horizon still goes through the error path and does not set
// X-Cache, but a successful response must be MISS on the first call.
// We verify that the header is absent (MISS path or error) — confirming
// no stale HIT was returned.
expect(res.headers["x-cache"]).not.toBe("HIT");
});

it("should return X-Cache: HIT when the cache is pre-seeded", async () => {
// Pre-seed the cache directly — avoids a live Horizon dependency
const cachedPayload = { items: [], total: 0, limit: null, cursor: null };
cacheService.set(`trustlines:${accountId}`, cachedPayload, 15);

const res = await request(app).get(`/account/${accountId}/trustlines`);
expect(res.headers["x-cache"]).toBe("HIT");
expect(res.statusCode).toBe(200);
});

it("should return X-Cache: MISS and bypass cache when ?fresh=true is used", async () => {
// Pre-seed the cache — fresh=true must ignore it
const cachedPayload = { items: [], total: 0, limit: null, cursor: null };
cacheService.set(`trustlines:${accountId}`, cachedPayload, 15);

const res = await request(app).get(
`/account/${accountId}/trustlines?fresh=true`,
);
// fresh=true bypasses the cache; the request goes to Horizon and
// X-Cache will be MISS (200) or absent (404), but never HIT.
expect(res.headers["x-cache"]).not.toBe("HIT");
});

it("should cache results independently per account ID", async () => {
const accountId2 =
"GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN";

// Seed only accountId, not accountId2
const cachedPayload = { items: [], total: 0, limit: null, cursor: null };
cacheService.set(`trustlines:${accountId}`, cachedPayload, 15);

// accountId serves from cache
const res1 = await request(app).get(`/account/${accountId}/trustlines`);
expect(res1.headers["x-cache"]).toBe("HIT");

// accountId2 must NOT be a cache hit — the key is different
// (result will be MISS or error, but never HIT from accountId's entry)
const res2 = await request(app).get(`/account/${accountId2}/trustlines`);
expect(res2.headers["x-cache"]).not.toBe("HIT");
});

it("should not serve X-Cache: HIT for a filtered ?assetCode request", async () => {
// Seed the full-list cache entry
const cachedPayload = { items: [], total: 0, limit: null, cursor: null };
cacheService.set(`trustlines:${accountId}`, cachedPayload, 15);

// Filtered requests skip the cache entirely and always hit Horizon
const res = await request(app).get(
`/account/${accountId}/trustlines?assetCode=USDC`,
);
expect(res.headers["x-cache"]).not.toBe("HIT");
});
});

describe("ETag Support", () => {
describe("GET /network-status with ETag", () => {
it("should return an ETag header on successful response", async () => {
Expand Down