Skip to content
Merged
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
9 changes: 9 additions & 0 deletions README.me
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,15 @@ Errors are similarly wrapped with:

This consistent contract helps client applications avoid ad hoc parsing logic.

Recent updates in this work session
----------------------------------

- Added optional `?minBalance` and `?maxBalance` query parameters to `GET /asset/:code/:issuer/holders` for filtering asset holders by balance range.
- Implemented short-term caching for `GET /account/:id/pool-positions` with a default 15 second TTL, configurable via `CACHE_TTL_POOL_POSITIONS_MS`.
- Added `X-Cache` response headers to indicate cache status and support `?fresh=true` to bypass cached pool position data.
- Cleaned and stabilized route imports in `src/routes/account.js` and added request validation helpers for account and pagination endpoints.
- Documented the behavior and usage of the updated asset and account endpoints in the README.

### `src/utils/validators.js`

Input validation is critical when accepting public keys, asset codes, and numeric parameters. This module centralizes validation rules for:
Expand Down
6 changes: 6 additions & 0 deletions src/config/cacheConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ const cacheTTL = {
30000
),

/** /account/:id/pool-positions — changes only when joining or exiting a liquidity pool */
poolPositions: msToSeconds(
process.env.CACHE_TTL_POOL_POSITIONS_MS,
15000
),

/** /account/:id/transaction-count — changes only on new submissions */
transactionCount: msToSeconds(
process.env.CACHE_TTL_TX_COUNT_MS,
Expand Down
43 changes: 26 additions & 17 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ const registerParamValidation = require("../middleware/validateRouteParams");
registerParamValidation(router);

const { buildAccountAgeResponse } = require("../utils/accountAge");
const cacheTTL = require("../config/cacheConfig");
const { validateAccountId, validateLimit } = require("../utils/validators");
const { parsePaginationParams } = require("../utils/pagination");
const { validateEffectType } = require("../utils/effectTypes");

const axios = require("axios");
const { Asset } = require("@stellar/stellar-sdk");
const { normalizeAsset, normalizeAssetFromString } = require("../utils/asset");
Expand All @@ -24,18 +27,6 @@ const { validateEffectType } = require("../utils/effectTypes");
// Cache TTL for account endpoint responses (in seconds)
const CACHE_TTL_ACCOUNT = parseInt(process.env.CACHE_TTL_ACCOUNT_MS, 10) / 1000 || 10;

function validateLimit(limit, max = 200) {
const n = Number(limit);
if (!Number.isInteger(n) || n <= 0 || n > max) {
const err = new Error(`limit must be between 1 and ${max}`);
err.status = 400;
err.field = "limit";
err.receivedValue = String(limit);
throw err;
}
return n;
}

function normalizeSignerType(type) {
const normalized = String(type || "").toLowerCase();

Expand Down Expand Up @@ -2104,19 +2095,33 @@ router.get("/:id/pool-positions", async (req, res, next) => {
const { id } = req.params;
validateAccountId(id);

const fresh = req.query.fresh === "true";
const cacheKey = `pool-positions:${id}`;

if (!fresh) {
const cached = cacheService.get(cacheKey);
if (cached) {
res.set("X-Cache", "HIT");
return success(res, cached);
}
}

const account = await server.loadAccount(id);

const poolShareTrustlines = (account.balances || []).filter(
(balance) => balance.asset_type === "liquidity_pool_shares",
);

if (poolShareTrustlines.length === 0) {
return success(res, {
const data = {
items: [],
total: 0,
limit: null,
cursor: null,
});
};
cacheService.set(cacheKey, data, cacheTTL.poolPositions);
res.set("X-Cache", "MISS");
return success(res, data);
}

const poolDetailsPromises = poolShareTrustlines.map((trustline) =>
Expand Down Expand Up @@ -2174,12 +2179,16 @@ router.get("/:id/pool-positions", async (req, res, next) => {
});
}

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

cacheService.set(cacheKey, data, cacheTTL.poolPositions);
res.set("X-Cache", "MISS");
return success(res, data);
} catch (err) {
handleAccountNotFound(err, next, req.params.id);
}
Expand Down
107 changes: 86 additions & 21 deletions src/routes/asset.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
const { success } = require("../utils/response");
const { assetHoldersRateLimiter } = require("../middleware/rateLimiter");
const normalizeAssetCode = require("../middleware/normalizeAssetCode");
const { validateAccountId, validateAssetCode, validateAsset, validateLimit } = require("../utils/validators");

Check warning on line 11 in src/routes/asset.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'validateAccountId' is assigned a value but never used
const { parsePaginationParams } = require("../utils/pagination");
const { makeAssetNotFoundError } = require("../utils/errors");
const cacheTTL = require("../config/cacheConfig");
Expand Down Expand Up @@ -43,17 +43,57 @@
};
}

function isValidNonNegativeDecimal(value) {
if (value === undefined || value === null) return false;
const normalized = String(value).trim();
return /^\d+(?:\.\d+)?$/.test(normalized);
}

function parseNonNegativeDecimalQueryParam(rawValue, fieldName) {
if (rawValue === undefined) return null;
const value = String(rawValue).trim();

if (value === "" || !isValidNonNegativeDecimal(value)) {
const err = new Error(
`Query parameter '${fieldName}': must be a non-negative decimal number.`,
);
err.isValidation = true;
err.status = 400;
err.field = fieldName;
err.receivedValue = rawValue !== undefined ? String(rawValue) : rawValue;
err.expectedFormat = "non-negative decimal string, e.g. 123.45";
throw err;
}

const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
const err = new Error(
`Query parameter '${fieldName}': must be a non-negative decimal number.`,
);
err.isValidation = true;
err.status = 400;
err.field = fieldName;
err.receivedValue = rawValue !== undefined ? String(rawValue) : rawValue;
err.expectedFormat = "non-negative decimal string, e.g. 123.45";
throw err;
}

return parsed;
}

/**
* GET /asset/:code/:issuer/holders
* Returns paginated accounts that hold a trustline for a specific asset.
*
* Query params:
* - limit (number, default: 10, max: 200)
* - cursor (string, pagination cursor from previous response)
* - order ("asc" | "desc", default: "desc")
*
* @route GET /asset/:code/:issuer/holders
* @desc Returns paginated accounts that hold a trustline for a specific asset.
* @param {string} code - Asset code, e.g. USDC
* @param {string} issuer - Asset issuer account ID, e.g. GA5ZSEJYB...
* @param {number} [limit=10] - Maximum number of holders to return.
* @param {string} [cursor] - Horizon paging cursor for pagination.
* @param {string} [order=desc] - Sort direction for holders.
* @param {string} [minBalance] - Optional minimum holder balance filter.
* @param {string} [maxBalance] - Optional maximum holder balance filter.
* @returns {Object[]} List of holders and pagination metadata.
* @example
* GET /asset/USDC/GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/holders
* curl "http://localhost:3000/asset/USDC/GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/holders?minBalance=10&maxBalance=100"
*/
router.get(
"/:code/:issuer/holders",
Expand All @@ -66,10 +106,31 @@
const assetCode = code.toUpperCase();
const { limit, order, cursor } = parsePaginationParams(req.query);

const fresh = isFreshRequest(req.query);
const fresh = req.query.fresh === "true";
const minBalance = parseNonNegativeDecimalQueryParam(
req.query.minBalance,
"minBalance",
);
const maxBalance = parseNonNegativeDecimalQueryParam(
req.query.maxBalance,
"maxBalance",
);

if (minBalance !== null && maxBalance !== null && minBalance > maxBalance) {
const err = new Error(
"Query parameter 'minBalance' must not be greater than 'maxBalance'.",
);
err.isValidation = true;
err.status = 400;
err.field = "minBalance";
err.receivedValue = `${req.query.minBalance}`;
throw err;
}

const hasBalanceFilter = minBalance !== null || maxBalance !== null;
const cacheKey = `asset-holders:${assetCode}:${issuer}:${limit}:${order}:${cursor || ""}`;

if (!fresh) {
if (!fresh && !hasBalanceFilter) {
const cached = cacheService.get(cacheKey);
if (cached) {
res.set("X-Cache", "HIT");
Expand All @@ -90,27 +151,31 @@
const holders = records.map((account) =>
formatAssetHolder(account, assetCode, issuer),
);

const filteredHolders = holders.filter((holder) => {
const balanceValue = Number(holder.balance);
if (minBalance !== null && balanceValue < minBalance) return false;
if (maxBalance !== null && balanceValue > maxBalance) return false;
return true;
});

const lastRecord = records[records.length - 1];
const nextCursor = lastRecord ? lastRecord.paging_token : null;

const meta = {
count: holders.length,
count: filteredHolders.length,
limit,
order,
nextCursor,
hasMore: holders.length === limit,
hasMore: filteredHolders.length === limit,
};

cacheService.set(cacheKey, { holders, meta }, getAssetHoldersCacheTtlSeconds());
if (!hasBalanceFilter) {
cacheService.set(cacheKey, { holders: filteredHolders, meta }, getAssetHoldersCacheTtlSeconds());
}

res.set("X-Cache", "MISS");
return success(res, holders, { meta });
return success(res, {
items: holders,
total: holders.length,
limit,
cursor: nextCursor,
});
return success(res, filteredHolders, { meta });
} catch (err) {
next(err);
}
Expand Down Expand Up @@ -441,7 +506,7 @@
try {
issuerAccount = await server.loadAccount(issuer);
checks.accountExists = { passed: true, detail: "Issuer account exists on the Stellar network." };
} catch (err) {

Check warning on line 509 in src/routes/asset.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'err' is defined but never used
// All subsequent checks depend on account existing
return success(res, { verified: false, checks });
}
Expand All @@ -460,7 +525,7 @@
const response = await axios.get(tomlUrl, { timeout: 5000 });
tomlText = response.data;
checks.tomlReachable = { passed: true, detail: `stellar.toml fetched from ${tomlUrl}.` };
} catch (err) {

Check warning on line 528 in src/routes/asset.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'err' is defined but never used
return success(res, { verified: false, checks });
}

Expand Down
Loading