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
103 changes: 30 additions & 73 deletions docs/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,97 +39,68 @@ Returned when a request parameter or body value fails validation.

## Error Types

| Type | HTTP Status | Description |
| ----------------- | ----------- | --------------------------------------------------------------------- |
| `ValidationError` | 400 | Input validation failed (invalid account ID, asset code, limit, etc.) |
| `HorizonError` | varies | Error propagated from the Stellar Horizon API |
| `InsufficientReserve` | 422 | Account does not have enough XLM to cover the minimum reserve requirement |
| `OfferNotFound` | 404 | A specific offer was requested but does not exist on the network |
| `NotFound` | 404 | Route or resource not found |
| `RateLimitError` | 429 | Too many requests from the same IP |
| `ServerError` | 500 | Unexpected internal error |
| Type | HTTP Status | Description |
| --------------------- | ----------- | --------------------------------------------------------------------- |
| `ValidationError` | 400 | Input validation failed (invalid account ID, asset code, limit, etc.) |
| `HorizonError` | varies | Error propagated from the Stellar Horizon API |
| `InsufficientReserve` | 422 | Account does not have enough XLM to cover the minimum reserve requirement |
| `OfferNotFound` | 404 | A specific offer was requested but does not exist on the network |
| `TrustlineNotFound` | 404 | The requested asset trustline does not exist on the account |
| `NotFound` | 404 | Route or resource not found |
| `RateLimitError` | 429 | Too many requests from the same IP |
| `ServerError` | 500 | Unexpected internal error |

---

### OfferNotFound

Returned when `GET /account/:id/offers?offerId=<id>` is called with an offer ID that does not exist, or when any operation references a non-existent offer.
StellarKit API Error Reference

Every error response follows the same envelope:
**Example response:**

```json
{
"success": false,
"error": {
"type": "ErrorType",
"message": "Human-readable description."
"type": "OfferNotFound",
"message": "Offer '12345' was not found on the Stellar testnet network.",
"suggestion": "The offer may have already been filled, cancelled, or the offer ID may be incorrect."
}
}
```

Some error types include additional fields such as `detail`, `suggestion`, `field`, `receivedValue`, `expectedFormat`, or Horizon-specific `extras`.

For a complete reference of HTTP status codes returned by the API, see [Error Codes](./error-codes.md).

---

## ValidationError

Returned when a request parameter or body value fails validation.

**Status:** `400`

**Example:**

```json
{
"success": false,
"error": {
"type": "<ErrorType>",
"message": "...",
...
}
}
```

## Error Types

| Type | HTTP Status | Description |
| ----------------- | ----------- | --------------------------------------------------------------------- |
| `ValidationError` | 400 | Input validation failed (invalid account ID, asset code, limit, etc.) |
| `HorizonError` | varies | Error propagated from the Stellar Horizon API |
| `InsufficientReserve` | 422 | Account does not have enough XLM to cover the minimum reserve requirement |
| `OfferNotFound` | 404 | A specific offer was requested but does not exist on the network |
| `NotFound` | 404 | Route or resource not found |
| `RateLimitError` | 429 | Too many requests from the same IP |
| `ServerError` | 500 | Unexpected internal error |
### TrustlineNotFound

---
Returned when an endpoint looks up a specific asset trustline for an account and that trustline does not exist.

### OfferNotFound
**Status:** `404`

Returned when `GET /account/:id/offers?offerId=<id>` is called with an offer ID that does not exist, or when any operation references a non-existent offer.
**Affected endpoints:**
- `GET /account/:id/asset-balance/:assetCode/:assetIssuer`
- `GET /account/:id/freeze-status/:assetCode/:assetIssuer`
- `GET /account/:id/can-receive/:assetCode/:assetIssuer`

**Example response:**

```json
{
"success": false,
"error": {
"type": "AccountNotFound",
"message": "Account GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN was not found on the Stellar testnet network.",
"suggestion": "Verify the account address is correct and that the account has been funded."
"type": "TrustlineNotFound",
"message": "Account 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' does not hold a trustline for USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN.",
"suggestion": "The account must establish a trustline before holding this asset."
}
}
```

**Common causes:**
- The public key is valid but the account has not been created on the network
- The account was merged and no longer exists
- Using a testnet key on mainnet or vice versa
- The account has never created a trustline for the requested asset
- The trustline was removed by the account holder
- The wrong issuer address was supplied

**Suggested fix:** Verify the account address. If on testnet, fund the account using Friendbot (`GET /utils/friendbot/:accountId`).
**Suggested fix:** The account must submit a `changeTrust` operation for the asset before it can hold a balance or interact with it.

---

Expand Down Expand Up @@ -298,24 +269,10 @@ Returned when API key authentication is enabled and the request is missing or ha

**Status:** `401`

**Example:**

```json
{
"success": false,
"error": {
"type": "HorizonError",
"title": "Transaction Failed",
"detail": "The transaction failed when submitted to the Stellar network.",
"status": 400,
"extras": { "result_codes": { "transaction": "tx_failed" } },
"code": "tx_failed",
"message": "tx_failed"
}
}
```
---

### ServerError

**HTTP status:** `500` (or another 5xx status when set upstream)

Returned for unexpected errors not covered by another error type. In production, the message is generic (`"An unexpected error occurred."`) to avoid leaking internals; the full message is included outside of production.
Expand Down
8 changes: 4 additions & 4 deletions src/config/cacheConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ const cacheTTL = {
20000
),

/** /soroban/contract/:id/storage — instance storage changes only on contract invocation */
contractStorage: msToSeconds(
process.env.CACHE_TTL_CONTRACT_STORAGE_MS,
15000
/** /network/fee-percentiles */
feePercentiles: msToSeconds(
process.env.CACHE_TTL_FEE_PERCENTILES_MS,
globalFallbackMs
),

/** /account/:id/asset-balance/:code/:issuer — single trustline balance lookup */
Expand Down
5 changes: 5 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,11 @@ app.get("/", (req, res) => {
path: "/network/validators",
description: "Normalised network validator / ledger info",
},
{
method: "GET",
path: "/network/fee-percentiles",
description: "Fee percentile distribution (p10–p99) with accepted fee range and ledger sequence",
},
{
method: "GET",
path: "/network/ledger-timing",
Expand Down
37 changes: 37 additions & 0 deletions src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
}
}

const ACCOUNT_MERGE_FAILURES = {

Check warning on line 42 in src/middleware/errorHandler.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'ACCOUNT_MERGE_FAILURES' is assigned a value but never used
op_does_not_exist: {
message: "Account merge failed because the destination account does not exist.",
suggestion:
Expand All @@ -57,7 +57,7 @@
},
};

function isMergePath(pathname) {

Check warning on line 60 in src/middleware/errorHandler.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'isMergePath' is defined but never used
if (!pathname || typeof pathname !== "string") return false;
return pathname.toLowerCase().includes("merge");
}
Expand Down Expand Up @@ -90,6 +90,29 @@
);
}

/**
* Returns true when err is a network-level connection failure to Horizon.
*/
function isConnectionError(err) {
if (!err) return false;
const code = err.code || (err.cause && err.cause.code);
if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ECONNRESET") return true;
const msg = (err.message || "").toLowerCase();
return msg.includes("econnrefused") || msg.includes("enotfound");
}

/**
* Returns true when a Horizon 404 is for an offer endpoint
* (e.g. GET /offers/123 returned Not Found).
*/
function isOfferNotFoundError(err) {
if (!err || !err.response) return false;
const { status, config } = err.response;
if (status !== 404) return false;
const url = (config && config.url) || "";
return url.includes("/offers/");
}

/**
* Builds a normalised error body for a transaction submission failure.
*/
Expand Down Expand Up @@ -309,6 +332,20 @@
});
}

// TrustlineNotFound errors — specific asset trustline missing on an account
if (err.isTrustlineNotFound) {
logError(404, req, err.message);
return res.status(404).json({
success: false,
error: {
type: "TrustlineNotFound",
message: err.message,
suggestion:
"The account must establish a trustline before holding this asset.",
},
});
}

// InvalidAccountId errors — thrown by validateAccountId(id)
if (err.isInvalidAccountId) {
logError(400, req, err.message);
Expand Down
1 change: 1 addition & 0 deletions src/middleware/sanitize.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,4 @@ function sanitize(req, res, next) {
}

module.exports = sanitize;
module.exports.sanitizeAny = sanitizeAny;
19 changes: 3 additions & 16 deletions src/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
const { success, toISOTimestamp } = require("../utils/response");
const {
makeAccountNotFoundError,
makeClaimableBalanceNotFoundError,

Check warning on line 7 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'makeClaimableBalanceNotFoundError' is assigned a value but never used
makeTrustlineNotFoundError,
} = require("../utils/errors");
const cacheService = require("../services/cache");
const cacheTTL = require("../config/cacheConfig");
const { validateAccountId, validateAssetCode } = require("../utils/validators");
const { accountSummaryRateLimiter } = require("../middleware/rateLimiter");

Check warning on line 13 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'accountSummaryRateLimiter' is assigned a value but never used
const registerParamValidation = require("../middleware/validateRouteParams");
registerParamValidation(router);

Expand All @@ -18,8 +19,8 @@
const { parsePaginationParams } = require("../utils/pagination");
const { validateEffectType } = require("../utils/effectTypes");

const axios = require("axios");

Check warning on line 22 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'axios' is assigned a value but never used
const { Asset } = require("@stellar/stellar-sdk");

Check warning on line 23 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'Asset' is assigned a value but never used
const { normalizeAsset, normalizeAssetFromString } = require("../utils/asset");
const { isNativeAsset, isNonNativeAsset } = require("../utils/assetHelpers");
const { getAssetMetadataFromToml } = require("../utils/tomlResolver");
Expand Down Expand Up @@ -224,7 +225,7 @@
}
try {
toml = await getAssetMetadataFromToml(homeDomain, assetCode);
} catch (_) {

Check warning on line 228 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'_' is defined but never used
toml = null;
}
}
Expand Down Expand Up @@ -768,7 +769,7 @@
if (toml) {
assetDetail = { ...assetDetail, toml };
}
} catch (_) {

Check warning on line 772 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'_' is defined but never used
// TOML resolution failed, keep basic asset detail
}
}
Expand All @@ -785,7 +786,7 @@
}
}

const payments = rawRecords.map((op) => {

Check warning on line 789 in src/routes/account.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'payments' is assigned a value but never used
const isPayment = op.type === "payment";
const assetCode = isPayment ? op.asset_code || "XLM" : "XLM";
const assetIssuer = isPayment ? op.asset_issuer || null : null;
Expand Down Expand Up @@ -1819,11 +1820,7 @@
);

if (!trustline) {
const notFoundErr = new Error(
`Account does not hold asset ${normalizedAssetCode}:${assetIssuer}.`,
);
notFoundErr.status = 404;
throw notFoundErr;
return next(makeTrustlineNotFoundError(id, normalizedAssetCode, assetIssuer));
}

const isAuthorized = trustline.is_authorized !== false;
Expand Down Expand Up @@ -2045,17 +2042,7 @@
);

if (!trustline) {
return success(res, {
accountId: account.id,
asset: normalizeAsset(normalizedAssetCode, assetIssuer, undefined),
canReceive: false,
reasons: ["No trustline exists for this asset."],
trustlineExists: false,
isAuthorized: false,
availableCapacity: 0,
currentBalance: 0,
limit: 0,
});
return next(makeTrustlineNotFoundError(id, normalizedAssetCode, assetIssuer));
}

const isAuthorized = trustline.is_authorized === true;
Expand Down
80 changes: 71 additions & 9 deletions src/routes/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ function isFreshRequest(query) {
return query.fresh === true || query.fresh === "true";
}

const STROOPS_PER_XLM = 10_000_000;
const STROOP_DECIMALS = 7;
const FEE_PERCENTILES_CACHE_TTL = 5;
const PERCENTILE_LEVELS = [10, 20, 30, 50, 70, 90, 95, 99];
const TX_FETCH_LIMIT = 100;

function computePercentile(sortedValues, percentile) {
if (sortedValues.length === 0) return 0;
if (sortedValues.length === 1) return sortedValues[0];
const index = (percentile / 100) * (sortedValues.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) return sortedValues[lower];
const weight = index - lower;
return Math.round(
sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight,
);
}

function buildFeeObject(stroops) {
return {
stroops,
xlm: (stroops / STROOPS_PER_XLM).toFixed(STROOP_DECIMALS),
};
}


/**
* GET /network/validators
Expand Down Expand Up @@ -136,7 +162,15 @@ router.get("/base-fee", async (req, res, next) => {

/**
* GET /network/fee-percentiles
* Returns fee distribution percentiles from recent network activity.
* Returns fee distribution percentiles at multiple levels, the current
* ledger's accepted fee range, and the latest ledger sequence.
*
* Query params:
* - fresh (boolean, default: false) — bypasses cache when set to "true"
*
* @example
* GET /network/fee-percentiles
* GET /network/fee-percentiles?fresh=true
*/
router.get("/fee-percentiles", async (req, res, next) => {
try {
Expand All @@ -152,18 +186,46 @@ router.get("/fee-percentiles", async (req, res, next) => {
}

const feeStats = await server.feeStats();
const ledgerResponse = await server.ledgers().order("desc").limit(1).call();
const latestLedger = (ledgerResponse.records || [])[0] || {};

const feeCharged = feeStats.fee_charged || {};
const feeAccepted = feeStats.fee_accepted || feeCharged;

const minFeeStroops = parseInt(feeAccepted.min || feeCharged.min, 10);
const maxFeeStroops = parseInt(feeAccepted.max || feeCharged.max, 10);

const txResponse = await server
.transactions()
.order("desc")
.limit(TX_FETCH_LIMIT)
.call();
const txRecords = txResponse.records || [];
const fees = txRecords
.map((tx) => parseInt(tx.max_fee, 10))
.filter((f) => f > 0);
fees.sort((a, b) => a - b);

const percentiles = {};
for (const p of PERCENTILE_LEVELS) {
const sourceValue = feeCharged[`p${p}`];
if (sourceValue !== undefined && sourceValue !== null) {
percentiles[`p${p}`] = buildFeeObject(parseInt(sourceValue, 10));
} else {
percentiles[`p${p}`] = buildFeeObject(computePercentile(fees, p));
}
}

const data = {
p10: parseInt(feeStats.fee_charged.p10 || feeStats.fee_charged.min, 10),
p50: parseInt(feeStats.fee_charged.p50 || feeStats.fee_charged.mode, 10),
p90: parseInt(feeStats.fee_charged.p90 || feeStats.fee_charged.p95, 10),
p95: parseInt(feeStats.fee_charged.p95, 10),
p99: parseInt(feeStats.fee_charged.p99 || feeStats.fee_charged.max, 10),
lastLedgerBaseFee: parseInt(feeStats.last_ledger_base_fee, 10),
ledgerCapacityUsage: parseFloat(feeStats.ledger_capacity_usage),
percentiles,
minFee: buildFeeObject(minFeeStroops),
maxFee: buildFeeObject(maxFeeStroops),
ledgerSequence: latestLedger.sequence
? parseInt(latestLedger.sequence, 10)
: null,
};

cacheService.set(cacheKey, data, BASE_FEE_CACHE_TTL);
cacheService.set(cacheKey, data, FEE_PERCENTILES_CACHE_TTL);

res.set("X-Cache", "MISS");
return success(res, data);
Expand Down
Loading
Loading